refactor: use canonical transcript reader identity (#89581)

* refactor: use canonical transcript reader identity

* refactor: keep transcript reader dependency storage-neutral
This commit is contained in:
Josh Lehman
2026-06-19 10:40:18 -07:00
committed by GitHub
parent d41a3d28a0
commit d216f7c876
31 changed files with 536 additions and 251 deletions
@@ -95,10 +95,20 @@ export const migratedSessionAccessorFiles = new Set([
"src/cron/isolated-agent/delivery-target.ts",
"src/cron/service/timer.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/gateway/session-history-state.ts",
"src/gateway/session-utils.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions.ts",
"src/gateway/server-session-events.ts",
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/status/status-message.ts",
"src/tui/embedded-backend.ts",
]);
@@ -49,6 +49,8 @@ const transcriptReaderNames = new Set([
"visitSessionMessagesAsync",
]);
const storageSpecificTranscriptReaderAliasNames = new Set(["readSessionMessagesFromFileAsync"]);
export const migratedSessionTranscriptReaderFiles = new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/subagent-announce-output.test.ts",
@@ -126,6 +128,13 @@ export function findSessionTranscriptReaderBoundaryViolations(content, fileName
const legacyNamespaces = new Set();
const visit = (node) => {
if (ts.isIdentifier(node) && storageSpecificTranscriptReaderAliasNames.has(node.text)) {
violations.push({
line: toLine(sourceFile, node),
reason: `uses storage-specific transcript reader alias "${node.text}"`,
});
}
if (ts.isImportDeclaration(node)) {
const moduleName = importedModuleName(node);
const namedBindings = node.importClause?.namedBindings;
+2 -1
View File
@@ -805,8 +805,9 @@ async function recoverStore(params: {
messages = await readSessionMessagesAsync(
{
agentId: resolveAgentIdFromSessionKey(sessionKey),
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
storePath: params.storePath,
},
{
+2 -1
View File
@@ -296,8 +296,9 @@ export async function recoverOrphanedSubagentSessions(params: {
const messages = await readSessionMessagesAsync(
{
agentId: resolveAgentIdFromSessionKey(childSessionKey),
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: childSessionKey,
storePath,
},
{
@@ -69,4 +69,16 @@ vi.mock("../../channels/plugins/session-conversation.js", () => ({
threadId: match.groups.threadId,
};
},
resolveSessionThreadInfo: (sessionKey: string | undefined | null) => {
const trimmed = sessionKey?.trim();
const topicMarker = ":topic:";
const topicIndex = trimmed?.lastIndexOf(topicMarker) ?? -1;
if (!trimmed || topicIndex < 0) {
return { baseSessionKey: trimmed, threadId: undefined };
}
return {
baseSessionKey: trimmed.slice(0, topicIndex),
threadId: trimmed.slice(topicIndex + topicMarker.length) || undefined,
};
},
}));
@@ -126,8 +126,9 @@ describe("embedded gateway stub", () => {
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{
@@ -192,8 +193,9 @@ describe("embedded gateway stub", () => {
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{
@@ -225,8 +227,9 @@ describe("embedded gateway stub", () => {
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{
+9 -1
View File
@@ -155,14 +155,22 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
const sessionEntry =
typeof entry?.sessionId === "string"
? {
sessionId: entry.sessionId,
...(typeof entry.sessionFile === "string" ? { sessionFile: entry.sessionFile } : {}),
}
: undefined;
const localMessages =
sessionId && storePath
? await rt.readSessionMessagesAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile as string | undefined,
sessionEntry,
sessionId,
sessionKey,
storePath,
},
{
+13 -3
View File
@@ -156,8 +156,9 @@ export function createSessionsListTool(opts?: {
const titleTargets: Array<{
row: SessionListRow;
titleEntry: SessionEntry;
sessionEntry: { sessionFile?: string; sessionId: string };
sessionId: string;
sessionFile?: string;
sessionKey: string;
agentId: string;
}> = [];
@@ -356,8 +357,16 @@ export function createSessionsListTool(opts?: {
subject: readStringValue((entry as { subject?: unknown }).subject),
updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0,
},
sessionEntry: {
sessionId,
...(sessionFile ? { sessionFile } : {}),
},
sessionId,
...(sessionFile ? { sessionFile } : {}),
sessionKey: resolveInternalSessionKey({
key,
alias,
mainKey,
}),
agentId: resolvedAgentId,
});
}
@@ -385,8 +394,9 @@ export function createSessionsListTool(opts?: {
const target = titleTargets[next];
const fields = await readSessionTitleFieldsFromTranscriptAsync({
agentId: target.agentId,
sessionFile: target.sessionFile,
sessionEntry: target.sessionEntry,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
storePath,
});
if (includeDerivedTitles && !target.row.derivedTitle) {
@@ -24,6 +24,7 @@ import {
publishTranscriptUpdate,
readSessionUpdatedAt,
replaceSessionEntry,
resolveSessionTranscriptReadTarget,
resolveSessionTranscriptRuntimeReadTarget,
resolveSessionTranscriptRuntimeTarget,
trimSessionTranscriptForManualCompact,
@@ -1475,6 +1476,44 @@ describe("session accessor file-backed seam", () => {
expect(loadSessionEntry(scope)?.sessionFile).toBeUndefined();
});
it("uses a supplied read session entry without loading the store", () => {
const explicitSessionFile = path.join(tempDir, "entry-session.jsonl");
fs.writeFileSync(explicitSessionFile, "", "utf8");
fs.writeFileSync(storePath, "{not-json", "utf8");
const target = resolveSessionTranscriptReadTarget({
agentId: "main",
sessionEntry: {
sessionFile: explicitSessionFile,
sessionId: "session-1",
},
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath,
});
expect(target).toMatchObject({
agentId: "main",
sessionFile: fs.realpathSync(explicitSessionFile),
sessionId: "session-1",
sessionKey: "agent:main:main",
});
});
it("resolves an explicit read transcript file without agent identity", () => {
const explicitSessionFile = path.join(tempDir, "explicit-read-session.jsonl");
const target = resolveSessionTranscriptReadTarget({
sessionFile: explicitSessionFile,
sessionId: "session-1",
});
expect(target).toEqual({
sessionFile: explicitSessionFile,
sessionId: "session-1",
});
});
it("keeps read and write runtime targets aligned for new topic sessions", async () => {
const scope = {
agentId: "main",
+95 -14
View File
@@ -117,27 +117,17 @@ export type SessionAccessScope = {
storePath?: string;
};
export type SessionTranscriptReadScope = Omit<SessionAccessScope, "sessionKey"> & {
export type SessionTranscriptAccessScope = Omit<SessionAccessScope, "sessionKey"> & {
/** Explicit transcript file path; bypasses store lookup when already known. */
sessionFile?: string;
/** Runtime session id used to derive a transcript file when no explicit file is provided. */
sessionId: string;
/** Optional key for read callers that can resolve via the session entry. */
/** Required when resolving through session metadata; optional for explicit transcript artifacts. */
sessionKey?: string;
/** Channel thread suffix used when deriving topic transcript paths. */
threadId?: string | number;
};
export type SessionTranscriptAccessScope = SessionTranscriptReadScope & {
/**
* Identifies the owning entry when the transcript target must be resolved
* (and possibly persisted) through the session store. May be omitted only
* when an explicit sessionFile binds the operation to a concrete artifact;
* such writes never read or update entry metadata.
*/
sessionKey?: string;
};
export type SessionTranscriptRuntimeScope = SessionAccessScope & {
/** Resolved file-backed artifact for the current runtime target. */
sessionFile?: string;
@@ -145,6 +135,21 @@ export type SessionTranscriptRuntimeScope = SessionAccessScope & {
threadId?: string | number;
};
export type SessionTranscriptReadScope = Omit<SessionTranscriptRuntimeScope, "sessionKey"> & {
/** Canonical key when the caller has a session-store identity for this read. */
sessionKey?: string;
/** Entry already loaded by hot callers; avoids rereading the session store. */
sessionEntry?: Pick<SessionEntry, "sessionFile"> & Partial<Pick<SessionEntry, "sessionId">>;
};
export type SessionTranscriptReadTarget = Omit<
SessionTranscriptRuntimeTarget,
"agentId" | "sessionKey"
> & {
agentId?: string;
sessionKey?: string;
};
export type SessionTranscriptWriteScope = Omit<SessionTranscriptAccessScope, "sessionId"> & {
/** Optional for appenders that can operate on an existing explicit transcript target. */
sessionId?: string;
@@ -846,7 +851,7 @@ export async function persistSessionRolloverLifecycle(params: {
/** Reads parsed transcript records from an explicit or derived transcript target. */
export async function loadTranscriptEvents(
scope: SessionTranscriptReadScope,
scope: SessionTranscriptAccessScope,
): Promise<TranscriptEvent[]> {
const transcript = await resolveTranscriptReadAccess(scope);
const events: TranscriptEvent[] = [];
@@ -1479,6 +1484,82 @@ export async function resolveSessionTranscriptRuntimeReadTarget(
};
}
/**
* Resolves the current file-backed target for read-only transcript callers.
* Unlike writer/runtime resolution, this does not persist missing sessionFile
* metadata; reader projections must not mutate session metadata.
*/
export function resolveSessionTranscriptReadTarget(
scope: SessionTranscriptReadScope,
): SessionTranscriptReadTarget {
const explicitSessionFile = scope.sessionFile?.trim();
if (explicitSessionFile) {
return {
sessionFile: explicitSessionFile,
sessionId: scope.sessionId,
...(scope.agentId ? { agentId: scope.agentId } : {}),
...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}),
};
}
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey);
if (!agentId) {
throw new Error(`Cannot resolve transcript scope without an agent id: ${scope.sessionKey}`);
}
const storePath = resolveConcreteReadStorePath(scope.storePath);
const resolvedStoreEntry =
scope.sessionEntry || !scope.sessionKey
? undefined
: storePath
? resolveSessionStoreEntry({
store: loadSessionStore(storePath, { skipCache: true }),
sessionKey: scope.sessionKey,
})
: undefined;
const sessionEntry =
scope.sessionEntry ??
resolvedStoreEntry?.existing ??
(scope.sessionKey ? loadSessionEntry({ ...scope, sessionKey: scope.sessionKey }) : undefined);
const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey;
const matchingSessionEntry =
sessionEntry?.sessionId === undefined || sessionEntry.sessionId === scope.sessionId
? sessionEntry
: undefined;
const threadId =
scope.threadId ?? (sessionKey ? parseSessionThreadInfo(sessionKey).threadId : undefined);
const sessionFile = matchingSessionEntry?.sessionFile
? resolveSessionFilePath(
scope.sessionId,
matchingSessionEntry,
resolveSessionFilePathOptions({
agentId,
...(storePath ? { storePath } : {}),
}),
)
: storePath
? resolveSessionTranscriptPathInDir(
// File-backed readers derive beside sessions.json only for the JSON-store
// deprecation window; the SQLite flip resolves from canonical metadata.
scope.sessionId,
path.dirname(path.resolve(storePath)),
threadId,
)
: resolveSessionTranscriptPath(scope.sessionId, agentId, threadId);
return {
agentId,
sessionFile,
sessionId: scope.sessionId,
...(sessionKey ? { sessionKey } : {}),
};
}
function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined {
const trimmed = storePath?.trim();
if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) {
return undefined;
}
return trimmed;
}
function createFallbackSessionEntry(patch: Partial<SessionEntry>): SessionEntry {
const now = Date.now();
return {
@@ -1555,7 +1636,7 @@ function resolveAccessStorePath(scope: SessionAccessScope): string {
});
}
async function resolveTranscriptReadAccess(scope: SessionTranscriptReadScope): Promise<{
async function resolveTranscriptReadAccess(scope: SessionTranscriptAccessScope): Promise<{
sessionFile: string;
}> {
if (scope.sessionFile?.trim()) {
@@ -2127,8 +2127,9 @@ async function readSessionAssistantTexts(sessionKey: string, modelKey?: string):
}
const messages = await readSessionMessagesAsync(
{
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
storePath,
},
{
+10 -2
View File
@@ -292,8 +292,12 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
expect(readSessionMessagesMock).toHaveBeenCalledWith(
{
agentId: undefined,
sessionFile: "session.jsonl",
sessionEntry: {
sessionFile: "session.jsonl",
sessionId: "sess-1",
},
sessionId: "sess-1",
sessionKey: "agent:main:main",
storePath: path.join(stateDir, "gateway-sessions.json"),
},
expect.objectContaining({ allowResetArchiveFallback: true }),
@@ -1064,8 +1068,12 @@ describe("cleanupManagedOutgoingImageRecords", () => {
expect(readSessionMessagesMock).toHaveBeenCalledWith(
{
agentId: undefined,
sessionFile: "/tmp/sess-main.jsonl",
sessionEntry: {
sessionFile: "/tmp/sess-main.jsonl",
sessionId: "sess-main",
},
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: path.join(stateDir, "gateway-sessions.json"),
},
expect.objectContaining({ allowResetArchiveFallback: true }),
+2 -1
View File
@@ -731,8 +731,9 @@ async function getSessionManagedOutgoingAttachmentIndex(
const readResult = await readSessionMessagesWithSourceAsync(
{
agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId,
sessionKey,
storePath,
},
{
+5 -1
View File
@@ -244,8 +244,12 @@ describe("artifacts RPC handlers", () => {
expect(hoisted.visitSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: "/tmp/sess-main.jsonl",
sessionEntry: {
sessionFile: "/tmp/sess-main.jsonl",
sessionId: "sess-main",
},
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/sessions.json",
},
expect.any(Function),
+2 -1
View File
@@ -467,8 +467,9 @@ async function loadArtifacts(
await visitSessionMessagesAsync(
{
agentId: resolved.agentId ?? resolveAgentIdFromSessionKey(sessionKey),
sessionFile: entry?.sessionFile,
sessionEntry: entry,
sessionId,
sessionKey,
storePath,
},
(message, seq) => {
+10 -5
View File
@@ -2551,7 +2551,8 @@ function readChatHistoryMessageId(message: unknown): string | undefined {
async function isChatMessageIdVisibleAfterHistoryFilters(params: {
sessionId: string;
storePath: string | undefined;
sessionFile: string | undefined;
sessionEntry?: { sessionFile?: string; sessionId?: string };
sessionKey: string;
agentId?: string;
messageId: string;
sessionStartedAt?: number;
@@ -2563,8 +2564,9 @@ async function isChatMessageIdVisibleAfterHistoryFilters(params: {
const messages = await readSessionMessagesAsync(
{
agentId: params.agentId,
sessionFile: params.sessionFile,
sessionEntry: params.sessionEntry,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
},
{
@@ -2683,8 +2685,9 @@ async function handleChatHistoryRequest({
? await readRecentSessionMessagesAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile,
sessionEntry: entry,
sessionId,
sessionKey: canonicalKey,
storePath,
},
{
@@ -2876,8 +2879,9 @@ export const chatHandlers: GatewayRequestHandlers = {
const resolved = await readSessionMessageByIdAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile,
sessionEntry: entry,
sessionId,
sessionKey,
storePath,
},
messageId,
@@ -2890,7 +2894,8 @@ export const chatHandlers: GatewayRequestHandlers = {
const visible = await isChatMessageIdVisibleAfterHistoryFilters({
sessionId,
storePath,
sessionFile: entry?.sessionFile,
sessionEntry: entry,
sessionKey,
agentId: sessionAgentId,
messageId,
sessionStartedAt:
+2 -1
View File
@@ -604,8 +604,9 @@ async function loadSessionFiles(params: {
await visitSessionMessagesAsync(
{
agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: canonicalKey,
storePath,
},
(message) => collectTouchedFilesFromMessage(message, files),
+8 -4
View File
@@ -830,8 +830,9 @@ async function handleSessionSend(params: {
const messageSeq =
(await readSessionMessageCountAsync({
agentId: requestedAgentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: canonicalKey,
storePath,
})) + 1;
let sendAcked = false;
@@ -1173,8 +1174,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const items = readSessionPreviewItemsFromTranscript(
{
agentId: target.agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
},
limit,
@@ -1532,8 +1534,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const messageSeq = initialMessage
? (await readSessionMessageCountAsync({
agentId: target.agentId,
sessionFile: createdEntry.sessionFile,
sessionEntry: createdEntry,
sessionId: createdEntry.sessionId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
})) + 1
: undefined;
@@ -2398,8 +2401,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const { messages } = await readRecentSessionMessagesWithStatsAsync(
{
agentId: requestedAgent.agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: key,
storePath,
},
{
+2 -1
View File
@@ -185,8 +185,9 @@ async function handleTranscriptUpdateBroadcast(
? asPositiveSafeInteger(
await readSessionMessageCountAsync({
agentId: visibleAgentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
storePath,
}),
)
+2 -2
View File
@@ -35,7 +35,7 @@ function userTextMessage(text: string, seq: number) {
function newState(rawMessages: Array<Record<string, unknown>>, options: RawStateOptions = {}) {
return SessionHistorySseState.fromRawSnapshot({
target: { sessionId: "sess-main" },
target: { sessionId: "sess-main", sessionKey: "agent:main:main" },
rawMessages,
...options,
});
@@ -192,7 +192,7 @@ describe("SessionHistorySseState", () => {
test("keeps message-tool mirror pending across projected sessions_send inline history", () => {
const state = SessionHistorySseState.fromRawSnapshot({
target: { sessionId: "sess-main" },
target: { sessionId: "sess-main", sessionKey: "agent:main:main" },
rawMessages: [
{
role: "assistant",
+6 -3
View File
@@ -43,9 +43,10 @@ type InlineSessionHistoryAppend = {
type SessionHistoryTranscriptTarget = {
agentId?: string;
sessionEntry?: { sessionFile?: string; sessionId?: string };
sessionId: string;
sessionKey: string;
storePath?: string;
sessionFile?: string;
};
type SessionHistoryRawSnapshot = {
@@ -364,8 +365,9 @@ export class SessionHistorySseState {
const snapshot = await readRecentSessionMessagesWithStatsAsync(
{
agentId: this.target.agentId,
sessionFile: this.target.sessionFile,
sessionEntry: this.target.sessionEntry,
sessionId: this.target.sessionId,
sessionKey: this.target.sessionKey,
storePath: this.target.storePath,
},
{
@@ -383,8 +385,9 @@ export class SessionHistorySseState {
const snapshot = await readSessionMessagesWithSourceAsync(
{
agentId: this.target.agentId,
sessionFile: this.target.sessionFile,
sessionEntry: this.target.sessionEntry,
sessionId: this.target.sessionId,
sessionKey: this.target.sessionKey,
storePath: this.target.storePath,
},
{
+2 -1
View File
@@ -800,8 +800,9 @@ export async function emitGatewayBeforeResetPluginHook(params: {
messages = await readSessionMessagesAsync(
{
agentId,
sessionFile,
sessionEntry: params.entry,
sessionId,
sessionKey,
storePath: params.storePath,
},
{
@@ -41,7 +41,7 @@ describe("session transcript reader facade", () => {
`${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
"utf-8",
);
return { sessionId, storePath };
return { sessionId, sessionKey: `agent:main:${sessionId}`, storePath };
}
test("reads active-branch messages and message ids through a scope", async () => {
+139 -94
View File
@@ -1,3 +1,5 @@
import type { SessionTranscriptReadScope } from "../config/sessions/session-accessor.js";
import { resolveSessionTranscriptReadTarget } from "../config/sessions/session-accessor.js";
import type {
ReadRecentSessionMessagesOptions,
ReadSessionMessagesAsyncOptions,
@@ -30,12 +32,7 @@ import {
export type { ReadRecentSessionMessagesOptions, ReadSessionMessagesAsyncOptions };
export { attachOpenClawTranscriptMeta, capArrayByJsonBytes } from "./session-utils.fs.js";
export type SessionTranscriptReadScope = {
agentId?: string;
sessionFile?: string;
sessionId: string;
storePath?: string;
};
export type { SessionTranscriptReadScope };
type SessionTitleFields = {
firstUserMessage: string | null;
@@ -72,13 +69,40 @@ type SessionTranscriptUsageSnapshot = {
costUsd?: number;
};
type FileBackedReadScope = {
agentId?: string;
sessionFile: string;
sessionId: string;
storePath?: string;
};
function resolveFileBackedReadScope(scope: SessionTranscriptReadScope): FileBackedReadScope {
const target = resolveSessionTranscriptReadTarget(scope);
const storePath = resolveConcreteReadStorePath(scope.storePath);
return {
agentId: target.agentId,
sessionFile: target.sessionFile,
sessionId: target.sessionId,
...(storePath ? { storePath } : {}),
};
}
function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined {
const trimmed = storePath?.trim();
if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) {
return undefined;
}
return trimmed;
}
/** Reads display messages from a session transcript through the reader seam. */
export function readSessionMessages(scope: SessionTranscriptReadScope): unknown[] {
const target = resolveFileBackedReadScope(scope);
return readSessionMessagesFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
);
}
@@ -87,12 +111,13 @@ export function readRecentSessionMessages(
scope: SessionTranscriptReadScope,
opts?: ReadRecentSessionMessagesOptions,
): unknown[] {
const target = resolveFileBackedReadScope(scope);
return readRecentSessionMessagesFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
opts,
scope.agentId,
target.agentId,
);
}
@@ -101,22 +126,24 @@ export function visitSessionMessages(
scope: SessionTranscriptReadScope,
visit: (message: unknown, seq: number) => void,
): number {
const target = resolveFileBackedReadScope(scope);
return visitSessionMessagesFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
visit,
scope.agentId,
target.agentId,
);
}
/** Counts display messages in a session transcript through the reader seam. */
export function readSessionMessageCount(scope: SessionTranscriptReadScope): number {
const target = resolveFileBackedReadScope(scope);
return readSessionMessageCountFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
);
}
@@ -125,12 +152,13 @@ export async function readSessionMessagesAsync(
scope: SessionTranscriptReadScope,
opts: ReadSessionMessagesAsyncOptions,
): Promise<unknown[]> {
const target = resolveFileBackedReadScope(scope);
return await readSessionMessagesAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
opts,
scope.agentId,
target.agentId,
);
}
@@ -139,12 +167,13 @@ export async function readSessionMessagesWithSourceAsync(
scope: SessionTranscriptReadScope,
opts: ReadSessionMessagesAsyncOptions,
): Promise<ReadSessionMessagesResult> {
const target = resolveFileBackedReadScope(scope);
return await readSessionMessagesWithSourceAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
opts,
scope.agentId,
target.agentId,
);
}
@@ -153,12 +182,13 @@ export async function readRecentSessionMessagesAsync(
scope: SessionTranscriptReadScope,
opts?: ReadRecentSessionMessagesOptions,
): Promise<unknown[]> {
const target = resolveFileBackedReadScope(scope);
return await readRecentSessionMessagesAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
opts,
scope.agentId,
target.agentId,
);
}
@@ -168,12 +198,13 @@ export async function readSessionMessageByIdAsync(
messageId: string,
opts?: { allowResetArchiveFallback?: boolean },
): Promise<ReadSessionMessageByIdResult> {
const target = resolveFileBackedReadScope(scope);
return await readSessionMessageByIdAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
messageId,
{ ...opts, agentId: scope.agentId },
{ ...opts, agentId: target.agentId },
);
}
@@ -183,13 +214,14 @@ export async function visitSessionMessagesAsync(
visit: (message: unknown, seq: number) => void,
opts: { mode: "full"; reason: string; cache?: "reuse" | "skip" },
): Promise<number> {
const target = resolveFileBackedReadScope(scope);
return await visitSessionMessagesAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
visit,
opts,
scope.agentId,
target.agentId,
);
}
@@ -197,11 +229,12 @@ export async function visitSessionMessagesAsync(
export async function readSessionMessageCountAsync(
scope: SessionTranscriptReadScope,
): Promise<number> {
const target = resolveFileBackedReadScope(scope);
return await readSessionMessageCountAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
);
}
@@ -210,12 +243,13 @@ export function readRecentSessionMessagesWithStats(
scope: SessionTranscriptReadScope,
opts: ReadRecentSessionMessagesOptions,
): ReadRecentSessionMessagesResult {
const target = resolveFileBackedReadScope(scope);
return readRecentSessionMessagesWithStatsFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
opts,
scope.agentId,
target.agentId,
);
}
@@ -224,12 +258,13 @@ export async function readRecentSessionMessagesWithStatsAsync(
scope: SessionTranscriptReadScope,
opts: ReadRecentSessionMessagesOptions,
): Promise<ReadRecentSessionMessagesResult> {
const target = resolveFileBackedReadScope(scope);
return await readRecentSessionMessagesWithStatsAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
target.sessionId,
target.storePath,
target.sessionFile,
opts,
scope.agentId,
target.agentId,
);
}
@@ -239,11 +274,12 @@ export function readRecentSessionTranscriptLines(
maxLines: number;
},
): { lines: string[]; totalLines: number } | null {
const target = resolveFileBackedReadScope(params);
return readRecentSessionTranscriptLinesFile({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
agentId: params.agentId,
sessionId: target.sessionId,
storePath: target.storePath,
sessionFile: target.sessionFile,
agentId: target.agentId,
maxLines: params.maxLines,
});
}
@@ -253,11 +289,12 @@ export function readSessionTitleFieldsFromTranscript(
scope: SessionTranscriptReadScope,
opts?: { includeInterSession?: boolean },
): SessionTitleFields {
const target = resolveFileBackedReadScope(scope);
return readSessionTitleFieldsFromTranscriptFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
opts,
);
}
@@ -267,11 +304,12 @@ export async function readSessionTitleFieldsFromTranscriptAsync(
scope: SessionTranscriptReadScope,
opts?: { includeInterSession?: boolean },
): Promise<SessionTitleFields> {
const target = resolveFileBackedReadScope(scope);
return await readSessionTitleFieldsFromTranscriptAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
opts,
);
}
@@ -281,11 +319,12 @@ export function readFirstUserMessageFromTranscript(
scope: SessionTranscriptReadScope,
opts?: { includeInterSession?: boolean },
): string | null {
const target = resolveFileBackedReadScope(scope);
return readFirstUserMessageFromTranscriptFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
opts,
);
}
@@ -294,11 +333,12 @@ export function readFirstUserMessageFromTranscript(
export function readLatestSessionUsageFromTranscript(
scope: SessionTranscriptReadScope,
): SessionTranscriptUsageSnapshot | null {
const target = resolveFileBackedReadScope(scope);
return readLatestSessionUsageFromTranscriptFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
);
}
@@ -306,11 +346,12 @@ export function readLatestSessionUsageFromTranscript(
export async function readLatestSessionUsageFromTranscriptAsync(
scope: SessionTranscriptReadScope,
): Promise<SessionTranscriptUsageSnapshot | null> {
const target = resolveFileBackedReadScope(scope);
return await readLatestSessionUsageFromTranscriptAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
);
}
@@ -319,11 +360,12 @@ export async function readRecentSessionUsageFromTranscriptAsync(
scope: SessionTranscriptReadScope,
maxBytes: number,
): Promise<SessionTranscriptUsageSnapshot | null> {
const target = resolveFileBackedReadScope(scope);
return await readRecentSessionUsageFromTranscriptAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
maxBytes,
);
}
@@ -333,11 +375,12 @@ export async function readLatestRecentSessionUsageFromTranscriptAsync(
scope: SessionTranscriptReadScope,
maxBytes: number,
): Promise<SessionTranscriptUsageSnapshot | null> {
const target = resolveFileBackedReadScope(scope);
return await readLatestRecentSessionUsageFromTranscriptAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
maxBytes,
);
}
@@ -347,11 +390,12 @@ export function readRecentSessionUsageFromTranscript(
scope: SessionTranscriptReadScope,
maxBytes: number,
): SessionTranscriptUsageSnapshot | null {
const target = resolveFileBackedReadScope(scope);
return readRecentSessionUsageFromTranscriptFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
maxBytes,
);
}
@@ -362,11 +406,12 @@ export function readSessionPreviewItemsFromTranscript(
maxItems: number,
maxChars: number,
): ReturnType<typeof readSessionPreviewItemsFromTranscriptFile> {
const target = resolveFileBackedReadScope(scope);
return readSessionPreviewItemsFromTranscriptFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
scope.agentId,
target.sessionId,
target.storePath,
target.sessionFile,
target.agentId,
maxItems,
maxChars,
);
+105 -104
View File
@@ -886,8 +886,9 @@ function resolveTranscriptUsageFallback(params: {
const snapshot = readScopedRecentSessionUsageFromTranscript(
{
agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: params.key,
storePath: params.storePath,
},
typeof params.maxTranscriptBytes === "number" ? params.maxTranscriptBytes : 256 * 1024,
@@ -1294,9 +1295,7 @@ export function listAgentsForGateway(
.filter(Boolean),
);
const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null;
let agentIds = listGatewayAgentIds(cfg).filter((id) =>
allowedIds ? allowedIds.has(id) : true,
);
let agentIds = listGatewayAgentIds(cfg).filter((id) => (allowedIds ? allowedIds.has(id) : true));
if (mainKey && !agentIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) {
agentIds = [...agentIds, mainKey];
}
@@ -2162,8 +2161,9 @@ export function buildGatewaySessionRow(params: {
if (entry?.sessionId && (params.includeDerivedTitles || params.includeLastMessage)) {
const fields = readScopedSessionTitleFieldsFromTranscript({
agentId: sessionAgentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: key,
storePath,
});
if (params.includeDerivedTitles) {
@@ -2795,110 +2795,111 @@ export async function listSessionsFromStoreAsync(params: {
// between rows, the memo never hits, and each row triggers a full
// loadPluginMetadataSnapshot scan (~100 ms).
return withPinnedActivePluginRegistryWorkspaceDir(async () => {
const { cfg, storePath, store, opts } = params;
const now = Date.now();
const sessionListTranscriptUsageMaxBytes = 64 * 1024;
const sessionListTranscriptFieldRows = 100;
let rowContext: SessionListRowContext | undefined;
const getRowContext = () => {
rowContext ??= buildSessionListRowContext({ store, now });
return rowContext;
};
const includeDerivedTitles = opts.includeDerivedTitles === true;
const includeLastMessage = opts.includeLastMessage === true;
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
const { cfg, storePath, store, opts } = params;
const now = Date.now();
const sessionListTranscriptUsageMaxBytes = 64 * 1024;
const sessionListTranscriptFieldRows = 100;
let rowContext: SessionListRowContext | undefined;
const getRowContext = () => {
rowContext ??= buildSessionListRowContext({ store, now });
return rowContext;
};
const includeDerivedTitles = opts.includeDerivedTitles === true;
const includeLastMessage = opts.includeLastMessage === true;
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
const selection = selectSessionEntries({
cfg,
store,
opts,
now,
getRowContext:
hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search))
? getRowContext
: undefined,
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
});
const { entries, 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);
const sessions: GatewaySessionRow[] = [];
for (let i = 0; i < entries.length; i++) {
const [key, entry] = entries[i];
const includeTranscriptFields = i < sessionListTranscriptFieldRows;
const rowAgentId =
key === "global" && typeof opts.agentId === "string"
? normalizeAgentId(opts.agentId)
: undefined;
const storeChildSessionsByKey =
fullRowContext?.storeChildSessionsByKey ??
buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now });
const row = buildGatewaySessionRow({
const selection = selectSessionEntries({
cfg,
storePath,
store,
key,
entry,
agentId: rowAgentId,
modelCatalog: params.modelCatalog,
opts,
now,
includeDerivedTitles: false,
includeLastMessage: false,
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
storeChildSessionsByKey,
rowContext: sharedRowContext,
skipTranscriptUsageFallback: true,
lightweightListRow: true,
getRowContext:
hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search))
? getRowContext
: undefined,
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
});
if (
entry?.sessionId &&
includeTranscriptFields &&
(includeDerivedTitles || includeLastMessage)
) {
const parsed = parseAgentSessionKey(key);
const sessionAgentId =
rowAgentId ??
(parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(cfg));
const fields = await readScopedSessionTitleFieldsFromTranscriptAsync({
agentId: sessionAgentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
});
if (includeDerivedTitles) {
row.derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
}
if (includeLastMessage && fields.lastMessagePreview) {
row.lastMessagePreview = fields.lastMessagePreview;
}
}
sessions.push(row);
// Yield to the event loop between batches so WebSocket heartbeats,
// channel I/O, and concurrent RPC calls are not starved.
if ((i + 1) % SESSIONS_LIST_YIELD_BATCH_SIZE === 0 && i + 1 < entries.length) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
const { entries, 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);
return {
ts: now,
path: storePath,
count: sessions.length,
totalCount,
limitApplied,
offset: offset > 0 ? offset : undefined,
nextOffset,
hasMore,
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
sessions,
};
const sessions: GatewaySessionRow[] = [];
for (let i = 0; i < entries.length; i++) {
const [key, entry] = entries[i];
const includeTranscriptFields = i < sessionListTranscriptFieldRows;
const rowAgentId =
key === "global" && typeof opts.agentId === "string"
? normalizeAgentId(opts.agentId)
: undefined;
const storeChildSessionsByKey =
fullRowContext?.storeChildSessionsByKey ??
buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now });
const row = buildGatewaySessionRow({
cfg,
storePath,
store,
key,
entry,
agentId: rowAgentId,
modelCatalog: params.modelCatalog,
now,
includeDerivedTitles: false,
includeLastMessage: false,
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
storeChildSessionsByKey,
rowContext: sharedRowContext,
skipTranscriptUsageFallback: true,
lightweightListRow: true,
});
if (
entry?.sessionId &&
includeTranscriptFields &&
(includeDerivedTitles || includeLastMessage)
) {
const parsed = parseAgentSessionKey(key);
const sessionAgentId =
rowAgentId ??
(parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(cfg));
const fields = await readScopedSessionTitleFieldsFromTranscriptAsync({
agentId: sessionAgentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: key,
storePath,
});
if (includeDerivedTitles) {
row.derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
}
if (includeLastMessage && fields.lastMessagePreview) {
row.lastMessagePreview = fields.lastMessagePreview;
}
}
sessions.push(row);
// Yield to the event loop between batches so WebSocket heartbeats,
// channel I/O, and concurrent RPC calls are not starved.
if ((i + 1) % SESSIONS_LIST_YIELD_BATCH_SIZE === 0 && i + 1 < entries.length) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
return {
ts: now,
path: storePath,
count: sessions.length,
totalCount,
limitApplied,
offset: offset > 0 ? offset : undefined,
nextOffset,
hasMore,
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
sessions,
};
});
}
+6 -3
View File
@@ -149,8 +149,9 @@ export async function handleSessionHistoryHttpRequest(
? await readRecentSessionMessagesWithStatsAsync(
{
agentId: target.agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
},
{
@@ -166,8 +167,9 @@ export async function handleSessionHistoryHttpRequest(
? await readSessionMessagesWithSourceAsync(
{
agentId: target.agentId,
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
},
{
@@ -213,9 +215,10 @@ export async function handleSessionHistoryHttpRequest(
const sseState = SessionHistorySseState.fromRawSnapshot({
target: {
agentId: target.agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: target.canonicalKey,
storePath: target.storePath,
sessionFile: entry.sessionFile,
},
rawMessages: rawSnapshot,
rawTranscriptSeq: boundedSnapshot?.totalMessages,
+3 -1
View File
@@ -322,8 +322,10 @@ const readUsageFromSessionLog = (
const snapshot = readRecentSessionUsageFromTranscript(
{
agentId: agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined),
sessionFile: sessionEntry?.sessionFile,
sessionEntry,
sessionFile: logPath,
sessionId,
sessionKey,
storePath,
},
256 * 1024,
+2 -1
View File
@@ -674,8 +674,9 @@ describe("EmbeddedTuiBackend", () => {
expect(readSessionMessagesAsyncMock).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{
+2 -1
View File
@@ -450,8 +450,9 @@ export class EmbeddedTuiBackend implements TuiBackend {
? await readSessionMessagesAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile,
sessionEntry: entry,
sessionId,
sessionKey: canonicalKey,
storePath,
},
{
@@ -43,10 +43,20 @@ describe("session accessor boundary guard", () => {
"src/cron/isolated-agent/delivery-target.ts",
"src/cron/service/timer.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/gateway/session-history-state.ts",
"src/gateway/session-utils.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions.ts",
"src/gateway/server-session-events.ts",
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/status/status-message.ts",
"src/tui/embedded-backend.ts",
]),
);
@@ -128,4 +128,22 @@ describe("session transcript reader boundary guard", () => {
`),
).toEqual([]);
});
it("flags storage-specific reader aliases in migrated files", () => {
expect(
findSessionTranscriptReaderBoundaryViolations(`
import { readSessionMessagesAsync as readSessionMessagesFromFileAsync } from "./session-transcript-readers.js";
await readSessionMessagesFromFileAsync(scope, opts);
`),
).toEqual([
{
line: 2,
reason: 'uses storage-specific transcript reader alias "readSessionMessagesFromFileAsync"',
},
{
line: 3,
reason: 'uses storage-specific transcript reader alias "readSessionMessagesFromFileAsync"',
},
]);
});
});