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 9010a6f21..c53e737ff 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, }; @@ -679,3 +713,9 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { ok: true, messageId }); }, }; + +// Exported for testing only +export const _testExports = { + findTranscriptLeafId, + appendAssistantTranscriptMessage, +};