mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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,
|
||||
|
||||
+39
@@ -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 =
|
||||
|
||||
+36
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user