From 81f291db58093dfd4d36aecf3e39073133689e4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 23:56:30 -0400 Subject: [PATCH] perf(sessions): watermark-cache derived titles and single-pass list filtering (#114842) * perf(sessions): watermark-cache derived titles and single-pass list filtering * refactor(sessions): split title reader and transcript watermark modules The origin/main merge pushed session-transcript-readers.ts and session-accessor.sqlite-active-events.ts over the max-lines ceiling. Move the title-read feature (bounded probes + watermark cache) into session-transcript-title-reader.ts and the watermark reader into session-accessor.sqlite-transcript-watermark.ts; update callers. --- src/agents/tools/sessions-list-tool.ts | 2 +- ...on-accessor.sqlite-transcript-watermark.ts | 46 +++ src/config/sessions/session-accessor.ts | 4 + .../server-methods/session-discussion.test.ts | 2 +- .../server-methods/session-discussion.ts | 2 +- .../session-title-inbound-metadata.test.ts | 2 +- .../session-transcript-readers.test.ts | 81 ++++- src/gateway/session-transcript-readers.ts | 142 +-------- .../session-transcript-title-reader.ts | 199 ++++++++++++ src/gateway/session-utils-creators.test.ts | 195 +++++++++++- src/gateway/session-utils-list.ts | 283 +++++++++--------- src/gateway/session-utils-projection.ts | 11 +- src/gateway/session-utils-row.ts | 2 +- 13 files changed, 674 insertions(+), 297 deletions(-) create mode 100644 src/config/sessions/session-accessor.sqlite-transcript-watermark.ts create mode 100644 src/gateway/session-transcript-title-reader.ts diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 8add81d4b232..2723907b262f 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -13,7 +13,7 @@ import { getRuntimeConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; -import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session-transcript-readers.js"; +import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session-transcript-title-reader.js"; import { deriveSessionTitle } from "../../gateway/session-utils.js"; import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { getSessionStateVersions } from "../../sessions/session-state-events.js"; diff --git a/src/config/sessions/session-accessor.sqlite-transcript-watermark.ts b/src/config/sessions/session-accessor.sqlite-transcript-watermark.ts new file mode 100644 index 000000000000..1e43802ab46f --- /dev/null +++ b/src/config/sessions/session-accessor.sqlite-transcript-watermark.ts @@ -0,0 +1,46 @@ +// Transcript watermark reader: the (generation, max seq) token pair that +// validates transcript-derived caches (derived titles, branch summaries). +// Kept apart from the active-events reader so cache validation stays a +// dependency-light import for gateway callers. +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; +import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; +import type { SessionTranscriptReadScope } from "./session-accessor.sqlite-contract.js"; +import { + resolveSqliteTranscriptReadScope, + toDatabaseOptions, +} from "./session-accessor.sqlite-scope.js"; + +type WatermarkDatabase = Pick< + OpenClawAgentKyselyDatabase, + "transcript_events" | "transcript_rewrite_watermarks" +>; + +export type SessionTranscriptWatermark = { + generation: string | null; + maxSeq: number | null; +}; + +/** Reads the append and rewrite tokens that validate transcript-derived caches. */ +export function readSessionTranscriptWatermark( + scope: SessionTranscriptReadScope, +): SessionTranscriptWatermark { + const resolved = resolveSqliteTranscriptReadScope(scope); + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + const db = getNodeSqliteKysely(database.db); + const maxSeq = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("transcript_events") + .select((eb) => eb.fn.max("seq").as("max_seq")) + .where("session_id", "=", resolved.sessionId), + )?.max_seq; + const generation = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("transcript_rewrite_watermarks") + .select("generation") + .where("session_id", "=", resolved.sessionId), + )?.generation; + return { generation: generation ?? null, maxSeq: maxSeq ?? null }; +} diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index fcb19a46de41..b3369c97db2a 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -232,6 +232,10 @@ export type { SessionTranscriptMessageEvent, SessionTranscriptMessageEventPage, } from "./session-accessor.sqlite-active-events.js"; +export { + readSessionTranscriptWatermark, + type SessionTranscriptWatermark, +} from "./session-accessor.sqlite-transcript-watermark.js"; export { resolveConcreteSessionStorePath, resolveSessionTranscriptReadTarget, diff --git a/src/gateway/server-methods/session-discussion.test.ts b/src/gateway/server-methods/session-discussion.test.ts index a55bda2aaed6..693d32724486 100644 --- a/src/gateway/server-methods/session-discussion.test.ts +++ b/src/gateway/server-methods/session-discussion.test.ts @@ -32,7 +32,7 @@ vi.mock("../dashboard-session-title.js", async (importOriginal) => { mocks.maybeGenerateSessionTitle.mockImplementation(actual.maybeGenerateSessionTitle); return { ...actual, maybeGenerateSessionTitle: mocks.maybeGenerateSessionTitle }; }); -vi.mock("../session-transcript-readers.js", () => ({ +vi.mock("../session-transcript-title-reader.js", () => ({ readSessionTitleFieldsFromTranscript: mocks.readSessionTitleFields, })); vi.mock("./session-change-event.js", () => ({ diff --git a/src/gateway/server-methods/session-discussion.ts b/src/gateway/server-methods/session-discussion.ts index 4605c374dc11..8460db7de82a 100644 --- a/src/gateway/server-methods/session-discussion.ts +++ b/src/gateway/server-methods/session-discussion.ts @@ -10,7 +10,7 @@ import { import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; import { getSessionDiscussionProvider } from "../../plugins/session-discussion-registry.js"; import { hasExplicitSessionName, maybeGenerateSessionTitle } from "../dashboard-session-title.js"; -import { readSessionTitleFieldsFromTranscript } from "../session-transcript-readers.js"; +import { readSessionTitleFieldsFromTranscript } from "../session-transcript-title-reader.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { loadAccessorSessionEntryForGatewayTarget } from "./sessions-shared.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; diff --git a/src/gateway/session-title-inbound-metadata.test.ts b/src/gateway/session-title-inbound-metadata.test.ts index 4636be5b89aa..0c4dfa7bace9 100644 --- a/src/gateway/session-title-inbound-metadata.test.ts +++ b/src/gateway/session-title-inbound-metadata.test.ts @@ -10,7 +10,7 @@ import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { readSessionTitleFieldsFromTranscript, readSessionTitleFieldsFromTranscriptAsync, -} from "./session-transcript-readers.js"; +} from "./session-transcript-title-reader.js"; import { deriveSessionTitle } from "./session-utils-core.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index 66b05606048c..7ea94d3e4931 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -22,9 +22,9 @@ import { readSessionMessagesAsync, readSessionMessagesPageWithStatsAsync, readLatestSessionUsageFromTranscriptAsync, - readSessionTitleFieldsFromTranscript, type SessionTranscriptReadScope, } from "./session-transcript-readers.js"; +import { readSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { const actual = await importOriginal(); @@ -398,6 +398,85 @@ describe("session transcript reader facade", () => { await expect(probeReadCount("reader-title-bounded-201", 201)).resolves.toBe(200); }); + test("reuses cached SQLite title fields while the transcript watermark is unchanged", async () => { + const scope = await writeSqliteMessages("reader-title-cache-warm", [ + { role: "user", content: "cached prompt" }, + { role: "assistant", content: "cached reply" }, + ]); + expect(readSessionTitleFieldsFromTranscript(scope)).toEqual({ + firstUserMessage: "cached prompt", + lastMessagePreview: "cached reply", + }); + vi.clearAllMocks(); + + expect(readSessionTitleFieldsFromTranscript(scope)).toEqual({ + firstUserMessage: "cached prompt", + lastMessagePreview: "cached reply", + }); + expect(sessionAccessor.readSessionTranscriptMessageEventPage).not.toHaveBeenCalled(); + }); + + test("invalidates cached SQLite title fields after an append advances max seq", async () => { + const sessionId = "reader-title-cache-append"; + const scope = await writeSqliteMessages(sessionId, [ + { role: "user", content: "append prompt" }, + { role: "assistant", content: "first reply" }, + ]); + expect(readSessionTitleFieldsFromTranscript(scope).lastMessagePreview).toBe("first reply"); + await persistSessionTranscriptTurn( + { agentId: "main", sessionId, sessionKey: `agent:main:${sessionId}`, storePath }, + { + messages: [{ message: { role: "assistant", content: "appended reply" } }], + touchSessionEntry: false, + }, + ); + vi.clearAllMocks(); + + expect(readSessionTitleFieldsFromTranscript(scope).lastMessagePreview).toBe("appended reply"); + expect(sessionAccessor.readSessionTranscriptMessageEventPage).toHaveBeenCalled(); + }); + + test("invalidates cached SQLite title fields after the rewrite generation changes", async () => { + const sessionId = "reader-title-cache-generation"; + const scope = await writeSqliteMessages(sessionId, [ + { role: "user", content: "generation prompt" }, + { role: "assistant", content: "generation reply" }, + ]); + expect(readSessionTitleFieldsFromTranscript(scope).firstUserMessage).toBe("generation prompt"); + openOpenClawAgentDatabase({ + agentId: "main", + path: path.join(tempDir, "openclaw-agent.sqlite"), + }) + .db.prepare("UPDATE transcript_rewrite_watermarks SET generation = ? WHERE session_id = ?") + .run("f".repeat(32), sessionId); + vi.clearAllMocks(); + + expect(readSessionTitleFieldsFromTranscript(scope)).toEqual({ + firstUserMessage: "generation prompt", + lastMessagePreview: "generation reply", + }); + expect(sessionAccessor.readSessionTranscriptMessageEventPage).toHaveBeenCalled(); + }); + + test("returns missing title fields when the bounded head and tail caps miss", async () => { + const scope = await writeSqliteMessages( + "reader-title-cap-miss", + Array.from({ length: 201 }, (_, index) => + index === 100 + ? { role: "user", content: "outside both probes" } + : { role: "assistant", content: " " }, + ), + ); + vi.clearAllMocks(); + + expect(readSessionTitleFieldsFromTranscript(scope)).toEqual({ + firstUserMessage: null, + lastMessagePreview: null, + }); + expect(sessionAccessor.readSessionTranscriptMessageEvents).not.toHaveBeenCalled(); + expect(boundedPageEventReadCount()).toBe(200); + }); + test("promotes SQLite message idempotency into transcript metadata", async () => { const sessionId = "reader-sqlite-idempotency"; const scope = { diff --git a/src/gateway/session-transcript-readers.ts b/src/gateway/session-transcript-readers.ts index c4875b02aba2..7f8f90985139 100644 --- a/src/gateway/session-transcript-readers.ts +++ b/src/gateway/session-transcript-readers.ts @@ -13,7 +13,6 @@ import { type TranscriptEvent, } from "../config/sessions/session-accessor.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { aggregateSqliteUsageSnapshots } from "./session-transcript-derived-readers.js"; import type { ReadRecentSessionMessagesOptions, @@ -33,8 +32,6 @@ import { readSessionMessagesAsync as readSessionMessagesAsyncFile, readSessionMessagesWithSourceAsync as readSessionMessagesWithSourceAsyncFile, readSessionPreviewItemsFromTranscript as readSessionPreviewItemsFromTranscriptFile, - readSessionTitleFieldsFromTranscript as readSessionTitleFieldsFromTranscriptFile, - readSessionTitleFieldsFromTranscriptAsync as readSessionTitleFieldsFromTranscriptAsyncFile, visitSessionMessagesAsync as visitSessionMessagesAsyncFile, } from "./session-utils.fs.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; @@ -45,16 +42,6 @@ export { readSessionTranscriptVisibleMessageDelta } from "../config/sessions/ses export type { SessionTranscriptReadScope }; -type SessionTitleFields = { - firstUserMessage: string | null; - lastMessagePreview: string | null; -}; - -// Session-list title probes must not scale with transcript size. Read at most -// this many active-path messages from either end, widening only once. -const SQLITE_TITLE_PROBE_INITIAL_MESSAGES = 20; -const SQLITE_TITLE_PROBE_MAX_MESSAGES = 100; - export type ReadRecentSessionMessagesResult = { activeLeafEntryId?: string | null; messages: unknown[]; @@ -76,7 +63,7 @@ type ReadSessionMessageByIdResult = { found: boolean; }; -type ResolvedTranscriptReadTarget = { +export type ResolvedTranscriptReadTarget = { agentId?: string; sessionFile: string; sessionId: string; @@ -246,13 +233,13 @@ export function sqliteMessageEventWithSeq(entry: SessionTranscriptMessageEvent): return record ? sqliteRecordMessageWithSeq({ ...record, seq: entry.seq }) : undefined; } -function extractMessageRole(message: unknown): string | undefined { +export function extractMessageRole(message: unknown): string | undefined { return message && typeof message === "object" && !Array.isArray(message) ? ((message as { role?: unknown }).role as string | undefined) : undefined; } -function extractMessageText(message: unknown): string | null { +export function extractMessageText(message: unknown): string | null { if (!message || typeof message !== "object" || Array.isArray(message)) { return null; } @@ -278,93 +265,6 @@ function extractMessageText(message: unknown): string | null { return null; } -function readSqliteTitleProbeRange( - scope: SessionTranscriptReadScope, - totalMessages: number, - start: number, - endExclusive: number, -): SessionTranscriptMessageEvent[] { - const end = Math.min(totalMessages, endExclusive); - const boundedStart = Math.min(Math.max(0, start), end); - if (boundedStart === end) { - return []; - } - return readSessionTranscriptMessageEventPage(scope, { - maxMessages: end - boundedStart, - offset: totalMessages - end, - }).events; -} - -function findFirstTitleUserMessage( - entries: readonly SessionTranscriptMessageEvent[], - includeInterSession: boolean, -): unknown { - return entries.map(sqliteMessageEventWithSeq).find((message) => { - if (extractMessageRole(message) !== "user") { - return false; - } - return ( - includeInterSession || - !hasInterSessionUserProvenance(message as { role?: unknown; provenance?: unknown }) - ); - }); -} - -function findLastMessageText(entries: readonly SessionTranscriptMessageEvent[]): string | null { - return ( - entries.toReversed().map(sqliteMessageEventWithSeq).map(extractMessageText).find(Boolean) ?? - null - ); -} - -function readSqliteTitleFields( - target: ResolvedTranscriptReadTarget, - opts?: { includeInterSession?: boolean }, -): SessionTitleFields { - const scope = toTranscriptReadScope(target); - const tail = readSessionTranscriptMessageEventPage(scope, { - maxMessages: SQLITE_TITLE_PROBE_INITIAL_MESSAGES, - offset: 0, - }); - let lastText = findLastMessageText(tail.events); - if (!lastText && tail.totalMessages > SQLITE_TITLE_PROBE_INITIAL_MESSAGES) { - lastText = findLastMessageText( - readSqliteTitleProbeRange( - scope, - tail.totalMessages, - tail.totalMessages - SQLITE_TITLE_PROBE_MAX_MESSAGES, - tail.totalMessages - SQLITE_TITLE_PROBE_INITIAL_MESSAGES, - ), - ); - } - - const head = - tail.totalMessages <= SQLITE_TITLE_PROBE_INITIAL_MESSAGES - ? tail.events - : readSqliteTitleProbeRange( - scope, - tail.totalMessages, - 0, - SQLITE_TITLE_PROBE_INITIAL_MESSAGES, - ); - let firstUser = findFirstTitleUserMessage(head, opts?.includeInterSession === true); - if (!firstUser && tail.totalMessages > SQLITE_TITLE_PROBE_INITIAL_MESSAGES) { - firstUser = findFirstTitleUserMessage( - readSqliteTitleProbeRange( - scope, - tail.totalMessages, - SQLITE_TITLE_PROBE_INITIAL_MESSAGES, - SQLITE_TITLE_PROBE_MAX_MESSAGES, - ), - opts?.includeInterSession === true, - ); - } - return { - firstUserMessage: firstUser ? extractMessageText(firstUser) : null, - lastMessagePreview: lastText, - }; -} - function readSqliteAggregateUsageSnapshot( target: ResolvedTranscriptReadTarget, ): SessionTranscriptUsageSnapshot | null { @@ -618,42 +518,6 @@ export async function readSessionMessagesPageWithStatsAsync( ); } -/** Reads title and preview text from a transcript through the reader seam. */ -export function readSessionTitleFieldsFromTranscript( - scope: SessionTranscriptReadScope, - opts?: { includeInterSession?: boolean }, -): SessionTitleFields { - const target = resolveTranscriptReadTarget(scope); - if (isSqliteReadTarget(target)) { - return readSqliteTitleFields(target, opts); - } - return readSessionTitleFieldsFromTranscriptFile( - target.sessionId, - target.storePath, - target.sessionFile, - target.agentId, - opts, - ); -} - -/** Reads title and preview text asynchronously through the reader seam. */ -export async function readSessionTitleFieldsFromTranscriptAsync( - scope: SessionTranscriptReadScope, - opts?: { includeInterSession?: boolean }, -): Promise { - const target = resolveTranscriptReadTarget(scope); - if (isSqliteReadTarget(target)) { - return readSqliteTitleFields(target, opts); - } - return await readSessionTitleFieldsFromTranscriptAsyncFile( - target.sessionId, - target.storePath, - target.sessionFile, - target.agentId, - opts, - ); -} - /** Reads aggregate usage from a full transcript asynchronously through the reader seam. */ export async function readLatestSessionUsageFromTranscriptAsync( scope: SessionTranscriptReadScope, diff --git a/src/gateway/session-transcript-title-reader.ts b/src/gateway/session-transcript-title-reader.ts new file mode 100644 index 000000000000..051922d76315 --- /dev/null +++ b/src/gateway/session-transcript-title-reader.ts @@ -0,0 +1,199 @@ +// Session-list title reads: bounded transcript probes plus a watermark-validated +// cache so list rendering never rescans transcripts that have not changed. +import { + readSessionTranscriptMessageEventPage, + readSessionTranscriptWatermark, + type SessionTranscriptMessageEvent, + type SessionTranscriptReadScope, +} from "../config/sessions/session-accessor.js"; +import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; +import { + extractMessageRole, + extractMessageText, + isSqliteReadTarget, + resolveTranscriptReadTarget, + sqliteMessageEventWithSeq, + toTranscriptReadScope, + type ResolvedTranscriptReadTarget, +} from "./session-transcript-readers.js"; +import { + readSessionTitleFieldsFromTranscript as readSessionTitleFieldsFromTranscriptFile, + readSessionTitleFieldsFromTranscriptAsync as readSessionTitleFieldsFromTranscriptAsyncFile, +} from "./session-utils.fs.js"; + +type SessionTitleFields = { + firstUserMessage: string | null; + lastMessagePreview: string | null; +}; + +// Session-list title probes must not scale with transcript size. Read at most +// this many active-path messages from either end, widening only once. +const SQLITE_TITLE_PROBE_INITIAL_MESSAGES = 20; +const SQLITE_TITLE_PROBE_MAX_MESSAGES = 100; +const SQLITE_TITLE_FIELD_CACHE_MAX_ENTRIES = 256; + +type SqliteTitleFieldCacheEntry = ReturnType & { + fields: Partial>; +}; + +// Appends advance maxSeq while rewind, fork, and compaction rotate generation. Both tokens must +// match or stale titles can survive transcript replacement; keep only a few list pages in memory. +const sqliteTitleFieldCache = new Map(); + +function sqliteTitleFieldCacheKey(target: ResolvedTranscriptReadTarget): string { + return `${target.agentId ?? ""}\0${target.sessionId}\0${target.storePath ?? ""}`; +} + +function setSqliteTitleFieldCache(key: string, entry: SqliteTitleFieldCacheEntry): void { + sqliteTitleFieldCache.delete(key); + sqliteTitleFieldCache.set(key, entry); + if (sqliteTitleFieldCache.size <= SQLITE_TITLE_FIELD_CACHE_MAX_ENTRIES) { + return; + } + const oldestKey = sqliteTitleFieldCache.keys().next().value; + if (oldestKey !== undefined) { + sqliteTitleFieldCache.delete(oldestKey); + } +} + +function readSqliteTitleProbeRange( + scope: SessionTranscriptReadScope, + totalMessages: number, + start: number, + endExclusive: number, +): SessionTranscriptMessageEvent[] { + const end = Math.min(totalMessages, endExclusive); + const boundedStart = Math.min(Math.max(0, start), end); + if (boundedStart === end) { + return []; + } + return readSessionTranscriptMessageEventPage(scope, { + maxMessages: end - boundedStart, + offset: totalMessages - end, + }).events; +} + +function findFirstTitleUserMessage( + entries: readonly SessionTranscriptMessageEvent[], + includeInterSession: boolean, +): unknown { + return entries.map(sqliteMessageEventWithSeq).find((message) => { + if (extractMessageRole(message) !== "user") { + return false; + } + return ( + includeInterSession || + !hasInterSessionUserProvenance(message as { role?: unknown; provenance?: unknown }) + ); + }); +} + +function findLastMessageText(entries: readonly SessionTranscriptMessageEvent[]): string | null { + return ( + entries.toReversed().map(sqliteMessageEventWithSeq).map(extractMessageText).find(Boolean) ?? + null + ); +} + +function readSqliteTitleFields( + target: ResolvedTranscriptReadTarget, + opts?: { includeInterSession?: boolean }, +): SessionTitleFields { + const scope = toTranscriptReadScope(target); + const cacheKey = sqliteTitleFieldCacheKey(target); + const watermark = readSessionTranscriptWatermark(scope); + const variant = opts?.includeInterSession === true ? "includeInterSession" : "default"; + const cached = sqliteTitleFieldCache.get(cacheKey); + const cachedFields = + cached?.generation === watermark.generation && cached.maxSeq === watermark.maxSeq + ? cached.fields[variant] + : undefined; + if (cached && cachedFields) { + setSqliteTitleFieldCache(cacheKey, cached); + return { ...cachedFields }; + } + const tail = readSessionTranscriptMessageEventPage(scope, { + maxMessages: SQLITE_TITLE_PROBE_INITIAL_MESSAGES, + offset: 0, + }); + let lastText = findLastMessageText(tail.events); + if (!lastText && tail.totalMessages > SQLITE_TITLE_PROBE_INITIAL_MESSAGES) { + lastText = findLastMessageText( + readSqliteTitleProbeRange( + scope, + tail.totalMessages, + tail.totalMessages - SQLITE_TITLE_PROBE_MAX_MESSAGES, + tail.totalMessages - SQLITE_TITLE_PROBE_INITIAL_MESSAGES, + ), + ); + } + + const head = + tail.totalMessages <= SQLITE_TITLE_PROBE_INITIAL_MESSAGES + ? tail.events + : readSqliteTitleProbeRange( + scope, + tail.totalMessages, + 0, + SQLITE_TITLE_PROBE_INITIAL_MESSAGES, + ); + let firstUser = findFirstTitleUserMessage(head, opts?.includeInterSession === true); + if (!firstUser && tail.totalMessages > SQLITE_TITLE_PROBE_INITIAL_MESSAGES) { + firstUser = findFirstTitleUserMessage( + readSqliteTitleProbeRange( + scope, + tail.totalMessages, + SQLITE_TITLE_PROBE_INITIAL_MESSAGES, + SQLITE_TITLE_PROBE_MAX_MESSAGES, + ), + opts?.includeInterSession === true, + ); + } + const fields = { + firstUserMessage: firstUser ? extractMessageText(firstUser) : null, + lastMessagePreview: lastText, + }; + const fieldsByVariant = + cached?.generation === watermark.generation && cached.maxSeq === watermark.maxSeq + ? cached.fields + : {}; + fieldsByVariant[variant] = fields; + setSqliteTitleFieldCache(cacheKey, { ...watermark, fields: fieldsByVariant }); + return { ...fields }; +} + +/** Reads title and preview text from a transcript through the reader seam. */ +export function readSessionTitleFieldsFromTranscript( + scope: SessionTranscriptReadScope, + opts?: { includeInterSession?: boolean }, +): SessionTitleFields { + const target = resolveTranscriptReadTarget(scope); + if (isSqliteReadTarget(target)) { + return readSqliteTitleFields(target, opts); + } + return readSessionTitleFieldsFromTranscriptFile( + target.sessionId, + target.storePath, + target.sessionFile, + target.agentId, + opts, + ); +} + +/** Reads title and preview text asynchronously through the reader seam. */ +export async function readSessionTitleFieldsFromTranscriptAsync( + scope: SessionTranscriptReadScope, + opts?: { includeInterSession?: boolean }, +): Promise { + const target = resolveTranscriptReadTarget(scope); + if (isSqliteReadTarget(target)) { + return readSqliteTitleFields(target, opts); + } + return await readSessionTitleFieldsFromTranscriptAsyncFile( + target.sessionId, + target.storePath, + target.sessionFile, + target.agentId, + opts, + ); +} diff --git a/src/gateway/session-utils-creators.test.ts b/src/gateway/session-utils-creators.test.ts index ce94de605393..0fbf81184e4c 100644 --- a/src/gateway/session-utils-creators.test.ts +++ b/src/gateway/session-utils-creators.test.ts @@ -1,6 +1,9 @@ -import { expect, it, vi } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { filterSessionStoreToConfiguredAgents } from "./server-methods/sessions-shared.js"; +import type { GatewayClient } from "./server-methods/types.js"; +import { filterDraftSessionsForClient } from "./session-sharing.js"; const getUserProfileListItem = vi.hoisted(() => vi.fn((profileId: string) => ({ @@ -13,6 +16,8 @@ vi.mock("../state/user-profiles.js", () => ({ getUserProfileListItem })); import { listSessionsFromStore } from "./session-utils.js"; +afterEach(() => vi.restoreAllMocks()); + it("returns the complete deterministic creator facet independently of pagination", () => { const store: Record = { "agent:main:ada": { @@ -63,3 +68,191 @@ it("returns the complete deterministic creator facet independently of pagination expect(filtered.sessions.map((row) => row.key)).toEqual(["agent:main:bob"]); expect(filtered.creators).toEqual(result.creators); }); + +it("preserves legacy list output across visibility, scope, creator, and search filters", () => { + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + getUserProfileListItem.mockImplementation((profileId: string) => ({ + id: profileId, + displayName: + profileId === "profile-ada" ? "Ada" : profileId === "profile-bob" ? "Bob" : "Carol", + })); + const cfg = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + } as OpenClawConfig; + const store: Record = { + global: { + createdActor: { type: "human", id: "profile-bob" }, + sessionId: "session-global", + subject: "needle global", + updatedAt: now - 1, + }, + unknown: { + createdActor: { type: "system", id: "system-import" }, + sessionId: "session-unknown", + subject: "needle unknown", + updatedAt: now - 2, + }, + "agent:main:shared": { + boardFace: "chat", + createdActor: { type: "human", id: "profile-ada" }, + label: "focus", + lastInteractionAt: now - 5, + sessionId: "session-main-shared", + subject: "needle main", + updatedAt: now - 3, + visibility: "shared", + }, + "agent:main:draft": { + createdActor: { type: "human", id: "profile-ada" }, + sessionId: "session-main-draft", + subject: "needle hidden draft", + updatedAt: now - 4, + visibility: "draft", + }, + "agent:work:shared": { + createdActor: { type: "human", id: "profile-bob" }, + sessionId: "session-work-shared", + subject: "needle work", + updatedAt: now - 5, + visibility: "shared", + }, + "agent:retired:shared": { + createdActor: { type: "human", id: "profile-carol" }, + sessionId: "session-retired-shared", + subject: "needle retired", + updatedAt: now - 6, + visibility: "shared", + }, + "agent:main:archived": { + archivedAt: now - 10, + createdActor: { type: "human", id: "profile-bob" }, + sessionId: "session-main-archived", + subject: "needle archived", + updatedAt: now - 7, + visibility: "shared", + }, + "agent:main:sessions": {} as SessionEntry, + }; + const viewer = { + authenticatedUserId: "profile-viewer", + authenticatedUserProfile: { + displayName: "Viewer", + hasAvatar: false, + profileId: "profile-viewer", + updatedAt: now, + }, + connect: { + client: { + id: "openclaw-control-ui", + mode: "webchat", + platform: "test", + version: "test", + }, + maxProtocol: 1, + minProtocol: 1, + role: "operator", + scopes: ["operator.read"], + }, + } as GatewayClient; + const visibleStore = filterDraftSessionsForClient({ client: viewer, store }); + const configuredStore = filterSessionStoreToConfiguredAgents(cfg, visibleStore); + + const project = (opts: Parameters[0]["opts"]) => { + const result = listSessionsFromStore({ + cfg, + opts, + store: configuredStore, + storePath: "/tmp/openclaw-session-filter-parity", + }); + return { + count: result.count, + creators: result.creators, + hasMore: result.hasMore, + keys: result.sessions.map((row) => row.key), + nextOffset: result.nextOffset, + totalCount: result.totalCount, + }; + }; + + // These exact projections were captured from the pre-refactor chained-filter implementation. + expect( + JSON.stringify( + project({ archived: "all", includeGlobal: true, includeUnknown: true, search: "needle" }), + ), + ).toBe( + JSON.stringify({ + count: 5, + creators: [ + { id: "profile-ada", label: "Ada" }, + { id: "profile-bob", label: "Bob" }, + { id: "system-import" }, + ], + hasMore: false, + keys: ["global", "unknown", "agent:main:shared", "agent:work:shared", "agent:main:archived"], + nextOffset: null, + totalCount: 5, + }), + ); + expect( + JSON.stringify( + project({ + agentId: "main", + archived: "all", + creatorId: "profile-bob", + includeGlobal: true, + includeUnknown: true, + search: "needle", + }), + ), + ).toBe( + JSON.stringify({ + count: 2, + creators: [ + { id: "profile-ada", label: "Ada" }, + { id: "profile-bob", label: "Bob" }, + ], + hasMore: false, + keys: ["global", "agent:main:archived"], + nextOffset: null, + totalCount: 2, + }), + ); +}); + +it("keeps the serialized list response byte-identical to the legacy filter path", () => { + vi.spyOn(Date, "now").mockReturnValue(1_000_000); + const result = listSessionsFromStore({ + cfg: { + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [{ id: "main", default: true, model: { primary: "openai/gpt-5.4" } }], + }, + } as OpenClawConfig, + opts: { archived: "all", includeGlobal: true, search: "needle" }, + store: { + global: { + contextTokens: 100, + createdActor: { type: "system", id: "creator-b" }, + estimatedCostUsd: 0, + model: "gpt-5.4", + modelProvider: "openai", + sessionId: "session-global", + subject: "needle global", + totalTokens: 1, + totalTokensFresh: true, + updatedAt: 999_999, + }, + }, + storePath: "/tmp/openclaw-session-byte-parity", + }); + const legacySerializedResponse = [ + '{"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}]}', + ].join(""); + + expect(JSON.stringify(result)).toBe(legacySerializedResponse); +}); diff --git a/src/gateway/session-utils-list.ts b/src/gateway/session-utils-list.ts index 30dd3b70e698..d90618038744 100644 --- a/src/gateway/session-utils-list.ts +++ b/src/gateway/session-utils-list.ts @@ -19,7 +19,7 @@ import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.j import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { type SessionEntryPair, sortAndLimitSessionEntries } from "./session-list-order.js"; import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; -import { readSessionTitleFieldsFromTranscriptAsync as readScopedSessionTitleFieldsFromTranscriptAsync } from "./session-transcript-readers.js"; +import { readSessionTitleFieldsFromTranscriptAsync as readScopedSessionTitleFieldsFromTranscriptAsync } from "./session-transcript-title-reader.js"; import type { SessionListRowContext, SessionListRowContextProvider, @@ -57,7 +57,7 @@ const SESSIONS_LIST_DEFAULT_LIMIT = 100; type SessionEntrySelection = { entries: SessionEntryPair[]; - creatorEntries: SessionEntryPair[]; + creators: Array<{ id: string; label?: string }>; totalCount: number; limitApplied?: number; offset: number; @@ -65,6 +65,32 @@ type SessionEntrySelection = { hasMore: boolean; }; +function addSessionCreatorIdentity( + creators: Map, + entry: SessionEntry, + userProfileLabelById: Map, +): void { + const actor = projectSessionActor(entry.createdActor, userProfileLabelById); + const id = normalizeOptionalString(actor?.id); + if (!id) { + return; + } + const label = normalizeOptionalString(actor?.label); + const existing = creators.get(id); + if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) { + creators.set(id, { id, ...(label ? { label } : {}) }); + } +} + +function sortSessionCreatorIdentities( + creators: Map, +): Array<{ id: string; label?: string }> { + return [...creators.values()].toSorted((a, b) => { + const byLabel = (a.label ?? a.id).localeCompare(b.label ?? b.id); + return byLabel || a.id.localeCompare(b.id); + }); +} + function populateSessionListAcpMetadata(params: { cfg: OpenClawConfig; entries: readonly SessionEntryPair[]; @@ -123,9 +149,9 @@ function filterSessionEntries(params: { store: Record; opts: SessionsListParams; now: number; - rowContext?: SessionListRowContext; + userProfileLabelById?: Map; getRowContext?: SessionListRowContextProvider; -}): SessionEntryPair[] { +}): Pick { const { cfg, store, opts, now } = params; const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; @@ -138,130 +164,114 @@ function filterSessionEntries(params: { typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes) ? Math.max(1, Math.floor(opts.activeMinutes)) : undefined; + const creatorId = normalizeOptionalString(opts.creatorId); + const activeCutoff = activeMinutes === undefined ? undefined : now - activeMinutes * 60_000; + const entries: SessionEntryPair[] = []; + const creators = new Map(); - let entries = Object.entries(store) - .filter(([key]) => { - if (isCronRunSessionKey(key)) { - return false; - } - if (!includeGlobal && key === "global") { - return false; - } - if (!includeUnknown && key === "unknown") { - return false; - } - if (agentId) { - if (key === "global") { - return includeGlobal; - } - if (key === "unknown") { - return false; + for (const [key, entry] of Object.entries(store)) { + if ( + isCronRunSessionKey(key) || + (!includeGlobal && key === "global") || + (!includeUnknown && key === "unknown") + ) { + continue; + } + if (agentId) { + if (key === "global") { + if (!includeGlobal) { + continue; } + } else if (key === "unknown") { + continue; + } else { const parsed = parseAgentSessionKey(key); - if (!parsed) { - return false; + if (!parsed || normalizeAgentId(parsed.agentId) !== agentId) { + continue; } - return normalizeAgentId(parsed.agentId) === agentId; - } - return true; - }) - .filter(([key, entry]) => { - if (isPhantomAgentStoreListEntry(key, entry)) { - return false; - } - if (!spawnedBy) { - return true; } + } + if (isPhantomAgentStoreListEntry(key, entry)) { + continue; + } + if (spawnedBy) { if (key === "unknown" || key === "global") { - return false; + continue; } const filterRowContext = resolveSessionListRowContext(params); const latest = filterRowContext ? filterRowContext.subagentRuns.getDisplaySubagentRun(key) : getSessionDisplaySubagentRunByChildSessionKey(key); - if (latest) { - const latestControllerSessionKey = - normalizeOptionalString(latest.controllerSessionKey) || - normalizeOptionalString(latest.requesterSessionKey); - return ( - latestControllerSessionKey === spawnedBy && + const keepSpawned = latest + ? (normalizeOptionalString(latest.controllerSessionKey) || + normalizeOptionalString(latest.requesterSessionKey)) === spawnedBy && shouldKeepSubagentRunChildLink(latest, { activeDescendants: filterRowContext ? filterRowContext.subagentRuns.countActiveDescendantRuns(key) : countActiveDescendantRuns(key), now, }) - ); + : shouldKeepStoreOnlyChildLink(entry, now) && + (entry.spawnedBy === spawnedBy || entry.parentSessionKey === spawnedBy); + if (!keepSpawned) { + continue; } - return ( - shouldKeepStoreOnlyChildLink(entry, now) && - (entry?.spawnedBy === spawnedBy || entry?.parentSessionKey === spawnedBy) - ); - }) - .filter(([, entry]) => { - if (opts.archived === "all") { - return true; + } + if (opts.archived !== "all") { + const archived = entry.archivedAt !== undefined; + if (opts.archived === true ? !archived : archived) { + continue; } - const archived = entry?.archivedAt !== undefined; - return opts.archived === true ? archived : !archived; - }) - .filter(([, entry]) => { - if (opts.requireLastInteraction !== true) { - return true; - } - return ( - isFinitePositiveTimestamp(entry?.lastInteractionAt) && - !normalizeOptionalString(entry?.heartbeatIsolatedBaseSessionKey) - ); - }) - .filter(([, entry]) => { - if (!label) { - return true; - } - return entry?.label === label; - }) - .filter(([, entry]) => { - if (!boardFace) { - return true; - } - return entry?.boardFace === boardFace; - }); - - if (search) { - entries = entries.filter(([key, entry]) => { + } + if ( + opts.requireLastInteraction === true && + (!isFinitePositiveTimestamp(entry.lastInteractionAt) || + normalizeOptionalString(entry.heartbeatIsolatedBaseSessionKey)) + ) { + continue; + } + if ((label && entry.label !== label) || (boardFace && entry.boardFace !== boardFace)) { + continue; + } + if (search) { const cheapFields = [ resolveSessionListSearchDisplayName(key, entry), - entry?.label, - entry?.subject, - entry?.sessionId, + entry.label, + entry.subject, + entry.sessionId, key, ]; appendStoredSessionModelSearchFields(cheapFields, entry); - if (matchesSessionListSearch(cheapFields, search)) { - return true; + const cheapMatch = matchesSessionListSearch(cheapFields, search); + const derivedMatch = + !cheapMatch && + shouldResolveDerivedSessionModelSearchFields(search) && + matchesSessionListSearch( + resolveSessionListSearchModelFields({ + cfg, + key, + entry, + rowContext: resolveSessionListRowContext(params), + }), + search, + ); + if (!cheapMatch && !derivedMatch) { + continue; } - if (!shouldResolveDerivedSessionModelSearchFields(search)) { - return false; - } - const searchRowContext = resolveSessionListRowContext(params); - return matchesSessionListSearch( - resolveSessionListSearchModelFields({ - cfg, - key, - entry, - rowContext: searchRowContext, - }), - search, - ); - }); + } + if (activeCutoff !== undefined && (entry.updatedAt ?? 0) < activeCutoff) { + continue; + } + if (params.userProfileLabelById) { + addSessionCreatorIdentity(creators, entry, params.userProfileLabelById); + } + if (creatorId && entry.createdActor?.id !== creatorId) { + continue; + } + entries.push([key, entry]); } - if (activeMinutes !== undefined) { - const cutoff = now - activeMinutes * 60_000; - entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff); - } - - return entries; + return { entries, creators: sortSessionCreatorIdentities(creators) }; } function isPhantomAgentStoreListEntry(key: string, entry: SessionEntry | undefined): boolean { @@ -278,15 +288,11 @@ function selectSessionEntries(params: { store: Record; opts: SessionsListParams; now: number; - rowContext?: SessionListRowContext; getRowContext?: SessionListRowContextProvider; defaultLimit?: number; + userProfileLabelById?: Map; }): SessionEntrySelection { - const creatorEntries = filterSessionEntries(params); - const creatorId = normalizeOptionalString(params.opts.creatorId); - const filtered = creatorId - ? creatorEntries.filter(([, entry]) => entry.createdActor?.id === creatorId) - : creatorEntries; + const { creators, entries: filtered } = filterSessionEntries(params); const limit = resolveSessionsListLimit(params.opts, params.defaultLimit); const offset = resolveSessionsListOffset(params.opts); const windowLimit = resolveSessionsListWindowLimit(limit, offset); @@ -297,7 +303,7 @@ function selectSessionEntries(params: { const hasMore = nextOffset < filtered.length; return { entries, - creatorEntries, + creators, totalCount: filtered.length, limitApplied: limit, offset, @@ -306,36 +312,11 @@ function selectSessionEntries(params: { }; } -function listSessionCreatorIdentities( - entries: readonly SessionEntryPair[], - userProfileLabelById: Map, -): Array<{ id: string; label?: string }> { - const creators = new Map(); - for (const [, entry] of entries) { - const actor = projectSessionActor(entry.createdActor, userProfileLabelById); - const id = normalizeOptionalString(actor?.id); - if (!id) { - continue; - } - const label = normalizeOptionalString(actor?.label); - const existing = creators.get(id); - if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) { - creators.set(id, { id, ...(label ? { label } : {}) }); - } - } - return [...creators.values()].toSorted((a, b) => { - const byLabel = (a.label ?? a.id).localeCompare(b.label ?? b.id); - return byLabel || a.id.localeCompare(b.id); - }); -} - export function filterAndSortSessionEntries(params: { cfg: OpenClawConfig; store: Record; opts: SessionsListParams; now: number; - rowContext?: SessionListRowContext; - getRowContext?: SessionListRowContextProvider; }): [string, SessionEntry][] { return selectSessionEntries(params).entries; } @@ -351,9 +332,12 @@ export function listSessionsFromStore(params: { const now = Date.now(); const sessionListTranscriptUsageMaxBytes = 64 * 1024; const sessionListTranscriptFieldRows = 100; + // Creator facets and rows must share one profile-label snapshot. Every row context in this + // public list call is built with this map below, so a profile rename cannot split the response. + const userProfileLabelById = new Map(); let rowContext: SessionListRowContext | undefined; const getRowContext = () => { - rowContext ??= buildSessionListRowContext({ store, now }); + rowContext ??= buildSessionListRowContext({ store, now, userProfileLabelById }); return rowContext; }; const includeDerivedTitles = opts.includeDerivedTitles === true; @@ -370,16 +354,18 @@ export function listSessionsFromStore(params: { ? getRowContext : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, + userProfileLabelById, }); - const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } = - selection; + const { entries, creators, totalCount, limitApplied, offset, nextOffset, hasMore } = selection; const fullRowContext = rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE ? getRowContext() : undefined; const sharedRowContext = fullRowContext ?? - (entries.length > 0 ? buildSessionListRowMetadataContext({ now }) : undefined); + (entries.length > 0 + ? buildSessionListRowMetadataContext({ now, userProfileLabelById }) + : undefined); populateSessionListAcpMetadata({ cfg, entries, opts, rowContext: sharedRowContext }); const sessions = entries.map(([key, entry], index) => { @@ -417,10 +403,7 @@ export function listSessionsFromStore(params: { offset: offset > 0 ? offset : undefined, nextOffset, hasMore, - creators: listSessionCreatorIdentities( - creatorEntries, - sharedRowContext?.userProfileLabelById ?? new Map(), - ), + creators, defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }), sessions, }; @@ -453,9 +436,12 @@ export async function listSessionsFromStoreAsync(params: { const now = Date.now(); const sessionListTranscriptUsageMaxBytes = 64 * 1024; const sessionListTranscriptFieldRows = 100; + // Creator facets and rows must share one profile-label snapshot. Every row context in this + // public list call is built with this map below, so a profile rename cannot split the response. + const userProfileLabelById = new Map(); let rowContext: SessionListRowContext | undefined; const getRowContext = () => { - rowContext ??= buildSessionListRowContext({ store, now }); + rowContext ??= buildSessionListRowContext({ store, now, userProfileLabelById }); return rowContext; }; const includeDerivedTitles = opts.includeDerivedTitles === true; @@ -472,16 +458,18 @@ export async function listSessionsFromStoreAsync(params: { ? getRowContext : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, + userProfileLabelById, }); - const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } = - selection; + const { entries, creators, totalCount, limitApplied, offset, nextOffset, hasMore } = selection; const fullRowContext = rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE ? getRowContext() : undefined; const sharedRowContext = fullRowContext ?? - (entries.length > 0 ? buildSessionListRowMetadataContext({ now }) : undefined); + (entries.length > 0 + ? buildSessionListRowMetadataContext({ now, userProfileLabelById }) + : undefined); populateSessionListAcpMetadata({ cfg, entries, opts, rowContext: sharedRowContext }); const sessions: GatewaySessionRow[] = []; @@ -554,10 +542,7 @@ export async function listSessionsFromStoreAsync(params: { offset: offset > 0 ? offset : undefined, nextOffset, hasMore, - creators: listSessionCreatorIdentities( - creatorEntries, - sharedRowContext?.userProfileLabelById ?? new Map(), - ), + creators, defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }), sessions, }; diff --git a/src/gateway/session-utils-projection.ts b/src/gateway/session-utils-projection.ts index 78afe432de15..3840156c5129 100644 --- a/src/gateway/session-utils-projection.ts +++ b/src/gateway/session-utils-projection.ts @@ -22,17 +22,20 @@ import { resolveConcreteSessionStorePath } from "./session-utils-store.js"; export function buildSessionListRowContext(params: { store: Record; now: number; + userProfileLabelById?: Map; }): SessionListRowContext { const subagentRuns = buildSubagentRunReadIndex(params.now); return buildSessionListRowContextFromParts({ subagentRuns, storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns), + userProfileLabelById: params.userProfileLabelById, }); } function buildSessionListRowContextFromParts(params: { subagentRuns: ReturnType; storeChildSessionsByKey: Map; + userProfileLabelById?: Map; }): SessionListRowContext { return { subagentRuns: params.subagentRuns, @@ -41,15 +44,19 @@ function buildSessionListRowContextFromParts(params: { thinkingMetadataByModelRef: new Map(), displayModelIdentityByKey: new Map(), modelCostConfigByModelRef: new Map(), - userProfileLabelById: new Map(), + userProfileLabelById: params.userProfileLabelById ?? new Map(), acpSessionMetaByEntry: new Map(), }; } -export function buildSessionListRowMetadataContext(params: { now: number }): SessionListRowContext { +export function buildSessionListRowMetadataContext(params: { + now: number; + userProfileLabelById?: Map; +}): SessionListRowContext { return buildSessionListRowContextFromParts({ subagentRuns: buildSubagentRunReadIndex(params.now), storeChildSessionsByKey: new Map(), + userProfileLabelById: params.userProfileLabelById, }); } diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 27444edd0b8f..1ecc1da247bf 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -32,7 +32,7 @@ import { projectSessionDeliveryFields } from "../utils/delivery-context.shared.j import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; import { sessionHasAutomation } from "./session-automation-index.js"; import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; -import { readSessionTitleFieldsFromTranscript as readScopedSessionTitleFieldsFromTranscript } from "./session-transcript-readers.js"; +import { readSessionTitleFieldsFromTranscript as readScopedSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; import type { SessionListRowContext } from "./session-utils-contracts.js"; import { buildCompactionCheckpointPreview,