diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 8fd1fa93b093..bdfa21854a4c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -6227,6 +6227,17 @@ class ChatController internal constructor( key = key, updatedAtMs = obj["updatedAt"].asLongOrNull(), ownerAgentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + classification = obj["classification"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + accountId = obj["accountId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + peerKind = obj["peerKind"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + isMain = obj["isMain"].asBooleanOrNull(), + isBackground = obj["isBackground"].asBooleanOrNull(), + hasClassificationMetadata = + "classification" in obj || + "accountId" in obj || + "peerKind" in obj || + "isMain" in obj || + "isBackground" in obj, displayName = obj["displayName"].asStringOrNull()?.trim(), derivedTitle = obj["derivedTitle"].asStringOrNull()?.trim(), label = obj["label"].asStringOrNull()?.trim(), @@ -7139,6 +7150,12 @@ internal fun mergeChatSessionEntry( return existing.copy( updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs, ownerAgentId = next.ownerAgentId ?: existing.ownerAgentId, + classification = if (next.hasClassificationMetadata) next.classification else existing.classification, + accountId = if (next.hasClassificationMetadata) next.accountId else existing.accountId, + peerKind = if (next.hasClassificationMetadata) next.peerKind else existing.peerKind, + isMain = if (next.hasClassificationMetadata) next.isMain else existing.isMain, + isBackground = if (next.hasClassificationMetadata) next.isBackground else existing.isBackground, + hasClassificationMetadata = existing.hasClassificationMetadata || next.hasClassificationMetadata, displayName = next.displayName ?: existing.displayName, label = next.label ?: existing.label, category = next.category ?: existing.category, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index 8841ff43b5d9..2c507ba6e998 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -197,6 +197,13 @@ data class ChatSessionEntry( val key: String, val updatedAtMs: Long?, val ownerAgentId: String? = null, + val classification: String? = null, + val accountId: String? = null, + val peerKind: String? = null, + val isMain: Boolean? = null, + val isBackground: Boolean? = null, + val hasClassificationMetadata: Boolean = + classification != null || accountId != null || peerKind != null || isMain != null || isBackground != null, val displayName: String? = null, val derivedTitle: String? = null, val label: String? = null, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt index 8051653b996e..622052752caa 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionPolicyTest.kt @@ -180,6 +180,45 @@ class ChatControllerSessionPolicyTest { assertEquals(20L, merged.lastActivityAt) } + @Test + fun sessionMergeRetainsOrReplacesClassificationMetadataAsOneSnapshot() { + val existing = + ChatSessionEntry( + key = "agent:main:telegram:main:direct:491234567890", + updatedAtMs = 1L, + classification = "direct", + accountId = "main", + peerKind = "direct", + isMain = false, + isBackground = false, + ) + + val retained = mergeChatSessionEntry(existing, ChatSessionEntry(key = existing.key, updatedAtMs = 2L)) + assertEquals("direct", retained.classification) + assertEquals("main", retained.accountId) + assertEquals("direct", retained.peerKind) + assertEquals(false, retained.isMain) + assertEquals(false, retained.isBackground) + + val replaced = + mergeChatSessionEntry( + existing, + ChatSessionEntry( + key = existing.key, + updatedAtMs = 3L, + classification = "subagent", + isMain = false, + isBackground = true, + hasClassificationMetadata = true, + ), + ) + assertEquals("subagent", replaced.classification) + assertEquals(null, replaced.accountId) + assertEquals(null, replaced.peerKind) + assertEquals(false, replaced.isMain) + assertEquals(true, replaced.isBackground) + } + @Test fun sessionMergeReplacesRunMetadataAsOneSnapshot() { val existing = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt index 49c21bca13c2..90a20b61adcb 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionSearchTest.kt @@ -87,6 +87,42 @@ class ChatControllerSessionSearchTest { assertEquals("200", paramField(searchCall.paramsJson, "limit")) } + @Test + fun fetchSessionListPreservesGatewayClassificationFacts() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respond("sessions.list") { + buildJsonObject { + put( + "sessions", + JsonArray( + listOf( + buildJsonObject { + put("key", JsonPrimitive("agent:main:telegram:main:direct:491234567890")) + put("updatedAt", JsonPrimitive(100)) + put("agentId", JsonPrimitive("main")) + put("classification", JsonPrimitive("direct")) + put("accountId", JsonPrimitive("main")) + put("peerKind", JsonPrimitive("direct")) + put("isMain", JsonPrimitive(false)) + put("isBackground", JsonPrimitive(false)) + }, + ), + ), + ) + }.toString() + } + val controller = newController(gateway) + + val row = controller.fetchSessionList(search = null, archived = false).single() + assertEquals("main", row.ownerAgentId) + assertEquals("direct", row.classification) + assertEquals("main", row.accountId) + assertEquals("direct", row.peerKind) + assertEquals(false, row.isMain) + assertEquals(false, row.isBackground) + } + @Test fun fetchSessionListFallsBackToLocalFilterWhenOffline() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt index 7d9158fb4807..86b6fc15a929 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.util.concurrent.CopyOnWriteArrayList internal val chatControllerTestJson = Json { ignoreUnknownKeys = true } @@ -115,7 +116,9 @@ internal class ScriptedGateway( val paramsJson: String?, ) - val calls = mutableListOf() + // Controllers can retry from a background dispatcher while tests inspect calls. + // Snapshot iteration keeps assertions from racing concurrent request recording. + val calls = CopyOnWriteArrayList() private val handlers = mutableMapOf String>() /** Client-generated run id captured from the latest chat.send params. */ diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift index 1f39b4f465d5..1c6350b2e4e4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift @@ -357,6 +357,13 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl public var kind: String? public var displayName: String? public var derivedTitle: String? + /// Non-sensitive facts derived by the Gateway from the canonical session route. + public var classification: String? + public var agentId: String? + public var accountId: String? + public var peerKind: String? + public var isMain: Bool? + public var isBackground: Bool? public var label: String? public var category: String? public var pinned: Bool? @@ -418,6 +425,12 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl key: String, kind: String?, displayName: String?, + classification: String? = nil, + agentId: String? = nil, + accountId: String? = nil, + peerKind: String? = nil, + isMain: Bool? = nil, + isBackground: Bool? = nil, surface: String?, subject: String?, room: String?, @@ -476,6 +489,12 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl self.kind = kind self.displayName = displayName self.derivedTitle = derivedTitle + self.classification = classification + self.agentId = agentId + self.accountId = accountId + self.peerKind = peerKind + self.isMain = isMain + self.isBackground = isBackground self.label = label self.category = category self.pinned = pinned diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 43fbbaec87e2..91f609f47a23 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5071,6 +5071,12 @@ public struct SessionRow: Codable, Sendable { public let derivedtitle: String? public let lastmessagepreview: String? public let channel: String? + public let classification: String? + public let agentid: String? + public let accountid: String? + public let peerkind: String? + public let ismain: Bool? + public let isbackground: Bool? public let chattype: AnyCodable? public let updatedat: AnyCodable? public let archived: Bool? @@ -5128,6 +5134,12 @@ public struct SessionRow: Codable, Sendable { derivedtitle: String? = nil, lastmessagepreview: String? = nil, channel: String? = nil, + classification: String? = nil, + agentid: String? = nil, + accountid: String? = nil, + peerkind: String? = nil, + ismain: Bool? = nil, + isbackground: Bool? = nil, chattype: AnyCodable? = nil, updatedat: AnyCodable? = nil, archived: Bool? = nil, @@ -5184,6 +5196,12 @@ public struct SessionRow: Codable, Sendable { self.derivedtitle = derivedtitle self.lastmessagepreview = lastmessagepreview self.channel = channel + self.classification = classification + self.agentid = agentid + self.accountid = accountid + self.peerkind = peerkind + self.ismain = ismain + self.isbackground = isbackground self.chattype = chattype self.updatedat = updatedat self.archived = archived @@ -5242,6 +5260,12 @@ public struct SessionRow: Codable, Sendable { case derivedtitle = "derivedTitle" case lastmessagepreview = "lastMessagePreview" case channel + case classification + case agentid = "agentId" + case accountid = "accountId" + case peerkind = "peerKind" + case ismain = "isMain" + case isbackground = "isBackground" case chattype = "chatType" case updatedat = "updatedAt" case archived diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift index 43f909ed24da..497d3c2b2e7c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionSidebarModelTests.swift @@ -383,6 +383,10 @@ struct ChatSessionSidebarModelTests { let data = try #require(""" { "key": "agent:main:child", + "classification": "subagent", + "agentId": "main", + "isMain": false, + "isBackground": true, "parentSessionKey": "agent:main:main", "spawnedBy": "agent:main:controller", "childSessions": ["agent:main:grandchild"], @@ -401,6 +405,10 @@ struct ChatSessionSidebarModelTests { let entry = try JSONDecoder().decode(OpenClawChatSessionEntry.self, from: data) #expect(entry.parentSessionKey == "agent:main:main") + #expect(entry.classification == "subagent") + #expect(entry.agentId == "main") + #expect(entry.isMain == false) + #expect(entry.isBackground == true) #expect(entry.spawnedBy == "agent:main:controller") #expect(entry.childSessions == ["agent:main:grandchild"]) #expect(entry.status == "running") diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index c4986a2b904b..cf48c01a78d6 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -31,6 +31,7 @@ export { type SessionRow, type SessionToolOverrides, } from "./schema/sessions-row.js"; +export * from "./schema/session-classification.js"; export * from "./schema/sessions-suggestions.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; diff --git a/packages/gateway-protocol/src/schema/session-classification.test.ts b/packages/gateway-protocol/src/schema/session-classification.test.ts new file mode 100644 index 000000000000..bdf30b4fe648 --- /dev/null +++ b/packages/gateway-protocol/src/schema/session-classification.test.ts @@ -0,0 +1,23 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { SessionClassificationSchema, SessionPeerKindSchema } from "./session-classification.js"; +import { SessionRowSchema } from "./sessions-row.js"; + +describe("session classification schemas", () => { + it("accepts flattened derived facts through the canonical row schema", () => { + expect(Value.Check(SessionClassificationSchema, "plugin-owned")).toBe(true); + expect(Value.Check(SessionPeerKindSchema, "topic")).toBe(true); + expect( + Value.Check(SessionRowSchema, { + key: "agent:main:telegram:main:direct:peer", + kind: "direct", + classification: "direct", + agentId: "main", + accountId: "main", + peerKind: "direct", + isMain: false, + isBackground: false, + }), + ).toBe(true); + }); +}); diff --git a/packages/gateway-protocol/src/schema/session-classification.ts b/packages/gateway-protocol/src/schema/session-classification.ts new file mode 100644 index 000000000000..709356c20ce0 --- /dev/null +++ b/packages/gateway-protocol/src/schema/session-classification.ts @@ -0,0 +1,16 @@ +import type { Static } from "typebox"; +import { NonEmptyString } from "./primitives.js"; + +/** + * Stable, non-sensitive classification for a session row. + * + * The taxonomy remains open so newer Gateways can add classifications without + * making otherwise compatible older clients reject the row. + */ +export const SessionClassificationSchema = NonEmptyString; + +/** Non-sensitive peer category derived from the session route, when known. */ +export const SessionPeerKindSchema = NonEmptyString; + +export type SessionClassification = Static; +export type SessionPeerKind = Static; diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index ea035ce32b87..e314bd57f57c 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -2,6 +2,7 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; +import { SessionClassificationSchema, SessionPeerKindSchema } from "./session-classification.js"; import { SessionSharingRoleSchema, SessionVisibilitySchema } from "./sessions-sharing-values.js"; export const SessionToolOverridesSchema = closedObject({ @@ -40,6 +41,13 @@ export const SessionRowSchema = Type.Object( derivedTitle: Type.Optional(Type.String()), lastMessagePreview: Type.Optional(Type.String()), channel: Type.Optional(Type.String()), + /** Stable non-sensitive facts derived from the canonical session route. */ + classification: Type.Optional(SessionClassificationSchema), + agentId: Type.Optional(NonEmptyString), + accountId: Type.Optional(NonEmptyString), + peerKind: Type.Optional(SessionPeerKindSchema), + isMain: Type.Optional(Type.Boolean()), + isBackground: Type.Optional(Type.Boolean()), chatType: Type.Optional( Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]), ), diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 088ac1383457..23129dad272e 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -84,12 +84,17 @@ test("lists and patches session store via sessions.* RPC", async () => { sessionId: "sess-group", updatedAt: stale, totalTokens: 50, + origin: { label: "U123ABC45" }, }, "agent:main:subagent:one": { sessionId: "sess-subagent", updatedAt: stale, spawnedBy: "agent:main:main", }, + "agent:main:telegram:main:direct:491234567890": { + sessionId: "sess-direct", + updatedAt: stale, + }, global: { sessionId: "sess-global", updatedAt: now - 10_000, @@ -201,6 +206,12 @@ test("lists and patches session store via sessions.* RPC", async () => { verboseLevel?: string; lastAccountId?: string; deliveryContext?: { channel?: string; to?: string; accountId?: string }; + classification?: string; + agentId?: string; + accountId?: string; + peerKind?: string; + isMain?: boolean; + isBackground?: boolean; }>; }>("sessions.list", { includeGlobal: false, includeUnknown: false }); @@ -220,6 +231,42 @@ test("lists and patches session store via sessions.* RPC", async () => { accountId: "work", threadId: "1737500000.123456", }); + expect(main).toMatchObject({ + classification: "main", + agentId: "main", + isMain: true, + isBackground: false, + }); + const group = list1.payload?.sessions.find((s) => s.key === "agent:main:discord:group:dev"); + expect(group).toMatchObject({ classification: "group", peerKind: "group" }); + expect( + JSON.stringify({ + classification: group?.classification, + agentId: group?.agentId, + accountId: group?.accountId, + peerKind: group?.peerKind, + isMain: group?.isMain, + isBackground: group?.isBackground, + }), + ).not.toContain("U123ABC45"); + const direct = list1.payload?.sessions.find( + (s) => s.key === "agent:main:telegram:main:direct:491234567890", + ); + expect(direct).toMatchObject({ + classification: "direct", + accountId: "main", + peerKind: "direct", + }); + expect( + JSON.stringify({ + classification: direct?.classification, + agentId: direct?.agentId, + accountId: direct?.accountId, + peerKind: direct?.peerKind, + isMain: direct?.isMain, + isBackground: direct?.isBackground, + }), + ).not.toContain("491234567890"); const active = await directSessionReq<{ sessions: Array<{ key: string }>; @@ -386,6 +433,8 @@ test("lists and patches session store via sessions.* RPC", async () => { sendPolicy?: string; label?: string; displayName?: string; + classification?: string; + isBackground?: boolean; }>; }>("sessions.list", {}); expect(list2.ok).toBe(true); @@ -396,6 +445,10 @@ test("lists and patches session store via sessions.* RPC", async () => { const subagent = list2.payload?.sessions.find((s) => s.key === "agent:main:subagent:one"); expect(subagent?.label).toBe("Briefing"); expect(subagent?.displayName).toBe("Briefing"); + expect(subagent).toMatchObject({ + classification: "subagent", + isBackground: true, + }); const clearedVerbose = await directSessionReq<{ ok: true; key: string }>("sessions.patch", { key: "agent:main:main", diff --git a/src/gateway/session-classification.test.ts b/src/gateway/session-classification.test.ts new file mode 100644 index 000000000000..1998fe202d0c --- /dev/null +++ b/src/gateway/session-classification.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sessionClassificationForRow } from "./session-classification.js"; + +function entry(overrides: Partial = {}): SessionEntry { + return { sessionId: "session", updatedAt: 1, ...overrides }; +} + +function classification(params: { + key: string; + isMain: boolean; + agentId?: string; + entry?: SessionEntry; +}) { + const cfg = { + agents: { list: [{ id: "main", default: true }] }, + ...(params.isMain ? {} : { session: { mainKey: "not-main" } }), + } as OpenClawConfig; + return sessionClassificationForRow(cfg, params.key, params.agentId ?? "main", params.entry); +} + +describe("sessionClassificationForRow", () => { + it.each([ + ["agent:main:main", true, "main", false], + ["agent:main:dashboard:01234567-89ab-cdef-0123-456789abcdef", false, "dashboard", false], + ["agent:main:tui-01234567-89ab-cdef-0123-456789abcdef", false, "tui", false], + ["agent:main:subagent:child", false, "subagent", true], + ["agent:main:acp:child", false, "acp", true], + ["agent:main:cron:job", false, "cron", true], + ["agent:main:hook:run", false, "hook", true], + ["agent:main:harness:codex:supervision:thread", false, "harness", true], + ["agent:main:voice:call:123", false, "voice", false], + ["agent:main:dreaming-narrative-rem-workspace", false, "dreaming", true], + ["agent:main:commitments:run", false, "system", true], + ] as const)("classifies %s", (key, isMain, expected, isBackground) => { + expect(classification({ key, isMain, entry: entry() })).toMatchObject({ + classification: expected, + isBackground, + isMain, + }); + }); + + it("canonicalizes structural casing while preserving opaque peer ids", () => { + expect( + classification({ + key: "AGENT:MAIN:MAIN", + isMain: true, + entry: entry(), + }), + ).toMatchObject({ classification: "main", agentId: "main", isMain: true }); + expect( + classification({ + key: "AGENT:MAIN:TELEGRAM:MAIN:DIRECT:491234567890", + isMain: false, + entry: entry(), + }), + ).toMatchObject({ classification: "direct", accountId: "main", peerKind: "direct" }); + expect( + sessionClassificationForRow( + { session: { scope: "global" } } as OpenClawConfig, + "GLOBAL", + "main", + entry(), + ), + ).toMatchObject({ classification: "global", isMain: true }); + expect(classification({ key: "UNKNOWN", isMain: false, entry: entry() })).toMatchObject({ + classification: "unknown", + isMain: false, + }); + }); + + it("projects routing facts without exposing the peer id", () => { + const result = classification({ + key: "agent:main:telegram:main:direct:491234567890", + isMain: false, + entry: entry(), + }); + + expect(result).toMatchObject({ + classification: "direct", + agentId: "main", + accountId: "main", + peerKind: "direct", + isBackground: false, + }); + expect(JSON.stringify(result)).not.toContain("491234567890"); + }); + + it("lets persisted spawn ownership override a delivery-shaped key", () => { + expect( + classification({ + key: "agent:main:telegram:main:direct:491234567890", + isMain: false, + entry: entry({ spawnedBy: "agent:main:main" }), + }), + ).toMatchObject({ + classification: "subagent", + accountId: "main", + peerKind: "direct", + isBackground: true, + }); + }); + + it("classifies stored chat type when a provider key is not a delivery route", () => { + expect( + classification({ + key: "provider-owned-room-key", + isMain: false, + entry: entry({ chatType: "group" }), + }), + ).toMatchObject({ classification: "group" }); + }); + + it("uses the persisted heartbeat marker instead of guessing from a suffix", () => { + expect( + classification({ key: "agent:main:alerts:heartbeat", isMain: false, entry: entry() }) + .classification, + ).toBe("custom"); + expect( + classification({ + key: "agent:main:alerts:heartbeat", + isMain: false, + entry: entry({ heartbeatIsolatedBaseSessionKey: "agent:main:alerts" }), + }), + ).toMatchObject({ classification: "heartbeat", isBackground: true }); + }); +}); diff --git a/src/gateway/session-classification.ts b/src/gateway/session-classification.ts new file mode 100644 index 000000000000..92a0ec9ebf0e --- /dev/null +++ b/src/gateway/session-classification.ts @@ -0,0 +1,153 @@ +/** Builds non-sensitive, client-useful classification facts for Gateway session rows. */ +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; +import type { + SessionClassification, + SessionPeerKind, +} from "../../packages/gateway-protocol/src/index.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseAgentSessionKey, parseSessionDeliveryRoute } from "../routing/session-key.js"; +import { + isAcpSessionKey, + isCronSessionKey, + isSubagentSessionKey, + normalizeSessionKeyPreservingOpaquePeerIds, + parseThreadSessionSuffix, +} from "../sessions/session-key-utils.js"; + +const BACKGROUND_CLASSIFICATIONS = new Set([ + "acp", + "cron", + "dreaming", + "harness", + "heartbeat", + "hook", + "subagent", + "system", +]); + +type GatewaySessionClassification = { + classification: SessionClassification; + agentId?: string; + accountId?: string; + peerKind?: SessionPeerKind; + isMain: boolean; + // Classification only: this does not change session visibility, sharing, + // retention, or authorization semantics. + isBackground: boolean; +}; + +function classifyRest(rest: string): SessionClassification { + const normalized = normalizeLowercaseStringOrEmpty(rest); + if (normalized.startsWith("dashboard:")) { + return "dashboard"; + } + if (normalized.startsWith("tui-")) { + return "tui"; + } + if (normalized.startsWith("explicit:")) { + return "explicit"; + } + if (normalized.startsWith("hook:")) { + return "hook"; + } + if (normalized.startsWith("harness:")) { + return "harness"; + } + if (normalized.startsWith("voice:")) { + return "voice"; + } + if (normalized.startsWith("dreaming-narrative-")) { + return "dreaming"; + } + if ( + normalized === "boot" || + normalized.startsWith("commitments:") || + normalized.startsWith("internal-session-effects:") + ) { + return "system"; + } + return "custom"; +} + +/** + * Derive portable facts without exposing peer ids, transcript text, absolute + * paths, or other data that is not already a session-list field. + */ +export function sessionClassificationForRow( + cfg: OpenClawConfig, + key: string, + agentId: string, + entry?: SessionEntry, +): GatewaySessionClassification { + const canonicalKey = normalizeSessionKeyPreservingOpaquePeerIds(key); + const isMain = + canonicalKey === "global" + ? cfg.session?.scope === "global" + : canonicalKey === resolveAgentMainSessionKey({ cfg, agentId }); + const parsedAgent = parseAgentSessionKey(canonicalKey); + const resolvedAgentId = parsedAgent?.agentId ?? normalizeOptionalString(agentId); + const rest = parsedAgent?.rest ?? canonicalKey; + const parsedThread = parseThreadSessionSuffix(canonicalKey); + const route = parseSessionDeliveryRoute(canonicalKey); + const hasLegacyDirectPeer = /^(?:direct|dm):.+$/i.test( + parseAgentSessionKey(parsedThread.baseSessionKey)?.rest ?? "", + ); + const hasDirectPeer = + route?.peerKind === "direct" || + route?.peerKind === "dm" || + hasLegacyDirectPeer || + entry?.chatType === "direct"; + + let classification: SessionClassification; + if (canonicalKey === "global") { + classification = "global"; + } else if (canonicalKey === "unknown") { + classification = "unknown"; + } else if (entry?.heartbeatIsolatedBaseSessionKey) { + classification = "heartbeat"; + } else if (isMain) { + classification = "main"; + } else if (entry?.spawnedBy) { + // Spawn ownership survives delivery-shaped keys; classify the child before + // route parsing so clients do not present background work as a chat. + classification = "subagent"; + } else if (isSubagentSessionKey(canonicalKey)) { + classification = "subagent"; + } else if (isAcpSessionKey(canonicalKey)) { + classification = "acp"; + } else if (isCronSessionKey(canonicalKey)) { + classification = "cron"; + } else if (parsedThread.threadId) { + classification = "thread"; + } else if (route?.peerKind === "group") { + classification = "group"; + } else if (route?.peerKind === "channel") { + classification = "channel"; + } else if (hasDirectPeer) { + classification = "direct"; + } else if ( + entry?.chatType === "direct" || + entry?.chatType === "group" || + entry?.chatType === "channel" + ) { + classification = entry.chatType; + } else { + classification = classifyRest(rest); + } + + const peerKind: SessionPeerKind | undefined = + route?.peerKind === "dm" || hasLegacyDirectPeer ? "direct" : route?.peerKind; + return { + classification, + ...(resolvedAgentId ? { agentId: resolvedAgentId } : {}), + ...(route?.accountId ? { accountId: route.accountId } : {}), + ...(peerKind ? { peerKind } : {}), + isMain, + isBackground: BACKGROUND_CLASSIFICATIONS.has(classification), + }; +} diff --git a/src/gateway/session-utils-creators.test.ts b/src/gateway/session-utils-creators.test.ts index 9f3d0aafb97a..969b08476de6 100644 --- a/src/gateway/session-utils-creators.test.ts +++ b/src/gateway/session-utils-creators.test.ts @@ -317,7 +317,7 @@ it("preserves legacy list output across visibility, scope, creator, and search f ); }); -it("keeps the serialized list response byte-identical to the legacy filter path", () => { +it("keeps the serialized list response deterministic for the current filter path", () => { vi.spyOn(Date, "now").mockReturnValue(1_000_000); const result = listSessionsFromStore({ cfg: { @@ -343,11 +343,11 @@ it("keeps the serialized list response byte-identical to the legacy filter path" }, storePath: "/tmp/openclaw-session-byte-parity", }); - const legacySerializedResponse = [ + const expectedSerializedResponse = [ '{"ts":1000000,"path":"/tmp/openclaw-session-byte-parity","count":1,"totalCount":1,"limitApplied":100,"nextOffset":null,"hasMore":false,"creators":[{"id":"creator-b"}]', ',"defaults":{"modelProvider":"openai","model":"gpt-5.4","contextTokens":200000,"agentRuntime":{"id":"codex","source":"implicit"},"thinkingLevels":[{"id":"off","label":"off"},{"id":"minimal","label":"minimal"},{"id":"low","label":"low"},{"id":"medium","label":"medium"},{"id":"high","label":"high"},{"id":"xhigh","label":"xhigh"}],"thinkingOptions":["off","minimal","low","medium","high","xhigh"],"thinkingDefault":"off"}', - ',"sessions":[{"key":"global","visibility":"shared","createdActor":{"type":"system","id":"creator-b"},"kind":"global","subject":"needle global","updatedAt":999999,"archived":false,"pinned":false,"unread":false,"sessionId":"session-global","thinkingLevels":[{"id":"off","label":"off"},{"id":"minimal","label":"minimal"},{"id":"low","label":"low"},{"id":"medium","label":"medium"},{"id":"high","label":"high"},{"id":"xhigh","label":"xhigh"}],"thinkingOptions":["off","minimal","low","medium","high","xhigh"],"thinkingDefault":"off","effectiveFastMode":false,"effectiveFastModeSource":"default","fastAutoOnSeconds":60,"totalTokens":1,"totalTokensFresh":true,"estimatedCostUsd":0,"effectiveResponseUsage":"off","effectiveQueueMode":"steer","modelProvider":"openai","model":"gpt-5.4","agentRuntime":{"id":"codex","source":"implicit"},"contextTokens":100}]}', + ',"sessions":[{"key":"global","visibility":"shared","createdActor":{"type":"system","id":"creator-b"},"kind":"global","classification":"global","agentId":"main","isMain":false,"isBackground":false,"subject":"needle global","updatedAt":999999,"archived":false,"pinned":false,"unread":false,"sessionId":"session-global","thinkingLevels":[{"id":"off","label":"off"},{"id":"minimal","label":"minimal"},{"id":"low","label":"low"},{"id":"medium","label":"medium"},{"id":"high","label":"high"},{"id":"xhigh","label":"xhigh"}],"thinkingOptions":["off","minimal","low","medium","high","xhigh"],"thinkingDefault":"off","effectiveFastMode":false,"effectiveFastModeSource":"default","fastAutoOnSeconds":60,"totalTokens":1,"totalTokensFresh":true,"estimatedCostUsd":0,"effectiveResponseUsage":"off","effectiveQueueMode":"steer","modelProvider":"openai","model":"gpt-5.4","agentRuntime":{"id":"codex","source":"implicit"},"contextTokens":100}]}', ].join(""); - expect(JSON.stringify(result)).toBe(legacySerializedResponse); + expect(JSON.stringify(result)).toBe(expectedSerializedResponse); }); diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 0a0fd68d2629..b3d8a9ded6dc 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -33,6 +33,7 @@ import { getUserProfileListItem } from "../state/user-profiles.js"; import { projectSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; import { sessionHasAutomation } from "./session-automation-index.js"; +import { sessionClassificationForRow } from "./session-classification.js"; import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; import { readSessionTitleFieldsFromTranscript as readScopedSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; import type { @@ -431,6 +432,7 @@ export function buildGatewaySessionRow(params: { label: entry?.label, category: entry?.category, boardFace: entry?.boardFace, + ...sessionClassificationForRow(cfg, key, sessionAgentId, entry), displayName, derivedTitle, lastMessagePreview, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 2a03f74671fc..187f7ff30d3d 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1467,6 +1467,51 @@ describe("gateway session utils", () => { expect(opaqueRow.displayName).toMatch(/^telegram:/); }); + test("buildGatewaySessionRow projects flat classification facts without group tokens", () => { + const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; + const subagentEntry = { + displayName: "Research", + } as SessionEntry; + const subagentRow = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:subagent:one": subagentEntry }, + key: "agent:main:subagent:one", + entry: subagentEntry, + }); + expect(subagentRow).toMatchObject({ + classification: "subagent", + agentId: "main", + isBackground: true, + }); + + const groupEntry = { + chatType: "group", + displayName: "telegram:g-private-token", + } as SessionEntry; + const groupRow = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:telegram:group:99": groupEntry }, + key: "agent:main:telegram:group:99", + entry: groupEntry, + }); + expect(groupRow).toMatchObject({ + classification: "group", + peerKind: "group", + }); + expect( + JSON.stringify({ + classification: groupRow.classification, + agentId: groupRow.agentId, + accountId: groupRow.accountId, + peerKind: groupRow.peerKind, + isMain: groupRow.isMain, + isBackground: groupRow.isBackground, + }), + ).not.toContain("private-token"); + }); + test("buildGatewaySessionRow projects worktree and execNode bindings", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; const entry: SessionEntry = { diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 5b0efa0d7cc8..417892557d47 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -3,6 +3,8 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { SessionCreatedActor, + SessionClassification, + SessionPeerKind, SessionPlacement, SessionRow, SessionSharingRole, @@ -53,6 +55,13 @@ type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; + /** Optional display metadata; never authoritative for session access or lifecycle. */ + classification?: SessionClassification; + agentId?: string; + accountId?: string; + peerKind?: SessionPeerKind; + isMain?: boolean; + isBackground?: boolean; /** Additive collaboration state; absent on older gateways. */ visibility?: SessionVisibility; /** Caller-relative role used by Control UI participation controls. */ diff --git a/test/scripts/openclaw-live-updater.test.ts b/test/scripts/openclaw-live-updater.test.ts index 6e84a24aa231..8a1c88b1fccc 100644 --- a/test/scripts/openclaw-live-updater.test.ts +++ b/test/scripts/openclaw-live-updater.test.ts @@ -74,6 +74,23 @@ function fetchFixtureMain(checkout: string, remote: string) { git(checkout, "fetch", origin, `main:refs/remotes/${remote}/main`); } +async function runFixtureManagedCommand({ + args, + bin, + cwd, + env, +}: { + args: string[]; + bin: string; + cwd?: string; + env?: NodeJS.ProcessEnv; +}) { + // Fixtures need a real fast-forward, but do not need production process-tree timing. + // Keep that contract in managed-child-process tests so fixture assertions stay isolated. + execFileSync(bin, args, { cwd, env, stdio: "ignore" }); + return 0; +} + function maintainFixture( options: Record, dependencies: Record = {}, @@ -111,6 +128,7 @@ function maintainFixture( proofSource: "fixture", }), readLaunchdEnvironment: () => null, + runManagedCommand: runFixtureManagedCommand, waitForGatewayProcess: () => {}, ...dependencies, }); diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index b2273b9dfbe0..80276a0c8ef5 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -454,6 +454,12 @@ type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; + classification?: import("../../../packages/gateway-protocol/src/index.js").SessionClassification; + agentId?: string; + accountId?: string; + peerKind?: import("../../../packages/gateway-protocol/src/index.js").SessionPeerKind; + isMain?: boolean; + isBackground?: boolean; visibility?: SessionVisibility; sharingRole?: SessionSharingRole; incognito?: true;