Skip to content

Commit 550f5be

Browse files
Merge pull request #563 from corbitsdev/cl-5935-display-hydrate-skips-non-null-malformed-turns-lines
Skip malformed turns lines in display-only resume hydrate (CL-5935)
2 parents 084d6f3 + 07a4f57 commit 550f5be

2 files changed

Lines changed: 75 additions & 2 deletions

File tree

src/session/optimized-context-store.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,60 @@ describe("loadRecentTurns", () => {
344344
const loaded = await loadRecentTurns(dir, 5);
345345
expect(loaded.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b"]);
346346
});
347+
348+
test("skips a non-tail malformed line in the newest segment", async () => {
349+
const dir = tempDir();
350+
fs.writeFileSync(path.join(dir, TURNS_FILE), jsonl([turn("a")]));
351+
fs.writeFileSync(
352+
path.join(dir, segmentFileName(TURNS_FILE, 1)),
353+
jsonl([turn("b")]) + '{"role":"user","content":[{"type":"te\n' + jsonl([turn("c")]),
354+
);
355+
356+
const loaded = await loadRecentTurns(dir, 5);
357+
expect(loaded.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b", "c"]);
358+
});
359+
360+
test("skips a malformed line in an older sealed segment", async () => {
361+
const dir = tempDir();
362+
fs.writeFileSync(path.join(dir, TURNS_FILE), jsonl([turn("a")]));
363+
fs.writeFileSync(
364+
path.join(dir, segmentFileName(TURNS_FILE, 1)),
365+
jsonl([turn("b")]) + '{"role":"user","content":[{"type":"te\n' + jsonl([turn("c")]),
366+
);
367+
fs.writeFileSync(path.join(dir, segmentFileName(TURNS_FILE, 2)), jsonl([turn("d")]));
368+
369+
const loaded = await loadRecentTurns(dir, 5);
370+
expect(loaded.map((t) => (t.content[0] as { text: string }).text)).toEqual([
371+
"a",
372+
"b",
373+
"c",
374+
"d",
375+
]);
376+
});
377+
378+
test("skips a line that parses as JSON but fails the turn schema", async () => {
379+
const dir = tempDir();
380+
fs.writeFileSync(path.join(dir, TURNS_FILE), jsonl([turn("a")]));
381+
const badTurn = JSON.stringify({ role: "user", content: "not-an-array", timestamp: 1 });
382+
fs.writeFileSync(
383+
path.join(dir, segmentFileName(TURNS_FILE, 1)),
384+
jsonl([turn("b")]) + badTurn + "\n" + jsonl([turn("c")]),
385+
);
386+
387+
const loaded = await loadRecentTurns(dir, 5);
388+
expect(loaded.map((t) => (t.content[0] as { text: string }).text)).toEqual(["a", "b", "c"]);
389+
});
390+
391+
test("the reactor's load() stays strict on the same corrupt fixture and names the segment", async () => {
392+
const dir = tempDir();
393+
const store = await createOptimizedContextStore(dir);
394+
fs.writeFileSync(
395+
path.join(dir, TURNS_FILE),
396+
jsonl([turn("a")]) + '{"role":"user","content":[{"type":"te\n' + jsonl([turn("b")]),
397+
);
398+
399+
await expect(store.load()).rejects.toThrow(TURNS_FILE);
400+
});
347401
});
348402

349403
describe("createOptimizedContextStore checkpoint", () => {

src/session/optimized-context-store.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,18 @@ function sanitizeCallId(callId: string): string {
7979
* so a poisoned segment can still yield its usable turns on resume. Errors name
8080
* `fileName` when provided so diagnostics point at the on-disk file, not a bare
8181
* Bun JSON token.
82+
*
83+
* `skipMalformed` is for display-only reads (see loadRecentTurns): a bad line
84+
* anywhere in any segment drops that line and keeps the surrounding history,
85+
* because a blank transcript is a worse answer than a transcript with a hole in
86+
* it. The reactor's own load() must never use it — there, history *is* the live
87+
* conversation state and silently dropping a turn would corrupt it (CL-5935).
8288
*/
8389
function parseSegmentTurns(
8490
text: string,
8591
tolerateTornTail: boolean,
8692
fileName = "turns segment",
93+
skipMalformed = false,
8794
): ConversationTurn[] {
8895
if (text.length === 0) return [];
8996
// POSIX truncate past EOF pads with `\0`. Strip them so the rest of the JSONL
@@ -103,10 +110,18 @@ function parseSegmentTurns(
103110
raw = JSON.parse(line);
104111
} catch (cause) {
105112
if (tolerateTornTail && isLast) break;
113+
if (skipMalformed) {
114+
log.warn?.(`skipping malformed JSON at ${fileName} line ${i + 1}`);
115+
continue;
116+
}
106117
throw new Error(`${fileName} has malformed JSON at line ${i + 1}`, { cause });
107118
}
108119
const result = ConversationTurnSchema(raw);
109120
if (result instanceof type.errors) {
121+
if (skipMalformed) {
122+
log.warn?.(`skipping unexpected structure at ${fileName} line ${i + 1}`);
123+
continue;
124+
}
110125
throw new Error(`${fileName} has unexpected structure at line ${i + 1}: ${result.summary}`);
111126
}
112127
turns.push(result);
@@ -233,9 +248,13 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise<Co
233248
const collectedNewestFirst: ConversationTurn[][] = [];
234249
let total = 0;
235250
for (let i = segments.length - 1; i >= 0; i--) {
236-
const text = await fs.promises.readFile(path.join(dir, segments[i]!), "utf-8");
251+
const name = segments[i]!;
252+
const text = await fs.promises.readFile(path.join(dir, name), "utf-8");
237253
// Only the active (last) segment can be mid-write; sealed ones are complete.
238-
const turns = parseSegmentTurns(text, i === segments.length - 1);
254+
// Display-only: skip lines that will not parse rather than losing the whole
255+
// transcript to one bad line, and name the segment in any error that does
256+
// escape (CL-5935).
257+
const turns = parseSegmentTurns(text, i === segments.length - 1, name, true);
239258
collectedNewestFirst.push(turns);
240259
total += turns.length;
241260
if (total >= minTurns) break;

0 commit comments

Comments
 (0)