From 3a48941c96f6b72c20e592493b693b1dc811faaf Mon Sep 17 00:00:00 2001 From: Kai Valo Date: Tue, 27 Jan 2026 01:31:12 +0000 Subject: [PATCH 1/2] fix: include stable entry ID in chat.history messages Messages returned by readSessionMessages now include the JSONL entry id, providing stable unique identifiers for each message. This fixes issues where downstream consumers (like kai-tools) relied on array-index-based IDs which were unstable when messages were filtered or added. --- src/gateway/session-utils.fs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index d6453ace6..8983f5434 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -23,7 +23,7 @@ export function readSessionMessages( try { const parsed = JSON.parse(line); if (parsed?.message) { - messages.push(parsed.message); + messages.push({ ...parsed.message, id: parsed.id }); } } catch { // ignore bad lines From 56692db403714255dabdfa0ce296025f653dbf43 Mon Sep 17 00:00:00 2001 From: Dominic Date: Tue, 27 Jan 2026 20:13:27 -0600 Subject: [PATCH 2/2] test: add unit tests for readSessionMessages stable ID Verify that readSessionMessages includes the JSONL entry ID in returned messages. Tests cover: - Messages with stable IDs are preserved - Messages without IDs handle gracefully (id: undefined) - Invalid JSON and empty lines are skipped - Message content and metadata are preserved - Nonexistent files return empty array --- src/gateway/session-utils.fs.test.ts | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 3cbbd4343..9b1f8543a 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, + readSessionMessages, readSessionPreviewItemsFromTranscript, } from "./session-utils.fs.js"; @@ -404,3 +405,119 @@ describe("readSessionPreviewItemsFromTranscript", () => { expect(result[0]?.text.endsWith("...")).toBe(true); }); }); + +describe("readSessionMessages", () => { + let tmpDir: string; + let sessionFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-messages-test-")); + sessionFile = path.join(tmpDir, "test-session.jsonl"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("includes stable entry ID in returned messages", () => { + // Create JSONL transcript with entries that have IDs + const entries = [ + { + id: "entry-1", + message: { role: "user", content: "Hello" }, + }, + { + id: "entry-2", + message: { role: "assistant", content: "Hi there!" }, + }, + { + id: "entry-3", + message: { role: "user", content: "How are you?" }, + }, + ]; + + const jsonl = entries.map((e) => JSON.stringify(e)).join("\n"); + fs.writeFileSync(sessionFile, jsonl, "utf-8"); + + // Read messages + const messages = readSessionMessages("test-session", undefined, sessionFile); + + // Verify each message includes the stable entry ID + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ role: "user", content: "Hello", id: "entry-1" }); + expect(messages[1]).toMatchObject({ role: "assistant", content: "Hi there!", id: "entry-2" }); + expect(messages[2]).toMatchObject({ role: "user", content: "How are you?", id: "entry-3" }); + }); + + test("handles entries without IDs gracefully", () => { + // Create JSONL with mixed entries (some with IDs, some without) + const entries = [ + { + id: "entry-1", + message: { role: "user", content: "Hello" }, + }, + { + // No id field + message: { role: "assistant", content: "Hi!" }, + }, + ]; + + const jsonl = entries.map((e) => JSON.stringify(e)).join("\n"); + fs.writeFileSync(sessionFile, jsonl, "utf-8"); + + const messages = readSessionMessages("test-session", undefined, sessionFile); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ role: "user", content: "Hello", id: "entry-1" }); + // Second message should have id: undefined (spread of undefined value) + expect(messages[1]).toMatchObject({ role: "assistant", content: "Hi!", id: undefined }); + }); + + test("skips empty lines and invalid JSON", () => { + const jsonl = [ + JSON.stringify({ id: "entry-1", message: { role: "user", content: "Valid" } }), + "", // Empty line + "invalid json {", + JSON.stringify({ id: "entry-2", message: { role: "user", content: "Also valid" } }), + ].join("\n"); + + fs.writeFileSync(sessionFile, jsonl, "utf-8"); + + const messages = readSessionMessages("test-session", undefined, sessionFile); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ id: "entry-1" }); + expect(messages[1]).toMatchObject({ id: "entry-2" }); + }); + + test("returns empty array when session file does not exist", () => { + const messages = readSessionMessages("nonexistent", undefined, "/nonexistent/path.jsonl"); + expect(messages).toEqual([]); + }); + + test("preserves message content and metadata", () => { + const entries = [ + { + id: "msg-1", + message: { + role: "assistant", + content: [{ type: "text", text: "Complex content" }], + metadata: { foo: "bar" }, + }, + }, + ]; + + const jsonl = entries.map((e) => JSON.stringify(e)).join("\n"); + fs.writeFileSync(sessionFile, jsonl, "utf-8"); + + const messages = readSessionMessages("test-session", undefined, sessionFile); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Complex content" }], + metadata: { foo: "bar" }, + id: "msg-1", + }); + }); +});