Skip to content

Commit d216f7c

Browse files
authored
refactor: use canonical transcript reader identity (#89581)
* refactor: use canonical transcript reader identity * refactor: keep transcript reader dependency storage-neutral
1 parent d41a3d2 commit d216f7c

31 files changed

Lines changed: 531 additions & 246 deletions

scripts/check-session-accessor-boundary.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,20 @@ export const migratedSessionAccessorFiles = new Set([
9595
"src/cron/isolated-agent/delivery-target.ts",
9696
"src/cron/service/timer.ts",
9797
"src/gateway/session-compaction-checkpoints.ts",
98+
"src/gateway/session-history-state.ts",
9899
"src/gateway/session-utils.ts",
100+
"src/gateway/managed-image-attachments.ts",
101+
"src/gateway/server-methods/artifacts.ts",
102+
"src/gateway/server-methods/chat.ts",
99103
"src/gateway/sessions-resolve.ts",
104+
"src/gateway/server-methods/sessions-files.ts",
100105
"src/gateway/server-methods/sessions.ts",
106+
"src/gateway/server-session-events.ts",
107+
"src/gateway/session-reset-service.ts",
101108
"src/infra/outbound/message-action-tts.ts",
109+
"src/agents/tools/embedded-gateway-stub.ts",
110+
"src/agents/tools/sessions-list-tool.ts",
111+
"src/status/status-message.ts",
102112
"src/tui/embedded-backend.ts",
103113
]);
104114

scripts/check-session-transcript-reader-boundary.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const transcriptReaderNames = new Set([
4949
"visitSessionMessagesAsync",
5050
]);
5151

52+
const storageSpecificTranscriptReaderAliasNames = new Set(["readSessionMessagesFromFileAsync"]);
53+
5254
export const migratedSessionTranscriptReaderFiles = new Set([
5355
"src/agents/main-session-restart-recovery.ts",
5456
"src/agents/subagent-announce-output.test.ts",
@@ -126,6 +128,13 @@ export function findSessionTranscriptReaderBoundaryViolations(content, fileName
126128
const legacyNamespaces = new Set();
127129

128130
const visit = (node) => {
131+
if (ts.isIdentifier(node) && storageSpecificTranscriptReaderAliasNames.has(node.text)) {
132+
violations.push({
133+
line: toLine(sourceFile, node),
134+
reason: `uses storage-specific transcript reader alias "${node.text}"`,
135+
});
136+
}
137+
129138
if (ts.isImportDeclaration(node)) {
130139
const moduleName = importedModuleName(node);
131140
const namedBindings = node.importClause?.namedBindings;

src/agents/main-session-restart-recovery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,8 +805,9 @@ async function recoverStore(params: {
805805
messages = await readSessionMessagesAsync(
806806
{
807807
agentId: resolveAgentIdFromSessionKey(sessionKey),
808-
sessionFile: entry.sessionFile,
808+
sessionEntry: entry,
809809
sessionId: entry.sessionId,
810+
sessionKey,
810811
storePath: params.storePath,
811812
},
812813
{

src/agents/subagent-orphan-recovery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,9 @@ export async function recoverOrphanedSubagentSessions(params: {
296296
const messages = await readSessionMessagesAsync(
297297
{
298298
agentId: resolveAgentIdFromSessionKey(childSessionKey),
299-
sessionFile: entry.sessionFile,
299+
sessionEntry: entry,
300300
sessionId: entry.sessionId,
301+
sessionKey: childSessionKey,
301302
storePath,
302303
},
303304
{

src/agents/test-helpers/fast-openclaw-tools-sessions.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,16 @@ vi.mock("../../channels/plugins/session-conversation.js", () => ({
6969
threadId: match.groups.threadId,
7070
};
7171
},
72+
resolveSessionThreadInfo: (sessionKey: string | undefined | null) => {
73+
const trimmed = sessionKey?.trim();
74+
const topicMarker = ":topic:";
75+
const topicIndex = trimmed?.lastIndexOf(topicMarker) ?? -1;
76+
if (!trimmed || topicIndex < 0) {
77+
return { baseSessionKey: trimmed, threadId: undefined };
78+
}
79+
return {
80+
baseSessionKey: trimmed.slice(0, topicIndex),
81+
threadId: trimmed.slice(topicIndex + topicMarker.length) || undefined,
82+
};
83+
},
7284
}));

src/agents/tools/embedded-gateway-stub.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ describe("embedded gateway stub", () => {
126126
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
127127
{
128128
agentId: "main",
129-
sessionFile: undefined,
129+
sessionEntry: { sessionId: "sess-main" },
130130
sessionId: "sess-main",
131+
sessionKey: "agent:main:main",
131132
storePath: "/tmp/openclaw-sessions.json",
132133
},
133134
{
@@ -192,8 +193,9 @@ describe("embedded gateway stub", () => {
192193
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
193194
{
194195
agentId: "main",
195-
sessionFile: undefined,
196+
sessionEntry: { sessionId: "sess-main" },
196197
sessionId: "sess-main",
198+
sessionKey: "agent:main:main",
197199
storePath: "/tmp/openclaw-sessions.json",
198200
},
199201
{
@@ -225,8 +227,9 @@ describe("embedded gateway stub", () => {
225227
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
226228
{
227229
agentId: "main",
228-
sessionFile: undefined,
230+
sessionEntry: { sessionId: "sess-main" },
229231
sessionId: "sess-main",
232+
sessionKey: "agent:main:main",
230233
storePath: "/tmp/openclaw-sessions.json",
231234
},
232235
{

src/agents/tools/embedded-gateway-stub.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,22 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
155155
const requested = typeof limit === "number" ? limit : defaultLimit;
156156
const max = Math.min(hardMax, requested);
157157
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
158+
const sessionEntry =
159+
typeof entry?.sessionId === "string"
160+
? {
161+
sessionId: entry.sessionId,
162+
...(typeof entry.sessionFile === "string" ? { sessionFile: entry.sessionFile } : {}),
163+
}
164+
: undefined;
158165

159166
const localMessages =
160167
sessionId && storePath
161168
? await rt.readSessionMessagesAsync(
162169
{
163170
agentId: sessionAgentId,
164-
sessionFile: entry?.sessionFile as string | undefined,
171+
sessionEntry,
165172
sessionId,
173+
sessionKey,
166174
storePath,
167175
},
168176
{

src/agents/tools/sessions-list-tool.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,9 @@ export function createSessionsListTool(opts?: {
156156
const titleTargets: Array<{
157157
row: SessionListRow;
158158
titleEntry: SessionEntry;
159+
sessionEntry: { sessionFile?: string; sessionId: string };
159160
sessionId: string;
160-
sessionFile?: string;
161+
sessionKey: string;
161162
agentId: string;
162163
}> = [];
163164

@@ -356,8 +357,16 @@ export function createSessionsListTool(opts?: {
356357
subject: readStringValue((entry as { subject?: unknown }).subject),
357358
updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0,
358359
},
360+
sessionEntry: {
361+
sessionId,
362+
...(sessionFile ? { sessionFile } : {}),
363+
},
359364
sessionId,
360-
...(sessionFile ? { sessionFile } : {}),
365+
sessionKey: resolveInternalSessionKey({
366+
key,
367+
alias,
368+
mainKey,
369+
}),
361370
agentId: resolvedAgentId,
362371
});
363372
}
@@ -385,8 +394,9 @@ export function createSessionsListTool(opts?: {
385394
const target = titleTargets[next];
386395
const fields = await readSessionTitleFieldsFromTranscriptAsync({
387396
agentId: target.agentId,
388-
sessionFile: target.sessionFile,
397+
sessionEntry: target.sessionEntry,
389398
sessionId: target.sessionId,
399+
sessionKey: target.sessionKey,
390400
storePath,
391401
});
392402
if (includeDerivedTitles && !target.row.derivedTitle) {

src/config/sessions/session-accessor.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
publishTranscriptUpdate,
2525
readSessionUpdatedAt,
2626
replaceSessionEntry,
27+
resolveSessionTranscriptReadTarget,
2728
resolveSessionTranscriptRuntimeReadTarget,
2829
resolveSessionTranscriptRuntimeTarget,
2930
trimSessionTranscriptForManualCompact,
@@ -1475,6 +1476,44 @@ describe("session accessor file-backed seam", () => {
14751476
expect(loadSessionEntry(scope)?.sessionFile).toBeUndefined();
14761477
});
14771478

1479+
it("uses a supplied read session entry without loading the store", () => {
1480+
const explicitSessionFile = path.join(tempDir, "entry-session.jsonl");
1481+
fs.writeFileSync(explicitSessionFile, "", "utf8");
1482+
fs.writeFileSync(storePath, "{not-json", "utf8");
1483+
1484+
const target = resolveSessionTranscriptReadTarget({
1485+
agentId: "main",
1486+
sessionEntry: {
1487+
sessionFile: explicitSessionFile,
1488+
sessionId: "session-1",
1489+
},
1490+
sessionId: "session-1",
1491+
sessionKey: "agent:main:main",
1492+
storePath,
1493+
});
1494+
1495+
expect(target).toMatchObject({
1496+
agentId: "main",
1497+
sessionFile: fs.realpathSync(explicitSessionFile),
1498+
sessionId: "session-1",
1499+
sessionKey: "agent:main:main",
1500+
});
1501+
});
1502+
1503+
it("resolves an explicit read transcript file without agent identity", () => {
1504+
const explicitSessionFile = path.join(tempDir, "explicit-read-session.jsonl");
1505+
1506+
const target = resolveSessionTranscriptReadTarget({
1507+
sessionFile: explicitSessionFile,
1508+
sessionId: "session-1",
1509+
});
1510+
1511+
expect(target).toEqual({
1512+
sessionFile: explicitSessionFile,
1513+
sessionId: "session-1",
1514+
});
1515+
});
1516+
14781517
it("keeps read and write runtime targets aligned for new topic sessions", async () => {
14791518
const scope = {
14801519
agentId: "main",

src/config/sessions/session-accessor.ts

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,34 +117,39 @@ export type SessionAccessScope = {
117117
storePath?: string;
118118
};
119119

120-
export type SessionTranscriptReadScope = Omit<SessionAccessScope, "sessionKey"> & {
120+
export type SessionTranscriptAccessScope = Omit<SessionAccessScope, "sessionKey"> & {
121121
/** Explicit transcript file path; bypasses store lookup when already known. */
122122
sessionFile?: string;
123123
/** Runtime session id used to derive a transcript file when no explicit file is provided. */
124124
sessionId: string;
125-
/** Optional key for read callers that can resolve via the session entry. */
125+
/** Required when resolving through session metadata; optional for explicit transcript artifacts. */
126126
sessionKey?: string;
127127
/** Channel thread suffix used when deriving topic transcript paths. */
128128
threadId?: string | number;
129129
};
130130

131-
export type SessionTranscriptAccessScope = SessionTranscriptReadScope & {
132-
/**
133-
* Identifies the owning entry when the transcript target must be resolved
134-
* (and possibly persisted) through the session store. May be omitted only
135-
* when an explicit sessionFile binds the operation to a concrete artifact;
136-
* such writes never read or update entry metadata.
137-
*/
138-
sessionKey?: string;
139-
};
140-
141131
export type SessionTranscriptRuntimeScope = SessionAccessScope & {
142132
/** Resolved file-backed artifact for the current runtime target. */
143133
sessionFile?: string;
144134
sessionId: string;
145135
threadId?: string | number;
146136
};
147137

138+
export type SessionTranscriptReadScope = Omit<SessionTranscriptRuntimeScope, "sessionKey"> & {
139+
/** Canonical key when the caller has a session-store identity for this read. */
140+
sessionKey?: string;
141+
/** Entry already loaded by hot callers; avoids rereading the session store. */
142+
sessionEntry?: Pick<SessionEntry, "sessionFile"> & Partial<Pick<SessionEntry, "sessionId">>;
143+
};
144+
145+
export type SessionTranscriptReadTarget = Omit<
146+
SessionTranscriptRuntimeTarget,
147+
"agentId" | "sessionKey"
148+
> & {
149+
agentId?: string;
150+
sessionKey?: string;
151+
};
152+
148153
export type SessionTranscriptWriteScope = Omit<SessionTranscriptAccessScope, "sessionId"> & {
149154
/** Optional for appenders that can operate on an existing explicit transcript target. */
150155
sessionId?: string;
@@ -846,7 +851,7 @@ export async function persistSessionRolloverLifecycle(params: {
846851

847852
/** Reads parsed transcript records from an explicit or derived transcript target. */
848853
export async function loadTranscriptEvents(
849-
scope: SessionTranscriptReadScope,
854+
scope: SessionTranscriptAccessScope,
850855
): Promise<TranscriptEvent[]> {
851856
const transcript = await resolveTranscriptReadAccess(scope);
852857
const events: TranscriptEvent[] = [];
@@ -1479,6 +1484,82 @@ export async function resolveSessionTranscriptRuntimeReadTarget(
14791484
};
14801485
}
14811486

1487+
/**
1488+
* Resolves the current file-backed target for read-only transcript callers.
1489+
* Unlike writer/runtime resolution, this does not persist missing sessionFile
1490+
* metadata; reader projections must not mutate session metadata.
1491+
*/
1492+
export function resolveSessionTranscriptReadTarget(
1493+
scope: SessionTranscriptReadScope,
1494+
): SessionTranscriptReadTarget {
1495+
const explicitSessionFile = scope.sessionFile?.trim();
1496+
if (explicitSessionFile) {
1497+
return {
1498+
sessionFile: explicitSessionFile,
1499+
sessionId: scope.sessionId,
1500+
...(scope.agentId ? { agentId: scope.agentId } : {}),
1501+
...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}),
1502+
};
1503+
}
1504+
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey);
1505+
if (!agentId) {
1506+
throw new Error(`Cannot resolve transcript scope without an agent id: ${scope.sessionKey}`);
1507+
}
1508+
const storePath = resolveConcreteReadStorePath(scope.storePath);
1509+
const resolvedStoreEntry =
1510+
scope.sessionEntry || !scope.sessionKey
1511+
? undefined
1512+
: storePath
1513+
? resolveSessionStoreEntry({
1514+
store: loadSessionStore(storePath, { skipCache: true }),
1515+
sessionKey: scope.sessionKey,
1516+
})
1517+
: undefined;
1518+
const sessionEntry =
1519+
scope.sessionEntry ??
1520+
resolvedStoreEntry?.existing ??
1521+
(scope.sessionKey ? loadSessionEntry({ ...scope, sessionKey: scope.sessionKey }) : undefined);
1522+
const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey;
1523+
const matchingSessionEntry =
1524+
sessionEntry?.sessionId === undefined || sessionEntry.sessionId === scope.sessionId
1525+
? sessionEntry
1526+
: undefined;
1527+
const threadId =
1528+
scope.threadId ?? (sessionKey ? parseSessionThreadInfo(sessionKey).threadId : undefined);
1529+
const sessionFile = matchingSessionEntry?.sessionFile
1530+
? resolveSessionFilePath(
1531+
scope.sessionId,
1532+
matchingSessionEntry,
1533+
resolveSessionFilePathOptions({
1534+
agentId,
1535+
...(storePath ? { storePath } : {}),
1536+
}),
1537+
)
1538+
: storePath
1539+
? resolveSessionTranscriptPathInDir(
1540+
// File-backed readers derive beside sessions.json only for the JSON-store
1541+
// deprecation window; the SQLite flip resolves from canonical metadata.
1542+
scope.sessionId,
1543+
path.dirname(path.resolve(storePath)),
1544+
threadId,
1545+
)
1546+
: resolveSessionTranscriptPath(scope.sessionId, agentId, threadId);
1547+
return {
1548+
agentId,
1549+
sessionFile,
1550+
sessionId: scope.sessionId,
1551+
...(sessionKey ? { sessionKey } : {}),
1552+
};
1553+
}
1554+
1555+
function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined {
1556+
const trimmed = storePath?.trim();
1557+
if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) {
1558+
return undefined;
1559+
}
1560+
return trimmed;
1561+
}
1562+
14821563
function createFallbackSessionEntry(patch: Partial<SessionEntry>): SessionEntry {
14831564
const now = Date.now();
14841565
return {
@@ -1555,7 +1636,7 @@ function resolveAccessStorePath(scope: SessionAccessScope): string {
15551636
});
15561637
}
15571638

1558-
async function resolveTranscriptReadAccess(scope: SessionTranscriptReadScope): Promise<{
1639+
async function resolveTranscriptReadAccess(scope: SessionTranscriptAccessScope): Promise<{
15591640
sessionFile: string;
15601641
}> {
15611642
if (scope.sessionFile?.trim()) {

0 commit comments

Comments
 (0)