From 2dce0a99b238b9dddee17e49b3eab29f99ddffe0 Mon Sep 17 00:00:00 2001 From: Stephen King Date: Wed, 28 Jan 2026 12:29:12 -0700 Subject: [PATCH 1/2] fix: add parentId to appendAssistantTranscriptMessage to preserve history chain Messages appended via /status (and other assistant transcript injections) were written with no parentId, breaking the tree structure that history reconstruction walks. This caused context to be truncated at these injection points. The fix reads the current leaf message ID before appending and includes it as parentId in the new entry, preserving the chain. --- src/gateway/server-methods/chat.ts | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9010a6f21..42ec8b1d8 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -82,6 +82,36 @@ function ensureTranscriptFile(params: { transcriptPath: string; sessionId: strin } } +/** + * Find the leaf (last message) ID from a transcript file. + * Returns null if file doesn't exist, is empty, or has no messages (header only). + */ +function findTranscriptLeafId(transcriptPath: string): string | null { + if (!fs.existsSync(transcriptPath)) return null; + + try { + const content = fs.readFileSync(transcriptPath, "utf-8"); + const lines = content.split(/\r?\n/).filter((line) => line.trim()); + if (lines.length === 0) return null; + + // Walk backwards to find the last message entry with an id + for (let i = lines.length - 1; i >= 0; i--) { + try { + const parsed = JSON.parse(lines[i]); + // Skip non-message entries (like session headers) + if (parsed?.type === "message" && typeof parsed?.id === "string") { + return parsed.id; + } + } catch { + // Skip malformed lines + } + } + return null; + } catch { + return null; + } +} + function appendAssistantTranscriptMessage(params: { message: string; label?: string; @@ -112,6 +142,9 @@ function appendAssistantTranscriptMessage(params: { } } + // Find the current leaf ID before we append + const parentId = findTranscriptLeafId(transcriptPath); + const now = Date.now(); const messageId = randomUUID().slice(0, 8); const labelPrefix = params.label ? `[${params.label}]\n\n` : ""; @@ -125,6 +158,7 @@ function appendAssistantTranscriptMessage(params: { const transcriptEntry = { type: "message", id: messageId, + parentId, timestamp: new Date(now).toISOString(), message: messageBody, }; From 9702782948d4c8b694bc68506739b2ad96489b77 Mon Sep 17 00:00:00 2001 From: Stephen King Date: Wed, 28 Jan 2026 16:31:35 -0700 Subject: [PATCH 2/2] test: add red/green tests for parentId chain fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that appendAssistantTranscriptMessage properly chains parentId to the previous message. Fails without fix (parentId = null), passes with. 🔥 Vivi (Claude Opus 4.5) --- src/gateway/server-methods/chat.test.ts | 106 ++++++++++++++++++++++++ src/gateway/server-methods/chat.ts | 6 ++ 2 files changed, 112 insertions(+) create mode 100644 src/gateway/server-methods/chat.test.ts diff --git a/src/gateway/server-methods/chat.test.ts b/src/gateway/server-methods/chat.test.ts new file mode 100644 index 000000000..f39710c38 --- /dev/null +++ b/src/gateway/server-methods/chat.test.ts @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { _testExports } from "./chat.js"; + +const { findTranscriptLeafId, appendAssistantTranscriptMessage } = _testExports; + +describe("appendAssistantTranscriptMessage parentId chain", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chat-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("sets parentId to previous message id when appending second message", () => { + const transcriptPath = path.join(tempDir, "transcript.jsonl"); + const sessionId = "test-session"; + + // Append first message + const result1 = appendAssistantTranscriptMessage({ + message: "First message", + sessionId, + storePath: undefined, + sessionFile: transcriptPath, + createIfMissing: true, + }); + expect(result1.ok).toBe(true); + if (!result1.ok) throw new Error("First append failed"); + + const firstMessageId = result1.messageId; + + // Append second message + const result2 = appendAssistantTranscriptMessage({ + message: "Second message", + sessionId, + storePath: undefined, + sessionFile: transcriptPath, + }); + expect(result2.ok).toBe(true); + + // Read and parse the transcript + const lines = fs + .readFileSync(transcriptPath, "utf-8") + .trim() + .split("\n") + .filter((l) => l.trim()); + + // Should have: header, first message, second message + expect(lines.length).toBe(3); + + const secondMessageEntry = JSON.parse(lines[2]); + expect(secondMessageEntry.type).toBe("message"); + + // THE KEY ASSERTION: second message's parentId should be first message's id + // This fails on upstream main (parentId would be undefined/null) + // This passes on fix branch (parentId equals firstMessageId) + expect(secondMessageEntry.parentId).toBe(firstMessageId); + }); +}); + +describe("findTranscriptLeafId", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chat-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns null for non-existent file", () => { + const result = findTranscriptLeafId(path.join(tempDir, "missing.jsonl")); + expect(result).toBeNull(); + }); + + it("returns null for file with only header", () => { + const transcriptPath = path.join(tempDir, "header-only.jsonl"); + fs.writeFileSync( + transcriptPath, + JSON.stringify({ type: "session", id: "test", version: 1 }) + "\n", + ); + const result = findTranscriptLeafId(transcriptPath); + expect(result).toBeNull(); + }); + + it("returns last message id", () => { + const transcriptPath = path.join(tempDir, "with-messages.jsonl"); + const lines = [ + JSON.stringify({ type: "session", id: "test", version: 1 }), + JSON.stringify({ type: "message", id: "msg-001", message: {} }), + JSON.stringify({ type: "message", id: "msg-002", message: {} }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n") + "\n"); + + const result = findTranscriptLeafId(transcriptPath); + expect(result).toBe("msg-002"); + }); +}); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 42ec8b1d8..c53e737ff 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -713,3 +713,9 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { ok: true, messageId }); }, }; + +// Exported for testing only +export const _testExports = { + findTranscriptLeafId, + appendAssistantTranscriptMessage, +};