feat: expose session classification facts (#106832)

* feat: expose session presentation metadata

* fix: restrict session titles to safe display metadata

* fix: preserve saved session display names

* fix(protocol): tolerate future session presentation values

* fix(gateway): keep direct peer names out of presentation

* fix(protocol): connect session presentation to native rows

* fix(gateway): canonicalize session presentation metadata

* fix(gateway): satisfy session presentation CI checks

* feat(gateway): flatten session classification facts

* fix: repair session classification generated models

* fix(gateway): canonicalize session classification keys

* fix(gateway): classify spawned delivery sessions

* fix(android): retain session classification facts

* test(android): make scripted gateway calls concurrency-safe

* test(live-updater): isolate fixture git commands
This commit is contained in:
Dallin Romney
2026-08-07 14:56:56 +08:00
committed by GitHub
parent 1f1333dfec
commit 2a0bbd23f5
21 changed files with 620 additions and 5 deletions
@@ -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,
@@ -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,
@@ -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 =
@@ -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 {
@@ -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<Call>()
// Controllers can retry from a background dispatcher while tests inspect calls.
// Snapshot iteration keeps assertions from racing concurrent request recording.
val calls = CopyOnWriteArrayList<Call>()
private val handlers = mutableMapOf<String, suspend (paramsJson: String?) -> String>()
/** Client-generated run id captured from the latest chat.send params. */
@@ -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
@@ -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
@@ -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")
+1
View File
@@ -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";
@@ -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);
});
});
@@ -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<typeof SessionClassificationSchema>;
export type SessionPeerKind = Static<typeof SessionPeerKindSchema>;
@@ -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")]),
),
@@ -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",
+128
View File
@@ -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> = {}): 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 });
});
});
+153
View File
@@ -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<SessionClassification>([
"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),
};
}
+4 -4
View File
@@ -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);
});
+2
View File
@@ -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,
+45
View File
@@ -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 = {
+9
View File
@@ -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. */
@@ -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<string, unknown>,
dependencies: Record<string, unknown> = {},
@@ -111,6 +128,7 @@ function maintainFixture(
proofSource: "fixture",
}),
readLaunchdEnvironment: () => null,
runManagedCommand: runFixtureManagedCommand,
waitForGatewayProcess: () => {},
...dependencies,
});
+6
View File
@@ -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;