Files
fathah--hermes-desktop/tests/reconcile-streamed-with-db.test.ts
2026-07-13 13:36:53 +08:00

1112 lines
34 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
reconcileAfterDbRefresh,
reconcileStreamedWithDb,
} from "../src/renderer/src/screens/Chat/sessionHistory";
import type { ChatMessage } from "../src/renderer/src/screens/Chat/types";
/**
* `reconcileStreamedWithDb` is the end-of-stream merge between the
* in-memory streamed transcript and the canonical `state.db` rows
* returned by `getSessionMessages`.
*
* Two cases drive the design:
*
* 1. Today — DeepSeek (and o1/o3) emit `reasoning_content` over SSE
* but the gateway (NousResearch/hermes-agent#30449) doesn't
* forward it. So reasoning + tool rows only exist in state.db.
* The merge must ADD those rows so the user sees them without
* a window-focus dance.
*
* 2. After upstream #30449 lands — reasoning streams in real time
* with a renderer-side id like `reasoning-${ts}`. The merge
* must KEEP that streamed id so React doesn't re-mount the
* already-rendered bubble when the DB version (id `db-r-…`)
* arrives at end-of-stream.
*
* Both behaviours are pinned below.
*/
const STREAMED_USER = (content: string, id = "u-1"): ChatMessage => ({
id,
role: "user",
content,
});
const STREAMED_IMAGE_USER = (content: string, id = "u-img"): ChatMessage => ({
id,
role: "user",
content,
attachments: [
{
id: "img-1",
kind: "image",
name: "pasted-image.png",
mime: "image/png",
size: 3,
dataUrl: "data:image/png;base64,AAA=",
},
],
});
const STREAMED_TEXT_FILE_USER = (
content: string,
id = "u-file",
): ChatMessage => ({
id,
role: "user",
content,
attachments: [
{
id: "file-1",
kind: "text-file",
name: "notes.txt",
mime: "text/plain",
size: 12,
text: "hello world\n",
},
],
});
const DB_IMAGE_USER = (content: string, dbId = 10): ChatMessage => ({
id: `db-${dbId}`,
role: "user",
content,
attachments: [
{
id: `db-att-${dbId}-0`,
kind: "image",
name: "image.png",
mime: "image/png",
size: 46227,
dataUrl: "data:image/png;base64,AAA=",
},
],
});
const STREAMED_AGENT = (content: string, id = "a-1"): ChatMessage => ({
id,
role: "agent",
content,
});
const LOCAL_ERROR = (
error: string,
id = "error-1",
turnId?: string,
): ChatMessage => ({
id,
role: "agent",
content: "",
error,
localOnly: true,
...(turnId ? { turnId } : {}),
});
const STREAMED_REASONING = (text: string, id = "r-1"): ChatMessage => ({
id,
kind: "reasoning",
role: "agent",
text,
});
const DB_USER = (content: string, dbId = 10): ChatMessage => ({
id: `db-${dbId}`,
role: "user",
content,
});
const DB_AGENT = (content: string, dbId = 11): ChatMessage => ({
id: `db-${dbId}`,
role: "agent",
content,
});
const DB_REASONING = (text: string, dbId = 12): ChatMessage => ({
id: `db-r-${dbId}`,
kind: "reasoning",
role: "agent",
text,
});
const DB_TOOL_CALL = (
callId: string,
name: string,
args: string,
dbId = 13,
): ChatMessage => ({
id: `db-tc-${dbId}-${callId}`,
kind: "tool_call",
role: "agent",
callId,
name,
args,
});
const DB_TOOL_RESULT = (
callId: string,
name: string,
content: string,
dbId = 14,
): ChatMessage => ({
id: `db-tr-${dbId}`,
kind: "tool_result",
role: "agent",
callId,
name,
content,
});
const LIVE_TOOL_CALL = (
callId: string,
name: string,
args: string,
id = `tool-call-${callId}`,
): ChatMessage => ({
id,
kind: "tool_call",
role: "agent",
callId,
name,
args,
status: "running",
});
describe("reconcileStreamedWithDb", () => {
it("today: gateway doesn't stream reasoning — merge inserts reasoning from DB", () => {
// Streamed transcript: user msg + assistant content (no reasoning).
const streamed: ChatMessage[] = [
STREAMED_USER("hi", "u-1"),
STREAMED_AGENT("hello", "a-1"),
];
// DB has the same user/assistant rows + a reasoning row in between.
const db: ChatMessage[] = [
DB_USER("hi", 1),
DB_REASONING("user said hi, respond politely", 2),
DB_AGENT("hello", 3),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged).toHaveLength(3);
// User & assistant keep their streamed ids (no re-mount).
expect(merged[0].id).toBe("u-1");
expect(merged[2].id).toBe("a-1");
// Reasoning came in from DB — has the db-r- prefix.
expect(merged[1].id).toBe("db-r-2");
expect(
(merged[1] as Extract<ChatMessage, { kind: "reasoning" }>).text,
).toBe("user said hi, respond politely");
});
it("future: reasoning DOES stream — merge keeps the streamed id, no re-mount", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("hi", "u-1"),
STREAMED_REASONING("user said hi, respond politely", "r-stream-99"),
STREAMED_AGENT("hello", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("hi", 1),
DB_REASONING("user said hi, respond politely", 2),
DB_AGENT("hello", 3),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged).toHaveLength(3);
// All three retain their streamed ids — React doesn't re-mount.
expect(merged.map((m) => m.id)).toEqual(["u-1", "r-stream-99", "a-1"]);
});
it("tool_call and tool_result rows come straight from DB (they never stream)", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("read foo.txt", "u-1"),
STREAMED_AGENT("Done.", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("read foo.txt", 1),
DB_TOOL_CALL("call-42", "fs.read", '{"path":"foo.txt"}', 2),
DB_TOOL_RESULT("call-42", "fs.read", "(file contents)", 3),
DB_AGENT("Done.", 4),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged).toHaveLength(4);
// User and assistant keep their streamed identities.
expect(merged[0].id).toBe("u-1");
expect(merged[3].id).toBe("a-1");
// Tool rows are sourced from DB with their db- ids.
expect(merged[1].id).toBe("db-tc-2-call-42");
expect(merged[2].id).toBe("db-tr-3");
});
it("handles a turn that fully streamed including reasoning AND has new tool rows in DB", () => {
// The most common "future" case: reasoning streamed live, then the
// model used a tool, then produced a final answer. Only the tool
// rows are new at merge time.
const streamed: ChatMessage[] = [
STREAMED_USER("what time is it in Tokyo?", "u-1"),
STREAMED_REASONING("I need to call get_time for Tokyo.", "r-stream-1"),
STREAMED_AGENT("It's 3pm in Tokyo.", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("what time is it in Tokyo?", 1),
DB_REASONING("I need to call get_time for Tokyo.", 2),
DB_TOOL_CALL("call-99", "get_time", '{"zone":"Asia/Tokyo"}', 3),
DB_TOOL_RESULT("call-99", "get_time", "15:00 JST", 4),
DB_AGENT("It's 3pm in Tokyo.", 5),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged).toHaveLength(5);
// Streamed rows preserved.
expect(merged[0].id).toBe("u-1");
expect(merged[1].id).toBe("r-stream-1");
expect(merged[4].id).toBe("a-1");
// Tool rows added from DB at their canonical positions.
expect(merged[2].id).toBe("db-tc-3-call-99");
expect(merged[3].id).toBe("db-tr-4");
});
it("duplicate content across turns is matched in order (FIFO, not collapse)", () => {
// User asked "ping" twice in two separate turns. The merge must not
// collapse both DB "ping" rows onto the first streamed "ping".
const streamed: ChatMessage[] = [
STREAMED_USER("ping", "u-first"),
STREAMED_AGENT("pong", "a-first"),
STREAMED_USER("ping", "u-second"),
STREAMED_AGENT("pong", "a-second"),
];
const db: ChatMessage[] = [
DB_USER("ping", 1),
DB_AGENT("pong", 2),
DB_USER("ping", 3),
DB_AGENT("pong", 4),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-first",
"a-first",
"u-second",
"a-second",
]);
});
it("preserves a renderer-only bubble that has no DB equivalent (error rows)", () => {
// `onChatError` writes a synthetic "Error: …" row into the in-memory
// transcript. It has no state.db row. Reconciliation must keep it
// so the user doesn't lose visibility of what went wrong.
const errorBubble: ChatMessage = {
id: "error-1",
role: "agent",
content: "Error: provider returned 401",
};
const streamed: ChatMessage[] = [STREAMED_USER("hi", "u-1"), errorBubble];
const db: ChatMessage[] = [DB_USER("hi", 1)];
const merged = reconcileStreamedWithDb(streamed, db);
// The user row reconciled by content; the error row appended at end.
expect(merged).toHaveLength(2);
expect(merged[0].id).toBe("u-1");
expect(merged[1].id).toBe("error-1");
});
it("keeps a failed local turn anchored before a later successful DB turn", () => {
const failedUser = STREAMED_USER("bad provider turn", "u-bad");
const goodUser = STREAMED_USER("good provider turn", "u-good");
const goodAnswer = STREAMED_AGENT("working response", "a-good");
const streamed: ChatMessage[] = [
failedUser,
LOCAL_ERROR("OpenRouter 403", "error-bad"),
goodUser,
goodAnswer,
];
const db: ChatMessage[] = [
DB_USER("good provider turn", 1),
DB_AGENT("working response", 2),
];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-bad",
"error-bad",
"u-good",
"a-good",
]);
});
it("does not duplicate a local error already loaded from a desktop continuation", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("bad provider turn", "u-bad"),
LOCAL_ERROR("OpenRouter 401", "error-bad"),
STREAMED_USER("good provider turn", "u-good"),
STREAMED_AGENT("working response", "a-good"),
];
const db: ChatMessage[] = [
DB_USER("bad provider turn", -10),
{
id: "db--11",
role: "agent",
content: "",
error: "OpenRouter 401",
localOnly: true,
},
DB_USER("good provider turn", 1),
DB_AGENT("working response", 2),
];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"db--10",
"db--11",
"u-good",
"a-good",
]);
expect(merged.filter((m) => "error" in m && m.error)).toHaveLength(1);
});
it("preserves a local assistant error when the DB only has the failed user row", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("bad provider turn", "u-bad"),
LOCAL_ERROR("OpenRouter 403", "error-bad"),
];
const db: ChatMessage[] = [DB_USER("bad provider turn", 1)];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual(["db-1", "error-bad"]);
});
it("does not let a failed repeated prompt steal the later successful DB turn", () => {
const streamed: ChatMessage[] = [
{ ...STREAMED_USER("hi", "u-failed"), turnId: "turn-failed" },
LOCAL_ERROR("OpenRouter 403", "error-failed", "turn-failed"),
{ ...STREAMED_USER("hi", "u-good"), turnId: "turn-good" },
{ ...STREAMED_AGENT("hello", "a-good"), turnId: "turn-good" },
];
const db: ChatMessage[] = [DB_USER("hi", 1), DB_AGENT("hello", 2)];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-failed",
"error-failed",
"u-good",
"a-good",
]);
});
it("preserves DB-only reasoning, tools, and artifacts after a prior local failure", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("use the bad provider", "u-failed"),
LOCAL_ERROR("OpenRouter 401: missing API key", "error-failed"),
STREAMED_USER("now use the working provider", "u-good"),
STREAMED_AGENT("The report is ready.", "a-good"),
];
const dbToolResult: ChatMessage = {
id: "db-tr-4",
kind: "tool_result",
role: "agent",
callId: "call-report",
name: "write_file",
content: "wrote report.md",
attachments: [
{
id: "artifact-report",
kind: "file",
name: "report.md",
mime: "text/markdown",
size: 42,
path: "C:/tmp/report.md",
},
],
};
const db: ChatMessage[] = [
DB_USER("now use the working provider", 1),
DB_REASONING("I should create a short report.", 2),
DB_TOOL_CALL("call-report", "write_file", '{"path":"report.md"}', 3),
dbToolResult,
DB_AGENT("The report is ready.", 5),
];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-failed",
"error-failed",
"u-good",
"db-r-2",
"db-tc-3-call-report",
"db-tr-4",
"a-good",
]);
expect(merged[5]).toMatchObject({
kind: "tool_result",
attachments: [{ id: "artifact-report", name: "report.md" }],
});
});
it("anchors a DB-only recovery answer after the local recovery user when DB missed that user row", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("bad provider turn", "u-failed"),
LOCAL_ERROR("OpenRouter 401: invalid API key", "error-failed"),
{ ...STREAMED_USER("recovery turn", "u-recovery"), turnId: "turn-good" },
];
const db: ChatMessage[] = [
DB_USER("bad provider turn", 1),
DB_AGENT("Recovered successfully.", 2),
];
const merged = reconcileAfterDbRefresh(streamed, db, {
activeTurn: {
turnId: "turn-good",
userId: "u-recovery",
startIndex: 2,
status: "running",
},
});
expect(merged.map((m) => m.id)).toEqual([
"db-1",
"error-failed",
"u-recovery",
"db-2",
]);
});
it("anchors a tool-heavy recovery turn when DB missed the active user row", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("hi", "u-failed"),
LOCAL_ERROR("OpenRouter 401: invalid API key", "error-failed"),
{
...STREAMED_USER("generate a toy duck", "u-duck"),
turnId: "turn-duck",
},
LIVE_TOOL_CALL("call-skill", "skill_view", "ai-playground-image-gen"),
LIVE_TOOL_CALL("call-run", "terminal", "python generate_duck.py"),
STREAMED_AGENT("Generated it with AI Playground.", "a-duck"),
];
const db: ChatMessage[] = [
DB_USER("hi", 1),
DB_TOOL_CALL("call-skill", "skill_view", "ai-playground-image-gen", 2),
DB_TOOL_RESULT("call-skill", "skill_view", "skill loaded", 3),
DB_TOOL_CALL("call-run", "terminal", "python generate_duck.py", 4),
DB_TOOL_RESULT("call-run", "terminal", "saved=toy_duck.png", 5),
DB_AGENT("Generated it with AI Playground.", 6),
];
const merged = reconcileAfterDbRefresh(streamed, db, {
activeTurn: {
turnId: "turn-duck",
userId: "u-duck",
startIndex: 2,
status: "running",
},
});
expect(merged.map((m) => m.id)).toEqual([
"db-1",
"error-failed",
"u-duck",
"tool-call-call-skill",
"db-tr-3",
"tool-call-call-run",
"db-tr-5",
"a-duck",
]);
expect(
merged.filter((m) => "kind" in m && m.kind === "tool_call"),
).toHaveLength(2);
expect(
merged.filter((m) => "kind" in m && m.kind === "tool_result"),
).toHaveLength(2);
});
it("handles an empty streamed array (cold session load)", () => {
const db: ChatMessage[] = [
DB_USER("hi", 1),
DB_REASONING("respond politely", 2),
DB_AGENT("hello", 3),
];
const merged = reconcileStreamedWithDb([], db);
// Pure pass-through of DB rows.
expect(merged).toEqual(db);
});
it("deduplicates streamed messages that exceed DB row count", () => {
// Edge case: the renderer somehow held two streamed bubbles with
// identical content, but the DB only has one. This is the exact
// duplication bug — the merge should deduplicate by content so
// only one message appears in the output.
const streamed: ChatMessage[] = [
STREAMED_AGENT("hello", "a-1"),
STREAMED_AGENT("hello", "a-2"),
];
const db: ChatMessage[] = [DB_AGENT("hello", 7)];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged).toHaveLength(1);
// DB row takes precedence; the duplicate streamed row is dropped.
expect(merged[0].id).toBe("a-1");
});
it("drops a concatenated streamed assistant bubble when DB splits the turn", () => {
const partA = "First paragraph from before the tool call.";
const partB = "Second paragraph after the tool result.";
const streamed: ChatMessage[] = [
STREAMED_USER("do the thing", "u-1"),
STREAMED_AGENT(`${partA}\n\n${partB}`, "a-concat"),
];
const db: ChatMessage[] = [
DB_USER("do the thing", 1),
DB_AGENT(partA, 2),
DB_TOOL_CALL("call-1", "terminal", '{"command":"echo ok"}', 3),
DB_TOOL_RESULT("call-1", "terminal", "ok", 4),
DB_AGENT(partB, 5),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-1",
"db-2",
"db-tc-3-call-1",
"db-tr-4",
"db-5",
]);
expect(
merged.filter((m) => !("kind" in m) && m.content.includes(partB)),
).toHaveLength(1);
});
it("does not repeat long-chat tool-split turns during DB refresh", () => {
const turn2A =
"I checked the current directory before running the command.";
const turn2B = "The directory contains package.json and src.";
const turn3A = "I will inspect the failing test next.";
const turn3B = "The failing assertion is caused by duplicate rendering.";
const streamed: ChatMessage[] = [
STREAMED_USER("hello", "u-1"),
STREAMED_AGENT("Hi there.", "a-1"),
STREAMED_USER("list files", "u-2"),
STREAMED_AGENT(`${turn2A}\n\n${turn2B}`, "a-2-concat"),
STREAMED_USER("why is it failing?", "u-3"),
STREAMED_AGENT(`${turn3A}\n\n${turn3B}`, "a-3-concat"),
];
const db: ChatMessage[] = [
DB_USER("hello", 1),
DB_AGENT("Hi there.", 2),
DB_USER("list files", 3),
DB_AGENT(turn2A, 4),
DB_TOOL_CALL("call-ls", "terminal", '{"command":"ls"}', 5),
DB_TOOL_RESULT("call-ls", "terminal", "package.json\nsrc", 6),
DB_AGENT(turn2B, 7),
DB_USER("why is it failing?", 8),
DB_AGENT(turn3A, 9),
DB_TOOL_CALL("call-test", "terminal", '{"command":"npm test"}', 10),
DB_TOOL_RESULT("call-test", "terminal", "1 failed", 11),
DB_AGENT(turn3B, 12),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-1",
"a-1",
"u-2",
"db-4",
"db-tc-5-call-ls",
"db-tr-6",
"db-7",
"u-3",
"db-9",
"db-tc-10-call-test",
"db-tr-11",
"db-12",
]);
for (const text of [turn2B, turn3B]) {
expect(
merged.filter((m) => !("kind" in m) && m.content.includes(text)),
).toHaveLength(1);
}
});
it("preserves unmatched streamed bubbles that are not covered by a DB split", () => {
const streamedOnly = STREAMED_AGENT(
"Renderer-only warning: the provider closed the stream early.",
"a-warning",
);
const streamed: ChatMessage[] = [STREAMED_USER("hi", "u-1"), streamedOnly];
const db: ChatMessage[] = [
DB_USER("hi", 1),
DB_AGENT("A different DB response.", 2),
DB_AGENT("Another canonical row.", 3),
];
const merged = reconcileStreamedWithDb(streamed, db);
// The unmatched streamed bubble should appear right after its
// preceding user message (u-1), preserving the streamed order
// rather than being forced to the bottom.
expect(merged[1]).toBe(streamedOnly);
});
it("does not drop a streamed answer that quotes assistant rows from different turns", () => {
const quoteA = "Earlier answer A.";
const quoteB = "Earlier answer B.";
const quotedSummary = `${quoteA}\n\n${quoteB}`;
const streamed: ChatMessage[] = [
STREAMED_USER("summarize prior answers", "u-current"),
STREAMED_AGENT(quotedSummary, "a-current"),
];
const db: ChatMessage[] = [
DB_USER("first question", 1),
DB_AGENT(quoteA, 2),
DB_USER("second question", 3),
DB_AGENT(quoteB, 4),
DB_USER("summarize prior answers", 5),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged[merged.length - 1]).toMatchObject({
id: "a-current",
role: "agent",
content: quotedSummary,
});
});
it("keeps earlier streamed turns before a DB suffix from a split session", () => {
// Regression: a cold desktop send briefly fell back to the CLI path,
// which created a timestamp-style session id. The next send used the
// API path and generated a fresh desk-* id. At chat-done, the DB fetch
// returned only the desk-* suffix, and the old reconciliation appended
// the unmatched first turn after the latest answer.
const streamed: ChatMessage[] = [
STREAMED_USER("hi", "u-old"),
STREAMED_AGENT("Hi! What can I help you with today?", "a-old"),
STREAMED_USER("what time is it?", "u-new"),
STREAMED_AGENT("It's Wed, May 27, 2026, 2:34 PM.", "a-new"),
];
const db: ChatMessage[] = [
DB_USER("what time is it?", 30),
DB_TOOL_CALL("call-time", "terminal", '{"command":"date"}', 31),
DB_TOOL_RESULT("call-time", "terminal", "Wed, May 27, 2026 2:34 PM", 32),
DB_AGENT("It's Wed, May 27, 2026, 2:34 PM.", 33),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-old",
"a-old",
"u-new",
"db-tc-31-call-time",
"db-tr-32",
"a-new",
]);
});
it("matches a streamed image user bubble to the DB screenshot placeholder", () => {
const streamed: ChatMessage[] = [
STREAMED_IMAGE_USER("describe this image", "u-img"),
STREAMED_AGENT("It is a simple cartoon image.", "a-img"),
];
const db: ChatMessage[] = [
DB_USER("describe this image\n[screenshot]", 40),
DB_AGENT("It is a simple cartoon image.", 41),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged).toHaveLength(2);
expect(merged[0].id).toBe("u-img");
expect(merged[0]).toMatchObject({
role: "user",
content: "describe this image",
});
expect(
("attachments" in merged[0] && merged[0].attachments) || [],
).toHaveLength(1);
expect(merged[1].id).toBe("a-img");
});
it("does not append an old streamed image turn after later DB-only rows", () => {
const streamed: ChatMessage[] = [
STREAMED_IMAGE_USER("describe this image", "u-img"),
STREAMED_AGENT("It is a simple cartoon image.", "a-img"),
STREAMED_USER("what time is it", "u-time"),
STREAMED_AGENT("It's Wed, May 27, 2026, 3:51 PM.", "a-time"),
];
const db: ChatMessage[] = [
DB_USER("describe this image\n[screenshot]", 50),
DB_AGENT("It is a simple cartoon image.", 51),
DB_USER("what time is it", 52),
DB_TOOL_CALL("call-time", "terminal", '{"command":"date"}', 53),
DB_TOOL_RESULT("call-time", "terminal", "Wed, May 27, 2026 3:51 PM", 54),
DB_AGENT("It's Wed, May 27, 2026, 3:51 PM.", 55),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-img",
"a-img",
"u-time",
"db-tc-53-call-time",
"db-tr-54",
"a-time",
]);
expect(merged.filter((m) => m.id === "u-img")).toHaveLength(1);
expect(
("attachments" in merged[0] && merged[0].attachments) || [],
).toHaveLength(1);
});
it("drops synthetic live tool rows once matching canonical DB tool rows arrive", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("make an image", "u-1"),
{
id: "tool-call-live-tool:run-1:skill_view:1",
kind: "tool_call",
role: "agent",
callId: "live-tool:run-1:skill_view:1",
name: "skill_view",
args: "ai-playground-image-gen",
},
{
id: "tool-call-live-tool:run-1:execute_code:1",
kind: "tool_call",
role: "agent",
callId: "live-tool:run-1:execute_code:1",
name: "execute_code",
args: "from hermes_tools import terminal",
},
STREAMED_AGENT("Done.", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("make an image", 60),
DB_TOOL_CALL("call-skill", "skill_view", "ai-playground-image-gen", 61),
DB_TOOL_RESULT("call-skill", "skill_view", "ok", 62),
DB_TOOL_CALL(
"call-code",
"execute_code",
"from hermes_tools import terminal",
63,
),
DB_TOOL_RESULT("call-code", "execute_code", "ok", 64),
DB_AGENT("Done.", 65),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-1",
"db-tc-61-call-skill",
"db-tr-62",
"db-tc-63-call-code",
"db-tr-64",
"a-1",
]);
});
it("matches legacy text-file DB wrappers to the optimistic attachment bubble", () => {
const streamed: ChatMessage[] = [
STREAMED_TEXT_FILE_USER("summarize the attachment", "u-file"),
STREAMED_AGENT("done", "a-file"),
];
const db: ChatMessage[] = [
DB_USER(
'summarize the attachment\n\n<file name="notes.txt" mime="text/plain">\nhello world\n</file>',
570,
),
DB_REASONING("The user attached a text file.", 571),
DB_AGENT("done", 571),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual(["u-file", "db-r-571", "a-file"]);
expect(
"attachments" in merged[0] ? merged[0].attachments?.[0].kind : "",
).toBe("text-file");
expect("content" in merged[0] ? merged[0].content : "").toBe(
"summarize the attachment",
);
});
it("does not drop extra repeated same-name synthetic tools when DB has fewer canonical rows", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("run checks", "u-1"),
{
id: "tool-call-live-tool:run-1:terminal:1",
kind: "tool_call",
role: "agent",
callId: "live-tool:run-1:terminal:1",
name: "terminal",
args: "npm test",
},
{
id: "tool-call-live-tool:run-1:terminal:2",
kind: "tool_call",
role: "agent",
callId: "live-tool:run-1:terminal:2",
name: "terminal",
args: "npm run typecheck",
},
STREAMED_AGENT("Done.", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("run checks", 70),
DB_TOOL_CALL("call-terminal-1", "terminal", "npm test", 71),
DB_AGENT("Done.", 72),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-1",
"tool-call-live-tool:run-1:terminal:2",
"db-tc-71-call-terminal-1",
"a-1",
]);
expect(merged[1]).toMatchObject({
kind: "tool_call",
name: "terminal",
args: "npm run typecheck",
});
});
it("does not let exact canonical tool matches consume extra synthetic same-name rows", () => {
const streamed: ChatMessage[] = [
STREAMED_USER("run checks", "u-1"),
{
id: "tool-call-call-terminal-1",
kind: "tool_call",
role: "agent",
callId: "call-terminal-1",
name: "terminal",
args: "npm test",
},
{
id: "tool-call-live-tool:run-1:terminal:2",
kind: "tool_call",
role: "agent",
callId: "live-tool:run-1:terminal:2",
name: "terminal",
args: "npm run typecheck",
},
STREAMED_AGENT("Done.", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("run checks", 80),
DB_TOOL_CALL("call-terminal-1", "terminal", "npm test", 81),
DB_AGENT("Done.", 82),
];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual([
"u-1",
"tool-call-call-terminal-1",
"tool-call-live-tool:run-1:terminal:2",
"a-1",
]);
});
it("keeps an inline clarify card at its streamed position after reconcile (regression: PR #604 review)", () => {
// The clarify card is renderer-only — it never lands in state.db. During
// streaming the user saw: user → clarify card → agent answer. The DB only
// has the user + the post-answer agent content. Without repositioning, the
// card (no reconciliationKey) gets flushed to the suffix and renders BELOW
// the agent answer — the reverse of what the user saw.
const CLARIFY = (id: string, requestId: string): ChatMessage => ({
id,
kind: "clarify",
role: "agent",
requestId,
question: "Which environment?",
choices: ["staging", "production"],
resolved: true,
answer: "production",
});
const streamed: ChatMessage[] = [
STREAMED_USER("deploy it", "u-1"),
CLARIFY("clarify-r1", "r1"),
STREAMED_AGENT("Deploying to production.", "a-1"),
];
const db: ChatMessage[] = [
DB_USER("deploy it", 1),
DB_AGENT("Deploying to production.", 2),
];
const merged = reconcileStreamedWithDb(streamed, db);
// Card stays between the user message and the agent answer.
expect(merged.map((m) => m.id)).toEqual(["u-1", "clarify-r1", "a-1"]);
});
it("preserves a leading clarify card (no streamed predecessor)", () => {
const CLARIFY = (id: string, requestId: string): ChatMessage => ({
id,
kind: "clarify",
role: "agent",
requestId,
question: "Pick one",
choices: ["a", "b"],
resolved: true,
answer: "a",
});
const streamed: ChatMessage[] = [
CLARIFY("clarify-r1", "r1"),
STREAMED_AGENT("done", "a-1"),
];
const db: ChatMessage[] = [DB_AGENT("done", 2)];
const merged = reconcileStreamedWithDb(streamed, db);
expect(merged.map((m) => m.id)).toEqual(["clarify-r1", "a-1"]);
});
it("keeps a continued restored image prompt before its answer when the DB snapshot briefly misses that user row", () => {
const answer =
"It's a cute yellow toy duck in a bathtub filled with blue bathwater.";
const streamed: ChatMessage[] = [
DB_USER("generate an image of a toy duck", 1),
DB_AGENT("Done - generated locally.", 2),
STREAMED_IMAGE_USER("what is this?", "u-img"),
STREAMED_AGENT(answer, "a-img"),
];
const db: ChatMessage[] = [
DB_USER("generate an image of a toy duck", 1),
DB_AGENT("Done - generated locally.", 2),
DB_AGENT(answer, 3),
];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual(["db-1", "db-2", "u-img", "a-img"]);
expect(
("attachments" in merged[2] && merged[2].attachments) || [],
).toHaveLength(1);
});
it("drops orphan duplicate pasted-image prompts once the DB has the canonical user row", () => {
const staleLocal1 = {
...STREAMED_IMAGE_USER("what is this?", "user-old-1"),
attachments: [
{
id: "att-old-1",
kind: "image" as const,
name: "image.png",
mime: "image/png",
size: 46227,
dataUrl: "data:image/png;base64,AAA=",
},
],
};
const staleLocal2 = {
...STREAMED_IMAGE_USER("what is this?", "user-old-2"),
attachments: [
{
id: "att-old-2",
kind: "image" as const,
name: "image.png",
mime: "image/png",
size: 46227,
dataUrl: "data:image/png;base64,AAA=",
},
],
};
const dbUser = DB_IMAGE_USER("what is this?\n[screenshot]", 444);
const dbReasoning = DB_REASONING("The image shows a bath toy.", 445);
const dbAnswer = DB_AGENT(
"It looks like a cute yellow rubber duck bath toy.",
446,
);
const streamed: ChatMessage[] = [
staleLocal1,
staleLocal2,
dbUser,
dbReasoning,
dbAnswer,
];
const db: ChatMessage[] = [dbUser, dbReasoning, dbAnswer];
const merged = reconcileAfterDbRefresh(streamed, db);
expect(merged.map((m) => m.id)).toEqual(["db-444", "db-r-445", "db-446"]);
});
it("does not anchor a pasted-image active user when the DB already has the canonical attachment row", () => {
const localUser = {
...STREAMED_IMAGE_USER("what is this?", "user-active-image"),
turnId: "turn-image",
attachments: [
{
id: "att-local-image",
kind: "image" as const,
name: "toy_duck_bathtub.png",
mime: "image/png",
size: 269771,
dataUrl: "data:image/png;base64,AAA=",
},
],
};
const streamedAnswer = {
...STREAMED_AGENT(
"It looks like a cute yellow duck in a bathtub.",
"agent-image",
),
turnId: "turn-image",
};
const dbUser = {
...DB_IMAGE_USER("what is this?\n[screenshot]", 450),
attachments: [
{
id: "db-att-450-0",
kind: "image" as const,
name: "toy_duck_bathtub.png",
mime: "image/png",
size: 269771,
dataUrl: "data:image/png;base64,AAA=",
},
],
};
const dbAnswer = DB_AGENT(
"It looks like a cute yellow duck in a bathtub.",
451,
);
const merged = reconcileAfterDbRefresh(
[localUser, streamedAnswer],
[dbUser, dbAnswer],
{
activeTurn: {
turnId: "turn-image",
userId: "user-active-image",
startIndex: 0,
status: "running",
},
},
);
expect(merged.map((m) => m.id)).toEqual([
"user-active-image",
"agent-image",
]);
expect(merged.filter((m) => m.id === "user-active-image")).toHaveLength(1);
});
});