Skip to content

Commit 3030701

Browse files
committed
fix(sessions): preserve recovered corrupt transcripts
1 parent 20ae0d3 commit 3030701

4 files changed

Lines changed: 98 additions & 12 deletions

File tree

src/agents/embedded-agent-runner/session-manager-init.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it } from "vitest";
5+
import { SessionManager } from "../sessions/session-manager.js";
56
import { prepareSessionManagerForRun } from "./session-manager-init.js";
67

78
const tempPaths: string[] = [];
@@ -205,4 +206,60 @@ describe("prepareSessionManagerForRun", () => {
205206
]);
206207
expect(sessionManager.flushed).toBe(true);
207208
});
209+
210+
it("keeps recovered user-only transcripts through open and run preparation", async () => {
211+
const sessionFile = await makeTempFile();
212+
const userEntry = {
213+
type: "message",
214+
id: "user-1",
215+
parentId: null,
216+
timestamp: "2026-05-27T00:00:01.000Z",
217+
message: { role: "user", content: "persisted prompt" },
218+
};
219+
await fs.writeFile(
220+
sessionFile,
221+
['{"type":"session","id":"broken"', JSON.stringify(userEntry)].join("\n") + "\n",
222+
"utf-8",
223+
);
224+
225+
const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd");
226+
227+
await prepareSessionManagerForRun({
228+
sessionManager,
229+
sessionFile,
230+
hadSessionFile: true,
231+
sessionId: "new-session",
232+
cwd: "/tmp/task-repo",
233+
});
234+
sessionManager.appendMessage({
235+
role: "assistant",
236+
content: [{ type: "text", text: "response" }],
237+
api: "messages",
238+
provider: "anthropic",
239+
model: "sonnet-4.6",
240+
usage: {
241+
input: 0,
242+
output: 0,
243+
cacheRead: 0,
244+
cacheWrite: 0,
245+
totalTokens: 0,
246+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
247+
},
248+
stopReason: "stop",
249+
timestamp: Date.now(),
250+
});
251+
252+
const entries = (await fs.readFile(sessionFile, "utf-8"))
253+
.trim()
254+
.split("\n")
255+
.map(
256+
(line) => JSON.parse(line) as { type: string; id?: string; message?: { role?: string } },
257+
);
258+
expect(entries.map((entry) => entry.type)).toEqual(["session", "message", "message"]);
259+
expect(entries[0]).toEqual(
260+
expect.objectContaining({ type: "session", id: "new-session", cwd: "/tmp/task-repo" }),
261+
);
262+
expect(entries[1]).toEqual(userEntry);
263+
expect(entries[2]?.message?.role).toBe("assistant");
264+
});
208265
});

src/agents/embedded-agent-runner/session-manager-init.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export async function prepareSessionManagerForRun(params: {
5353
byId?: Map<string, unknown>;
5454
labelsById?: Map<string, unknown>;
5555
leafId?: string | null;
56+
wasRecoveredFromCorruptHeader?: () => boolean;
5657
};
5758

5859
const header = sm.fileEntries.find((e): e is SessionHeaderEntry => e.type === "session");
@@ -69,6 +70,18 @@ export async function prepareSessionManagerForRun(params: {
6970
}
7071

7172
if (params.hadSessionFile && header && !hasAssistant) {
73+
if (sm.wasRecoveredFromCorruptHeader?.()) {
74+
header.id = params.sessionId;
75+
header.cwd = params.cwd;
76+
sm.sessionId = params.sessionId;
77+
sm.cwd = params.cwd;
78+
await writeJsonlLines(params.sessionFile, sm.fileEntries.map(serializeJsonlLine), {
79+
mode: 0o600,
80+
});
81+
sm.flushed = true;
82+
return;
83+
}
84+
7285
// Reset file so the first assistant flush includes header+user+assistant in order.
7386
await assertExistingHeaderIsReadable(params.sessionFile);
7487
await fs.writeFile(params.sessionFile, "", "utf-8");

src/agents/sessions/session-manager.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,24 @@ describe("SessionManager.open", () => {
7878
await fs.writeFile(sessionFile, '{"type":"session","version":3,"id":"sess', "utf8");
7979

8080
const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/task-repo");
81-
sessionManager.appendMessage({ role: "user", content: "hello" });
82-
sessionManager.appendMessage({ role: "assistant", content: "hi" });
81+
sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() });
82+
sessionManager.appendMessage({
83+
role: "assistant",
84+
content: [{ type: "text", text: "hi" }],
85+
api: "messages",
86+
provider: "anthropic",
87+
model: "sonnet-4.6",
88+
usage: {
89+
input: 0,
90+
output: 0,
91+
cacheRead: 0,
92+
cacheWrite: 0,
93+
totalTokens: 0,
94+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
95+
},
96+
stopReason: "stop",
97+
timestamp: Date.now(),
98+
});
8399

84100
const entries = (await fs.readFile(sessionFile, "utf8"))
85101
.trim()

src/agents/sessions/session-manager.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
import { readdir, readFile, stat } from "node:fs/promises";
1515
import { join, resolve } from "node:path";
1616
import {
17-
appendJsonlEntriesSync,
1817
appendJsonlEntrySync,
1918
writeJsonlEntriesSync,
2019
} from "../../config/sessions/transcript-jsonl.js";
@@ -742,6 +741,7 @@ export class SessionManager {
742741
private labelsById: Map<string, string> = new Map();
743742
private labelTimestampsById: Map<string, string> = new Map();
744743
private leafId: string | null = null;
744+
private recoveredCorruptHeader = false;
745745

746746
private constructor(
747747
cwd: string,
@@ -766,6 +766,7 @@ export class SessionManager {
766766
/** Switch to a different session file (used for resume and branching) */
767767
setSessionFile(sessionFile: string): void {
768768
this.sessionFile = resolve(sessionFile);
769+
this.recoveredCorruptHeader = false;
769770
if (existsSync(this.sessionFile)) {
770771
this.fileEntries = loadEntriesFromFile(this.sessionFile);
771772

@@ -778,15 +779,9 @@ export class SessionManager {
778779
const header = this.fileEntries.find((e) => e.type === "session");
779780
this.sessionId = header?.id ?? createSessionId();
780781
this.buildIndex();
781-
const hasAssistant = this.fileEntries.some(
782-
(e) => e.type === "message" && e.message.role === "assistant",
783-
);
784-
if (hasAssistant) {
785-
this.rewriteFile();
786-
this.flushed = true;
787-
} else {
788-
this.flushed = false;
789-
}
782+
this.rewriteFile();
783+
this.recoveredCorruptHeader = true;
784+
this.flushed = true;
790785
return;
791786
}
792787

@@ -815,6 +810,7 @@ export class SessionManager {
815810
}
816811

817812
newSession(options?: NewSessionOptions): string | undefined {
813+
this.recoveredCorruptHeader = false;
818814
this.sessionId = options?.id ?? createSessionId();
819815
const timestamp = new Date().toISOString();
820816
const header: SessionHeader = {
@@ -884,6 +880,10 @@ export class SessionManager {
884880
return this.sessionId;
885881
}
886882

883+
wasRecoveredFromCorruptHeader(): boolean {
884+
return this.recoveredCorruptHeader;
885+
}
886+
887887
getSessionFile(): string | undefined {
888888
return this.sessionFile;
889889
}

0 commit comments

Comments
 (0)