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.
This commit is contained in:
Peter Steinberger
2026-07-27 23:56:30 -04:00
committed by GitHub
parent cba26e3b68
commit 81f291db58
13 changed files with 674 additions and 297 deletions

View File

@@ -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";

View File

@@ -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<WatermarkDatabase>(database.db);
const maxSeq = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("transcript_events")
.select((eb) => eb.fn.max<number>("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 };
}

View File

@@ -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,

View File

@@ -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", () => ({

View File

@@ -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";

View File

@@ -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);

View File

@@ -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<typeof import("../config/sessions/session-accessor.js")>();
@@ -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 = {

View File

@@ -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<SessionTitleFields> {
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,

View File

@@ -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<typeof readSessionTranscriptWatermark> & {
fields: Partial<Record<"default" | "includeInterSession", SessionTitleFields>>;
};
// 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<string, SqliteTitleFieldCacheEntry>();
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<SessionTitleFields> {
const target = resolveTranscriptReadTarget(scope);
if (isSqliteReadTarget(target)) {
return readSqliteTitleFields(target, opts);
}
return await readSessionTitleFieldsFromTranscriptAsyncFile(
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
opts,
);
}

View File

@@ -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<string, SessionEntry> = {
"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<string, SessionEntry> = {
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<typeof listSessionsFromStore>[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);
});

View File

@@ -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<string, { id: string; label?: string }>,
entry: SessionEntry,
userProfileLabelById: Map<string, string | undefined>,
): 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<string, { id: string; label?: string }>,
): 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<string, SessionEntry>;
opts: SessionsListParams;
now: number;
rowContext?: SessionListRowContext;
userProfileLabelById?: Map<string, string | undefined>;
getRowContext?: SessionListRowContextProvider;
}): SessionEntryPair[] {
}): Pick<SessionEntrySelection, "creators" | "entries"> {
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<string, { id: string; label?: string }>();
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<string, SessionEntry>;
opts: SessionsListParams;
now: number;
rowContext?: SessionListRowContext;
getRowContext?: SessionListRowContextProvider;
defaultLimit?: number;
userProfileLabelById?: Map<string, string | undefined>;
}): 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<string, string | undefined>,
): Array<{ id: string; label?: string }> {
const creators = new Map<string, { id: string; label?: string }>();
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<string, SessionEntry>;
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<string, string | undefined>();
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<string, string | undefined>();
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,
};

View File

@@ -22,17 +22,20 @@ import { resolveConcreteSessionStorePath } from "./session-utils-store.js";
export function buildSessionListRowContext(params: {
store: Record<string, SessionEntry>;
now: number;
userProfileLabelById?: Map<string, string | undefined>;
}): SessionListRowContext {
const subagentRuns = buildSubagentRunReadIndex(params.now);
return buildSessionListRowContextFromParts({
subagentRuns,
storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns),
userProfileLabelById: params.userProfileLabelById,
});
}
function buildSessionListRowContextFromParts(params: {
subagentRuns: ReturnType<typeof buildSubagentRunReadIndex>;
storeChildSessionsByKey: Map<string, string[]>;
userProfileLabelById?: Map<string, string | undefined>;
}): 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<string, string | undefined>;
}): SessionListRowContext {
return buildSessionListRowContextFromParts({
subagentRuns: buildSubagentRunReadIndex(params.now),
storeChildSessionsByKey: new Map(),
userProfileLabelById: params.userProfileLabelById,
});
}

View File

@@ -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,