fix(tui): kept partial JSONL tail after full transcript rebuild

When a full rebuild caught the session file mid-append, #loadLocalFull cleared the pending buffer but advanced offset past the headless trailing bytes. The next poll then read only the completion bytes, parsed a headless fragment, and silently dropped the completed entry. Carry the bytes after the last newline as pending so the completion concatenates with them on the next #appendLocal pass.

Fixes #3258
This commit is contained in:
roboomp
2026-06-22 12:20:07 +00:00
parent e65b8e4cc7
commit 6bc0e56c20
2 changed files with 49 additions and 2 deletions
@@ -256,6 +256,14 @@ export class AgentTranscriptViewer implements Component {
} catch {
post = stat;
}
// A reader that opens the file mid-append sees a trailing partial line
// (no terminating newline). Carry those bytes as `pending` so the next
// poll's `#appendLocal` joins them with the completion bytes instead of
// parsing a headless line fragment and dropping the entry.
const text = data.toString("utf-8");
const lastNewline = text.lastIndexOf("\n");
const complete = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
const pending = lastNewline >= 0 ? text.slice(lastNewline + 1) : text;
this.#localUnavailable = "";
this.#localState = {
path: sessionFile,
@@ -264,11 +272,11 @@ export class AgentTranscriptViewer implements Component {
size: data.byteLength,
mtimeMs: post.mtimeMs,
offset: data.byteLength,
pending: "",
pending,
sentinels: sentinelsFromBuffer(data),
};
this.#model = undefined;
this.#rebuild(this.#extractMessages(parseSessionEntries(data.toString("utf-8"))));
this.#rebuild(this.#extractMessages(parseSessionEntries(complete)));
}
#appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
@@ -306,6 +306,45 @@ describe("AgentTranscriptViewer", () => {
}
});
it("preserves a partial trailing line through the full rebuild so the completion lands on the next poll", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-"));
const file = path.join(dir, "__advisor.jsonl");
const header = `${JSON.stringify({
type: "session",
version: CURRENT_SESSION_VERSION,
id: "adv",
timestamp: TS,
cwd: "/tmp",
})}\n`;
const completeLine = `${messageLine("a0", "FIRSTMARK")}\n`;
const partialLine = messageLine("a1", "PARTIALMARK");
fs.writeFileSync(file, header + completeLine + partialLine);
const viewer = makeViewer(file);
try {
const body = () =>
viewer
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
// First entry renders; the headless trailing line stays buffered.
expect(body()).toContain("FIRSTMARK");
expect(body()).not.toContain("PARTIALMARK");
// Completing the dangling line via a single newline must surface the
// buffered entry; it must NOT be dropped as a malformed fragment.
fs.appendFileSync(file, "\n");
const deadline = Date.now() + 5000;
while (!body().includes("PARTIALMARK") && Date.now() < deadline) {
await Bun.sleep(50);
}
expect(body()).toContain("PARTIALMARK");
} finally {
viewer.dispose();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("drops stale rendered rows when the host transcript rotates", async () => {
const header = `${JSON.stringify({
type: "session",