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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user