diff --git a/src/auto-reply/reply/session-transcript-replay.test.ts b/src/auto-reply/reply/session-transcript-replay.test.ts index aede8e95541..76d83a62a89 100644 --- a/src/auto-reply/reply/session-transcript-replay.test.ts +++ b/src/auto-reply/reply/session-transcript-replay.test.ts @@ -77,4 +77,39 @@ describe("replayRecentUserAssistantMessages", () => { expect(records[0]).toMatchObject({ id: "existing" }); expect(records[1].message.role).toBe("user"); }); + + it("coalesces same-role runs so replayed records strictly alternate", async () => { + const source = path.join(root, "prev.jsonl"); + const target = path.join(root, "next.jsonl"); + await fs.writeFile( + source, + [ + j({ message: { role: "user", content: "older user" } }), + j({ message: { role: "user", content: "latest user" } }), + j({ message: { role: "assistant", content: "older assistant" } }), + j({ message: { role: "assistant", content: "latest assistant" } }), + j({ message: { role: "user", content: "follow-up" } }), + j({ message: { role: "assistant", content: "answer" } }), + ].join(""), + "utf8", + ); + + expect(await call(source, target)).toBe(4); + const records = (await fs.readFile(target, "utf8")) + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + expect(records.slice(1).map((r) => r.message.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + expect(records.slice(1).map((r) => r.message.content)).toEqual([ + "latest user", + "latest assistant", + "follow-up", + "answer", + ]); + }); }); diff --git a/src/auto-reply/reply/session-transcript-replay.ts b/src/auto-reply/reply/session-transcript-replay.ts index dfc5661810b..b7afdbc9763 100644 --- a/src/auto-reply/reply/session-transcript-replay.ts +++ b/src/auto-reply/reply/session-transcript-replay.ts @@ -12,10 +12,10 @@ type KeptRecord = { role: "user" | "assistant"; line: string }; /** * Copy the tail of user/assistant JSONL records from a prior transcript into a * freshly-rotated one. Tool, system, and compaction records are skipped so - * replay cannot reshape tool/role ordering, and the tail is aligned to start - * with a user turn so role-ordering resets cannot immediately recur. Uses - * async I/O so long transcripts do not block the event loop. Returns 0 on - * any error. + * replay cannot reshape tool/role ordering, and the tail is aligned and + * coalesced into alternating user/assistant turns so role-ordering resets + * cannot immediately recur. Uses async I/O so long transcripts do not block + * the event loop. Returns 0 on any error. */ export async function replayRecentUserAssistantMessages(params: { sourceTranscript?: string; @@ -55,7 +55,7 @@ export async function replayRecentUserAssistantMessages(params: { // role-ordering hazard this reset path is recovering from. return 0; } - const tail = kept.slice(startIdx).map((entry) => entry.line); + const tail = coalesceAlternatingReplayTail(kept.slice(startIdx)).map((entry) => entry.line); if (!fs.existsSync(params.targetTranscript)) { await fsp.mkdir(path.dirname(params.targetTranscript), { recursive: true }); const header = JSON.stringify({ @@ -76,3 +76,18 @@ export async function replayRecentUserAssistantMessages(params: { return 0; } } + +// Keep the newest record from each same-role run, preserving original JSONL bytes +// for replay while ensuring strict provider alternation. +function coalesceAlternatingReplayTail(entries: KeptRecord[]): KeptRecord[] { + const tail: KeptRecord[] = []; + for (const entry of entries) { + const lastIdx = tail.length - 1; + if (lastIdx >= 0 && tail[lastIdx]?.role === entry.role) { + tail[lastIdx] = entry; + continue; + } + tail.push(entry); + } + return tail; +}