Skip to content

Commit 2e18181

Browse files
committed
fix(agents): dedupe transcript rewrite suffix replay
Context-overflow recovery re-runs transcript suffix replay (rewriteTranscriptEntriesInSessionManager / rewriteTranscriptEntriesInState) without a replay-identity guard, so byte-identical role=user entries are re-appended on every recovery pass. The persisted session JSONL then accumulates duplicate messages, amplifying transcript growth and cascading into further overflows. Collapse only the proven overflow-clone pattern during replay: byte-identical role=user messages (whose timestamp distinguishes a recovery clone from a legitimate repeat) and byte-identical compactions (identity folds in the remapped firstKeptEntryId so distinct kept boundaries survive). Exact suffix history is preserved for assistant, tool-result, and custom entries, whose identical payloads can be legitimate history. Closes #66443
1 parent f37ce4e commit 2e18181

2 files changed

Lines changed: 271 additions & 17 deletions

File tree

src/agents/embedded-agent-runner/transcript-rewrite.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,179 @@ describe("rewriteTranscriptEntriesInSessionManager", () => {
295295
}
296296
expect(replayedAssistant.content).toEqual([{ type: "text", text: "summarized" }]);
297297
});
298+
299+
it("dedupes byte-identical replayed messages so suffix replay does not clone user entries", () => {
300+
const sessionManager = SessionManager.inMemory();
301+
const entryIds = appendSessionMessages(sessionManager, [
302+
asAppendMessage({ role: "user", content: "anchor", timestamp: 1 }),
303+
asAppendMessage({
304+
role: "toolResult",
305+
toolCallId: "call_1",
306+
toolName: "read",
307+
content: createTextContent("x".repeat(8_000)),
308+
isError: false,
309+
timestamp: 2,
310+
}),
311+
asAppendMessage({ role: "user", content: "duplicate user message", timestamp: 3 }),
312+
asAppendMessage({ role: "user", content: "duplicate user message", timestamp: 3 }),
313+
asAppendMessage({ role: "assistant", content: createTextContent("tail"), timestamp: 4 }),
314+
]);
315+
316+
const result = rewriteTranscriptEntriesInSessionManager({
317+
sessionManager,
318+
replacements: [
319+
{
320+
entryId: entryIds[1],
321+
message: createToolResultReplacement("read", "[externalized file_123]", 2),
322+
},
323+
],
324+
});
325+
326+
expect(result.changed).toBe(true);
327+
const duplicateCount = getBranchMessages(sessionManager).filter(
328+
(message) => message.role === "user" && message.content === "duplicate user message",
329+
).length;
330+
expect(duplicateCount).toBe(1);
331+
});
332+
333+
it("keeps distinct compactions with identical summaries but different kept boundaries", () => {
334+
const sessionManager = SessionManager.inMemory();
335+
const entryIds = appendSessionMessages(sessionManager, [
336+
asAppendMessage({
337+
role: "toolResult",
338+
toolCallId: "call_1",
339+
toolName: "read",
340+
content: createTextContent("x".repeat(8_000)),
341+
isError: false,
342+
timestamp: 1,
343+
}),
344+
asAppendMessage({ role: "assistant", content: createTextContent("kept one"), timestamp: 2 }),
345+
asAppendMessage({ role: "assistant", content: createTextContent("kept two"), timestamp: 3 }),
346+
]);
347+
const keptOneId = entryIds[1];
348+
const keptTwoId = entryIds[2];
349+
// Two compactions sharing summary/tokens/details/fromHook but pointing at
350+
// different kept boundaries must not collapse during replay.
351+
sessionManager.appendCompaction("summary", keptOneId, 100);
352+
sessionManager.appendCompaction("summary", keptTwoId, 100);
353+
354+
const result = rewriteTranscriptEntriesInSessionManager({
355+
sessionManager,
356+
replacements: [
357+
{
358+
entryId: entryIds[0],
359+
message: createToolResultReplacement("read", "[externalized file_123]", 1),
360+
},
361+
],
362+
});
363+
364+
expect(result.changed).toBe(true);
365+
const compactions = sessionManager
366+
.getBranch()
367+
.filter((entry) => entry.type === "compaction");
368+
expect(compactions).toHaveLength(2);
369+
const keptBoundaries = new Set(
370+
compactions.map((entry) => (entry.type === "compaction" ? entry.firstKeptEntryId : null)),
371+
);
372+
expect(keptBoundaries.size).toBe(2);
373+
});
374+
375+
it("preserves legitimate repeated user messages that differ only by timestamp", () => {
376+
const sessionManager = SessionManager.inMemory();
377+
const entryIds = appendSessionMessages(sessionManager, [
378+
asAppendMessage({ role: "user", content: "anchor", timestamp: 1 }),
379+
asAppendMessage({
380+
role: "toolResult",
381+
toolCallId: "call_1",
382+
toolName: "read",
383+
content: createTextContent("x".repeat(8_000)),
384+
isError: false,
385+
timestamp: 2,
386+
}),
387+
asAppendMessage({ role: "user", content: "same question", timestamp: 3 }),
388+
asAppendMessage({ role: "user", content: "same question", timestamp: 4 }),
389+
asAppendMessage({ role: "assistant", content: createTextContent("tail"), timestamp: 5 }),
390+
]);
391+
392+
const result = rewriteTranscriptEntriesInSessionManager({
393+
sessionManager,
394+
replacements: [
395+
{
396+
entryId: entryIds[1],
397+
message: createToolResultReplacement("read", "[externalized file_123]", 2),
398+
},
399+
],
400+
});
401+
402+
expect(result.changed).toBe(true);
403+
// Two genuine repeats (distinct timestamps) must both survive — only the
404+
// byte-identical recovery clone is collapsed.
405+
const repeated = getBranchMessages(sessionManager).filter(
406+
(message) => message.role === "user" && message.content === "same question",
407+
);
408+
expect(repeated).toHaveLength(2);
409+
});
410+
411+
it("preserves byte-identical repeated assistant and tool-result entries during replay", () => {
412+
const sessionManager = SessionManager.inMemory();
413+
const entryIds = appendSessionMessages(sessionManager, [
414+
asAppendMessage({ role: "user", content: "anchor", timestamp: 1 }),
415+
asAppendMessage({
416+
role: "toolResult",
417+
toolCallId: "call_1",
418+
toolName: "read",
419+
content: createTextContent("x".repeat(8_000)),
420+
isError: false,
421+
timestamp: 2,
422+
}),
423+
asAppendMessage({ role: "assistant", content: createTextContent("repeat"), timestamp: 3 }),
424+
asAppendMessage({ role: "assistant", content: createTextContent("repeat"), timestamp: 3 }),
425+
asAppendMessage({
426+
role: "toolResult",
427+
toolCallId: "call_2",
428+
toolName: "exec",
429+
content: createTextContent("same output"),
430+
isError: false,
431+
timestamp: 4,
432+
}),
433+
asAppendMessage({
434+
role: "toolResult",
435+
toolCallId: "call_2",
436+
toolName: "exec",
437+
content: createTextContent("same output"),
438+
isError: false,
439+
timestamp: 4,
440+
}),
441+
]);
442+
443+
const result = rewriteTranscriptEntriesInSessionManager({
444+
sessionManager,
445+
replacements: [
446+
{
447+
entryId: entryIds[1],
448+
message: createToolResultReplacement("read", "[externalized file_123]", 2),
449+
},
450+
],
451+
});
452+
453+
expect(result.changed).toBe(true);
454+
const branchMessages = getBranchMessages(sessionManager);
455+
const assistantRepeats = branchMessages.filter(
456+
(message) =>
457+
message.role === "assistant" &&
458+
Array.isArray(message.content) &&
459+
message.content.some((part) => part.type === "text" && part.text === "repeat"),
460+
);
461+
const toolRepeats = branchMessages.filter(
462+
(message) =>
463+
message.role === "toolResult" &&
464+
Array.isArray(message.content) &&
465+
message.content.some((part) => part.type === "text" && part.text === "same output"),
466+
);
467+
// Identical assistant/tool payloads can be legitimate history; never deduped.
468+
expect(assistantRepeats).toHaveLength(2);
469+
expect(toolRepeats).toHaveLength(2);
470+
});
298471
});
299472

300473
describe("rewriteTranscriptEntriesInSessionFile", () => {

src/agents/embedded-agent-runner/transcript-rewrite.ts

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import type {
23
TranscriptRewriteReplacement,
34
TranscriptRewriteRequest,
@@ -37,6 +38,45 @@ function remapEntryId(
3738
return rewrittenEntryIds.get(entryId) ?? entryId;
3839
}
3940

41+
function hashJson(value: unknown): string {
42+
return createHash("sha256").update(JSON.stringify(value ?? null)).digest("hex");
43+
}
44+
45+
// Replay identity that collapses ONLY the proven context-overflow clone pattern
46+
// during suffix replay, preserving exact suffix history for everything else.
47+
// Recovery duplicated byte-identical role=user rows in the persisted transcript
48+
// (issue #66443). A legitimately repeated user message carries a distinct
49+
// timestamp, so hashing the full payload keeps it while collapsing the identical
50+
// recovery clone. Compaction identity folds in the remapped firstKeptEntryId so
51+
// two genuinely distinct compactions (different kept boundary) are not merged —
52+
// buildSessionContext() uses that boundary to pick surviving pre-compaction
53+
// messages. Assistant, tool-result, and custom entries are never deduped:
54+
// identical payloads there can be legitimate history, so they always replay.
55+
function computeBranchEntryReplayIdentity(
56+
entry: SessionBranchEntry,
57+
rewrittenEntryIds: ReadonlyMap<string, string>,
58+
): string | null {
59+
if (entry.type === "message") {
60+
return computeMessageReplayIdentity(entry.message);
61+
}
62+
if (entry.type === "compaction") {
63+
const keptId = remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId;
64+
return `compaction:${entry.tokensBefore}:${hashJson({
65+
keptId,
66+
summary: entry.summary,
67+
details: entry.details,
68+
fromHook: entry.fromHook,
69+
})}`;
70+
}
71+
return null;
72+
}
73+
74+
// Only role=user messages are clone-deduped; the message timestamp separates a
75+
// recovery clone (byte-identical) from a legitimate repeat (distinct timestamp).
76+
function computeMessageReplayIdentity(message: AgentMessage): string | null {
77+
return message.role === "user" ? `user:${hashJson(message)}` : null;
78+
}
79+
4080
function appendBranchEntry(params: {
4181
sessionManager: SessionManagerLike;
4282
entry: SessionBranchEntry;
@@ -227,19 +267,41 @@ export function rewriteTranscriptEntriesInSessionManager(params: {
227267
// re-running persistence hooks or size truncation on replayed messages.
228268
const appendMessage = getRawSessionAppendMessage(params.sessionManager);
229269
const rewrittenEntryIds = new Map<string, string>();
270+
const emittedReplayIdentities = new Map<string, string>();
230271
for (let index = matchedIndices[0]; index < branch.length; index++) {
231272
const entry = branch[index];
232273
const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined;
233-
const newEntryId =
234-
replacement === undefined
235-
? appendBranchEntry({
236-
sessionManager: params.sessionManager,
237-
entry,
238-
rewrittenEntryIds,
239-
appendMessage,
240-
})
241-
: appendMessage(replacement as Parameters<typeof params.sessionManager.appendMessage>[0]);
274+
if (replacement === undefined) {
275+
const replayIdentity = computeBranchEntryReplayIdentity(entry, rewrittenEntryIds);
276+
const existingEntryId =
277+
replayIdentity === null ? undefined : emittedReplayIdentities.get(replayIdentity);
278+
if (existingEntryId !== undefined) {
279+
// Identical entry already replayed in this pass; collapse the clone and
280+
// point its id at the survivor so repeated overflow recovery cannot keep
281+
// re-appending duplicate suffix entries.
282+
rewrittenEntryIds.set(entry.id, existingEntryId);
283+
continue;
284+
}
285+
const newEntryId = appendBranchEntry({
286+
sessionManager: params.sessionManager,
287+
entry,
288+
rewrittenEntryIds,
289+
appendMessage,
290+
});
291+
rewrittenEntryIds.set(entry.id, newEntryId);
292+
if (replayIdentity !== null) {
293+
emittedReplayIdentities.set(replayIdentity, newEntryId);
294+
}
295+
continue;
296+
}
297+
const newEntryId = appendMessage(
298+
replacement as Parameters<typeof params.sessionManager.appendMessage>[0],
299+
);
242300
rewrittenEntryIds.set(entry.id, newEntryId);
301+
const replacementReplayIdentity = computeMessageReplayIdentity(replacement);
302+
if (replacementReplayIdentity !== null) {
303+
emittedReplayIdentities.set(replacementReplayIdentity, newEntryId);
304+
}
243305
}
244306

245307
return {
@@ -345,19 +407,38 @@ export function rewriteTranscriptEntriesInState(params: {
345407

346408
const appendedEntries: SessionBranchEntry[] = [];
347409
const rewrittenEntryIds = new Map<string, string>();
410+
const emittedReplayIdentities = new Map<string, string>();
348411
for (let index = matchedIndices[0]; index < branch.length; index++) {
349412
const entry = branch[index];
350413
const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined;
351-
const newEntry =
352-
replacement === undefined
353-
? appendTranscriptStateBranchEntry({
354-
state: params.state,
355-
entry,
356-
rewrittenEntryIds,
357-
})
358-
: params.state.appendMessage(replacement);
414+
if (replacement === undefined) {
415+
const replayIdentity = computeBranchEntryReplayIdentity(entry, rewrittenEntryIds);
416+
const existingEntryId =
417+
replayIdentity === null ? undefined : emittedReplayIdentities.get(replayIdentity);
418+
if (existingEntryId !== undefined) {
419+
// Collapse the replay clone (see rewriteTranscriptEntriesInSessionManager).
420+
rewrittenEntryIds.set(entry.id, existingEntryId);
421+
continue;
422+
}
423+
const newEntry = appendTranscriptStateBranchEntry({
424+
state: params.state,
425+
entry,
426+
rewrittenEntryIds,
427+
});
428+
rewrittenEntryIds.set(entry.id, newEntry.id);
429+
appendedEntries.push(newEntry);
430+
if (replayIdentity !== null) {
431+
emittedReplayIdentities.set(replayIdentity, newEntry.id);
432+
}
433+
continue;
434+
}
435+
const newEntry = params.state.appendMessage(replacement);
359436
rewrittenEntryIds.set(entry.id, newEntry.id);
360437
appendedEntries.push(newEntry);
438+
const replacementReplayIdentity = computeMessageReplayIdentity(replacement);
439+
if (replacementReplayIdentity !== null) {
440+
emittedReplayIdentities.set(replacementReplayIdentity, newEntry.id);
441+
}
361442
}
362443

363444
return {

0 commit comments

Comments
 (0)