mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:51:38 +00:00
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:
committed by
GitHub
parent
cba26e3b68
commit
81f291db58
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
199
src/gateway/session-transcript-title-reader.ts
Normal file
199
src/gateway/session-transcript-title-reader.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user