Skip to content

Commit 453f596

Browse files
authored
refactor(gateway): route chat transcript injection through the session accessor (#101688)
* refactor(gateway): route chat transcript injection through the session accessor * fix(gateway): avoid map-spread in source reply mirror rewrite results * perf(sessions): find injected transcript duplicates with a reverse early-exit scan
1 parent 70de9ff commit 453f596

9 files changed

Lines changed: 659 additions & 395 deletions

scripts/check-session-accessor-boundary.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
193193
"src/config/sessions/cleanup-service.ts",
194194
"src/config/sessions/goals.ts",
195195
"src/gateway/boot.ts",
196+
"src/gateway/server-methods/chat.ts",
196197
"src/gateway/server-methods/sessions.ts",
197198
"src/gateway/server-node-events.ts",
198199
"src/gateway/session-compaction-checkpoints.ts",

scripts/lib/session-accessor-debt-baseline.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"src/config/sessions/session-file.ts": 2,
5959
"src/config/sessions/session-registry-maintenance.ts": 2,
6060
"src/gateway/server-methods/agent.ts": 2,
61-
"src/gateway/server-methods/chat.ts": 2,
6261
"src/gateway/session-lifecycle-state.ts": 2,
6362
"src/gateway/test-helpers.mocks.ts": 1,
6463
"src/infra/heartbeat-runner.ts": 5,

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import {
1818
cleanupSessionLifecycleArtifacts,
1919
commitReplySessionInitialization,
2020
createSessionEntryWithTranscript,
21+
findTranscriptEvent,
2122
listSessionEntries,
2223
loadReplySessionInitializationSnapshot,
2324
loadSessionEntry,
25+
loadTranscriptEvents,
2426
markSessionAbortTarget,
2527
openSessionEntryReadView,
2628
patchSessionEntry,
@@ -101,6 +103,68 @@ describe("session accessor file-backed seam", () => {
101103
});
102104
});
103105

106+
it("loads parsed transcript events from explicit and store-derived targets", async () => {
107+
const header = { type: "session", id: "session-events", timestamp: 1 };
108+
const message = { type: "message", id: "m1", message: { role: "assistant" } };
109+
fs.writeFileSync(
110+
transcriptPath,
111+
`${JSON.stringify(header)}\n${JSON.stringify(message)}\nnot-json\n`,
112+
"utf-8",
113+
);
114+
await upsertSessionEntry(
115+
{ sessionKey: "agent:main:main", storePath },
116+
{ sessionId: "session-events", sessionFile: transcriptPath, updatedAt: 10 },
117+
);
118+
119+
const explicit = await loadTranscriptEvents({
120+
sessionFile: transcriptPath,
121+
sessionId: "session-events",
122+
});
123+
expect(explicit).toEqual([header, message]);
124+
125+
const derived = await loadTranscriptEvents({
126+
sessionId: "session-events",
127+
sessionKey: "agent:main:main",
128+
storePath,
129+
});
130+
expect(derived).toEqual([header, message]);
131+
132+
const missing = await loadTranscriptEvents({
133+
sessionFile: path.join(tempDir, "missing.jsonl"),
134+
sessionId: "session-events",
135+
});
136+
expect(missing).toEqual([]);
137+
});
138+
139+
it("finds the newest matching transcript event without loading the whole file", async () => {
140+
const header = { type: "session", id: "session-find", timestamp: 1 };
141+
const older = { type: "message", id: "m1", message: { role: "assistant", tag: "old" } };
142+
const newer = { type: "message", id: "m2", message: { role: "assistant", tag: "new" } };
143+
fs.writeFileSync(
144+
transcriptPath,
145+
`${JSON.stringify(header)}\n${JSON.stringify(older)}\n${JSON.stringify(newer)}\n`,
146+
"utf-8",
147+
);
148+
149+
const seen: unknown[] = [];
150+
const found = await findTranscriptEvent(
151+
{ sessionFile: transcriptPath, sessionId: "session-find" },
152+
(event) => {
153+
seen.push(event);
154+
return (event as { type?: string }).type === "message";
155+
},
156+
);
157+
// Newest-first with early exit: the older message is never visited.
158+
expect(found).toEqual({ event: newer });
159+
expect(seen).toEqual([newer]);
160+
161+
const missing = await findTranscriptEvent(
162+
{ sessionFile: path.join(tempDir, "missing.jsonl"), sessionId: "session-find" },
163+
() => true,
164+
);
165+
expect(missing).toBeUndefined();
166+
});
167+
104168
it("opens a borrowed read view with raw exact-key probes and deferred enumeration", async () => {
105169
const mixedKey = "agent:main:matrix:channel:!RoomAbC:example.org";
106170
await upsertSessionEntry(

src/config/sessions/session-accessor.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ import { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
9696
import { createSessionTranscriptHeader } from "./transcript-header.js";
9797
import { serializeJsonlLine, writeJsonlLines } from "./transcript-jsonl.js";
9898
import { replayRecentUserAssistantMessages } from "./transcript-replay.js";
99-
import { streamSessionTranscriptLines } from "./transcript-stream.js";
99+
import {
100+
streamSessionTranscriptLines,
101+
streamSessionTranscriptLinesReverse,
102+
} from "./transcript-stream.js";
100103
import {
101104
scanSessionTranscriptTree,
102105
selectSessionTranscriptTreePathNodes,
@@ -2089,6 +2092,48 @@ export async function appendTranscriptMessage<TMessage>(
20892092
});
20902093
}
20912094

2095+
/**
2096+
* Finds the newest transcript record accepted by the matcher. Reads newest-first
2097+
* with early exit so hot append-path lookups never materialize the whole
2098+
* transcript; missing transcripts match nothing. The match is wrapped so parsed
2099+
* falsy records stay distinguishable from "no match".
2100+
*/
2101+
export async function findTranscriptEvent(
2102+
scope: SessionTranscriptReadScope,
2103+
match: (event: TranscriptEvent) => boolean,
2104+
): Promise<{ event: TranscriptEvent } | undefined> {
2105+
const target = resolveSessionTranscriptReadTarget(scope);
2106+
for await (const line of streamSessionTranscriptLinesReverse(target.sessionFile)) {
2107+
try {
2108+
const event = JSON.parse(line) as TranscriptEvent;
2109+
if (match(event)) {
2110+
return { event };
2111+
}
2112+
} catch {
2113+
// Malformed lines are skipped, matching transcript index tolerance.
2114+
}
2115+
}
2116+
return undefined;
2117+
}
2118+
2119+
/** Reads parsed transcript records from an explicit or derived transcript target. */
2120+
export async function loadTranscriptEvents(
2121+
scope: SessionTranscriptReadScope,
2122+
): Promise<TranscriptEvent[]> {
2123+
const target = resolveSessionTranscriptReadTarget(scope);
2124+
const events: TranscriptEvent[] = [];
2125+
// Missing transcripts stream zero lines, so readers get an empty event list
2126+
// instead of a filesystem error; that keeps the read contract storage-neutral.
2127+
for await (const line of streamSessionTranscriptLines(target.sessionFile)) {
2128+
try {
2129+
events.push(JSON.parse(line) as TranscriptEvent);
2130+
} catch {
2131+
// Malformed lines are skipped, matching transcript index tolerance.
2132+
}
2133+
}
2134+
return events;
2135+
}
2136+
20922137
/** Emits a transcript update after resolving the current transcript target. */
20932138
export async function publishTranscriptUpdate(
20942139
scope: SessionTranscriptWriteScope,

src/gateway/server-methods/chat-transcript-inject.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// Chat transcript injection appends gateway-authored assistant rows while
22
// preserving agent-session parent links and transcript update notifications.
33
import type { SessionManager } from "../../agents/sessions/session-manager.js";
4-
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
4+
import {
5+
findTranscriptEvent,
6+
persistSessionTranscriptTurn,
7+
type TranscriptEvent,
8+
} from "../../config/sessions/session-accessor.js";
59
import type { OpenClawConfig } from "../../config/types.openclaw.js";
610
import { formatErrorMessage } from "../../infra/errors.js";
711

@@ -53,9 +57,71 @@ function resolveInjectedAssistantContent(params: {
5357
return [{ type: "text", text: `${labelPrefix}${params.message}` }];
5458
}
5559

60+
function transcriptEventRecord(event: TranscriptEvent): Record<string, unknown> | undefined {
61+
return event && typeof event === "object" && !Array.isArray(event)
62+
? (event as Record<string, unknown>)
63+
: undefined;
64+
}
65+
66+
function transcriptEventMessage(event: TranscriptEvent): Record<string, unknown> | undefined {
67+
const message = transcriptEventRecord(event)?.message;
68+
return message && typeof message === "object" && !Array.isArray(message)
69+
? (message as Record<string, unknown>)
70+
: undefined;
71+
}
72+
73+
function transcriptEventId(event: TranscriptEvent): string | undefined {
74+
const id = transcriptEventRecord(event)?.id;
75+
return typeof id === "string" && id.trim().length > 0 ? id : undefined;
76+
}
77+
78+
type InjectedAssistantIdempotencyTarget = {
79+
agentId?: string;
80+
sessionFile?: string;
81+
sessionId?: string;
82+
sessionKey?: string;
83+
storePath?: string;
84+
};
85+
86+
async function findInjectedAssistantMessageByIdempotencyKey(params: {
87+
idempotencyKey: string;
88+
target: InjectedAssistantIdempotencyTarget;
89+
}): Promise<{ messageId: string; message: Record<string, unknown> } | undefined> {
90+
if (!params.target.sessionId || !params.target.sessionKey) {
91+
return undefined;
92+
}
93+
// The in-lock duplicate check resolves through the already-resolved
94+
// sessionFile when present so the lookup reads the file being appended to.
95+
// findTranscriptEvent scans newest-first with early exit, keeping the hot
96+
// idempotent append path from materializing the whole transcript.
97+
const found = await findTranscriptEvent(
98+
{
99+
...(params.target.agentId ? { agentId: params.target.agentId } : {}),
100+
...(params.target.sessionFile ? { sessionFile: params.target.sessionFile } : {}),
101+
sessionId: params.target.sessionId,
102+
sessionKey: params.target.sessionKey,
103+
...(params.target.storePath ? { storePath: params.target.storePath } : {}),
104+
},
105+
(candidate) => {
106+
const message = transcriptEventMessage(candidate);
107+
return message?.role === "assistant" && message.idempotencyKey === params.idempotencyKey;
108+
},
109+
);
110+
const message = found ? transcriptEventMessage(found.event) : undefined;
111+
if (!message) {
112+
return undefined;
113+
}
114+
// Legacy shipped transcripts can carry assistant rows without top-level ids;
115+
// fall back to the idempotency key so re-issued aborts still dedupe there.
116+
const messageId = (found ? transcriptEventId(found.event) : undefined) ?? params.idempotencyKey;
117+
return { messageId, message };
118+
}
119+
56120
/** Append a gateway-authored assistant message while preserving transcript parent links. */
57121
export async function appendInjectedAssistantMessageToTranscript(params: {
58-
transcriptPath: string;
122+
transcriptPath?: string;
123+
storePath?: string;
124+
sessionId?: string;
59125
sessionKey?: string;
60126
agentId?: string;
61127
message: string;
@@ -118,26 +184,60 @@ export async function appendInjectedAssistantMessageToTranscript(params: {
118184
};
119185

120186
try {
187+
if (!params.transcriptPath && (!params.storePath || !params.sessionId || !params.sessionKey)) {
188+
return { ok: false, error: "transcript identity not resolved" };
189+
}
190+
const assistantScopedIdempotency =
191+
params.idempotencyKey && params.storePath && params.sessionId && params.sessionKey
192+
? {
193+
idempotencyKey: params.idempotencyKey,
194+
target: {
195+
...(params.agentId ? { agentId: params.agentId } : {}),
196+
sessionId: params.sessionId,
197+
sessionKey: params.sessionKey,
198+
storePath: params.storePath,
199+
},
200+
}
201+
: undefined;
121202
const turn = await persistSessionTranscriptTurn(
122203
{
123-
sessionFile: params.transcriptPath,
124204
sessionKey: params.sessionKey ?? "",
205+
...(params.transcriptPath ? { sessionFile: params.transcriptPath } : {}),
206+
...(params.storePath ? { storePath: params.storePath } : {}),
207+
...(params.sessionId ? { sessionId: params.sessionId } : {}),
125208
...(params.agentId ? { agentId: params.agentId } : {}),
126209
},
127210
{
128211
updateMode: "inline",
212+
touchSessionEntry: Boolean(params.storePath && params.sessionId && params.sessionKey),
129213
...(params.config ? { config: params.config } : {}),
130214
messages: [
131215
{
132216
message: messageBody,
217+
idempotencyLookup: assistantScopedIdempotency ? "caller-checked" : "scan",
133218
now,
134219
useRawWhenLinear: true,
220+
shouldAppend: assistantScopedIdempotency
221+
? async (target) =>
222+
!(await findInjectedAssistantMessageByIdempotencyKey({
223+
idempotencyKey: assistantScopedIdempotency.idempotencyKey,
224+
target,
225+
}))
226+
: undefined,
135227
},
136228
],
137229
},
138230
);
139231
const appended = turn.messages[0];
140232
if (!appended) {
233+
if (assistantScopedIdempotency) {
234+
const existing = await findInjectedAssistantMessageByIdempotencyKey(
235+
assistantScopedIdempotency,
236+
);
237+
if (existing) {
238+
return { ok: true, messageId: existing.messageId, message: existing.message };
239+
}
240+
}
141241
return { ok: false, error: "gateway-injected assistant message was not appended" };
142242
}
143243
return {

src/gateway/server-methods/chat.abort-persistence.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66
import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions";
77
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
88
import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js";
9+
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
910
import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
1011
import {
1112
createActiveRun,
@@ -171,6 +172,12 @@ async function createTranscriptFixture(prefix: string) {
171172
const sessionId = "sess-main";
172173
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
173174
await writeTranscriptHeader(transcriptPath, sessionId);
175+
// The accessor resolves transcript targets from the persisted store, so the
176+
// fixture seeds a real entry instead of relying on the mocked gateway wrapper.
177+
await replaceSessionEntry(
178+
{ agentId: "main", sessionKey: "main", storePath: path.join(dir, "sessions.json") },
179+
{ sessionId, sessionFile: transcriptPath, updatedAt: Date.now() },
180+
);
174181
setMockSessionEntry(transcriptPath, sessionId);
175182
return { transcriptPath, sessionId };
176183
}

0 commit comments

Comments
 (0)