summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src/reconcile.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/conversation-store/src/reconcile.test.ts')
-rw-r--r--packages/conversation-store/src/reconcile.test.ts812
1 files changed, 413 insertions, 399 deletions
diff --git a/packages/conversation-store/src/reconcile.test.ts b/packages/conversation-store/src/reconcile.test.ts
index 78b808e..25b47d5 100644
--- a/packages/conversation-store/src/reconcile.test.ts
+++ b/packages/conversation-store/src/reconcile.test.ts
@@ -3,418 +3,432 @@ import { describe, expect, it } from "vitest";
import { reconcile, reconcileWithReport } from "./reconcile.js";
describe("reconcile", () => {
- it("returns empty array for empty input", () => {
- expect(reconcile([])).toEqual([]);
- });
+ it("returns empty array for empty input", () => {
+ expect(reconcile([])).toEqual([]);
+ });
- it("passes through a complete conversation unchanged", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
- ];
- const result = reconcile(messages);
- expect(result).toEqual(messages);
- });
+ it("passes through a complete conversation unchanged", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
+ ];
+ const result = reconcile(messages);
+ expect(result).toEqual(messages);
+ });
- it("passes through a complete tool-call/tool-result pair unchanged", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "read file" }] },
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_1",
- toolName: "readFile",
- input: { path: "/tmp/foo" },
- },
- ],
- },
- {
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_1",
- toolName: "readFile",
- content: "file contents",
- isError: false,
- },
- ],
- },
- { role: "assistant", chunks: [{ type: "text", text: "done" }] },
- ];
- const result = reconcile(messages);
- expect(result).toEqual(messages);
- });
+ it("passes through a complete tool-call/tool-result pair unchanged", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "read file" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "readFile",
+ input: { path: "/tmp/foo" },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "readFile",
+ content: "file contents",
+ isError: false,
+ },
+ ],
+ },
+ { role: "assistant", chunks: [{ type: "text", text: "done" }] },
+ ];
+ const result = reconcile(messages);
+ expect(result).toEqual(messages);
+ });
- it("synthesizes error result for orphaned tool-call", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "do something" }] },
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_orphan",
- toolName: "someTool",
- input: {},
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(3);
- expect(result[2]).toEqual({
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_orphan",
- toolName: "someTool",
- content: "interrupted: tool execution did not complete",
- isError: true,
- },
- ],
- });
- });
+ it("synthesizes error result for orphaned tool-call", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "do something" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_orphan",
+ toolName: "someTool",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(3);
+ expect(result[2]).toEqual({
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_orphan",
+ toolName: "someTool",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ });
+ });
- it("synthesizes results for multiple orphaned tool-calls", () => {
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_a",
- toolName: "toolA",
- input: {},
- },
- {
- type: "tool-call",
- toolCallId: "call_b",
- toolName: "toolB",
- input: {},
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(3);
- expect(result[1]?.role).toBe("tool");
- expect(result[2]?.role).toBe("tool");
- const ids = result.slice(1).map((m) => {
- const chunk = m.chunks[0];
- return chunk?.type === "tool-result" ? chunk.toolCallId : null;
- });
- expect(ids).toEqual(["call_a", "call_b"]);
- });
+ it("synthesizes results for multiple orphaned tool-calls", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_a",
+ toolName: "toolA",
+ input: {},
+ },
+ {
+ type: "tool-call",
+ toolCallId: "call_b",
+ toolName: "toolB",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(3);
+ expect(result[1]?.role).toBe("tool");
+ expect(result[2]?.role).toBe("tool");
+ const ids = result.slice(1).map((m) => {
+ const chunk = m.chunks[0];
+ return chunk?.type === "tool-result" ? chunk.toolCallId : null;
+ });
+ expect(ids).toEqual(["call_a", "call_b"]);
+ });
- it("handles mixed resolved and orphaned tool-calls", () => {
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_resolved",
- toolName: "toolResolved",
- input: {},
- },
- {
- type: "tool-call",
- toolCallId: "call_orphan",
- toolName: "toolOrphan",
- input: {},
- },
- ],
- },
- {
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_resolved",
- toolName: "toolResolved",
- content: "ok",
- isError: false,
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(3);
- expect(result[2]).toEqual({
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_orphan",
- toolName: "toolOrphan",
- content: "interrupted: tool execution did not complete",
- isError: true,
- },
- ],
- });
- });
+ it("handles mixed resolved and orphaned tool-calls", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_resolved",
+ toolName: "toolResolved",
+ input: {},
+ },
+ {
+ type: "tool-call",
+ toolCallId: "call_orphan",
+ toolName: "toolOrphan",
+ input: {},
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_resolved",
+ toolName: "toolResolved",
+ content: "ok",
+ isError: false,
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(3);
+ expect(result[2]).toEqual({
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_orphan",
+ toolName: "toolOrphan",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ });
+ });
- it("handles multiple turns with orphaned tool-calls in different turns", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "turn 1" }] },
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_t1",
- toolName: "tool1",
- input: {},
- },
- ],
- },
- {
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_t1",
- toolName: "tool1",
- content: "result",
- isError: false,
- },
- ],
- },
- { role: "user", chunks: [{ type: "text", text: "turn 2" }] },
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_t2",
- toolName: "tool2",
- input: {},
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(6);
- expect(result[5]).toEqual({
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_t2",
- toolName: "tool2",
- content: "interrupted: tool execution did not complete",
- isError: true,
- },
- ],
- });
- });
+ it("handles multiple turns with orphaned tool-calls in different turns", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "turn 1" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_t1",
+ toolName: "tool1",
+ input: {},
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_t1",
+ toolName: "tool1",
+ content: "result",
+ isError: false,
+ },
+ ],
+ },
+ { role: "user", chunks: [{ type: "text", text: "turn 2" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_t2",
+ toolName: "tool2",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(6);
+ expect(result[5]).toEqual({
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_t2",
+ toolName: "tool2",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ });
+ });
- it("preserves thinking and text chunks alongside tool-calls", () => {
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "let me think" },
- { type: "text", text: "I will call a tool" },
- {
- type: "tool-call",
- toolCallId: "call_x",
- toolName: "toolX",
- input: { a: 1 },
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(2);
- expect(result[0]?.chunks).toHaveLength(3);
- expect(result[1]?.role).toBe("tool");
- });
+ it("preserves thinking and text chunks alongside tool-calls", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "I will call a tool" },
+ {
+ type: "tool-call",
+ toolCallId: "call_x",
+ toolName: "toolX",
+ input: { a: 1 },
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(2);
+ expect(result[0]?.chunks).toHaveLength(3);
+ expect(result[1]?.role).toBe("tool");
+ });
- it("copies the originating tool-call's stepId onto a synthesized result", () => {
- const stepId = "step_orphan" as StepId;
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_sid",
- toolName: "someTool",
- input: {},
- stepId,
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(2);
- expect(result[1]?.role).toBe("tool");
- const chunk = result[1]?.chunks[0];
- if (chunk === undefined) throw new Error("expected chunk");
- expect(chunk.type).toBe("tool-result");
- if (chunk.type === "tool-result") {
- expect(chunk.stepId).toBe(stepId);
- }
- });
+ it("copies the originating tool-call's stepId onto a synthesized result", () => {
+ const stepId = "step_orphan" as StepId;
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_sid",
+ toolName: "someTool",
+ input: {},
+ stepId,
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(2);
+ expect(result[1]?.role).toBe("tool");
+ const chunk = result[1]?.chunks[0];
+ if (chunk === undefined) throw new Error("expected chunk");
+ expect(chunk.type).toBe("tool-result");
+ if (chunk.type === "tool-result") {
+ expect(chunk.stepId).toBe(stepId);
+ }
+ });
- it("omits stepId when the dangling call has none", () => {
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "call_nosid",
- toolName: "someTool",
- input: {},
- },
- ],
- },
- ];
- const result = reconcile(messages);
- expect(result).toHaveLength(2);
- const chunk = result[1]?.chunks[0];
- if (chunk === undefined) throw new Error("expected chunk");
- expect(chunk.type).toBe("tool-result");
- if (chunk.type === "tool-result") {
- expect(chunk).not.toHaveProperty("stepId");
- }
- });
+ it("omits stepId when the dangling call has none", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_nosid",
+ toolName: "someTool",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(2);
+ const chunk = result[1]?.chunks[0];
+ if (chunk === undefined) throw new Error("expected chunk");
+ expect(chunk.type).toBe("tool-result");
+ if (chunk.type === "tool-result") {
+ expect(chunk).not.toHaveProperty("stepId");
+ }
+ });
- // --- Layer 1: read-time self-repair of broken chats (error chunks) ---
+ // --- Layer 1: read-time self-repair of broken chats (error chunks) ---
- it("reconcile strips error-only trailing assistant message", () => {
- // The 77574596/102587c0 shape: [user, assistant{error}] -> [user].
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hi" }] },
- { role: "assistant", chunks: [{ type: "error", message: "boom" }] },
- ];
- const { messages: result, report } = reconcileWithReport(messages);
- expect(result).toEqual([{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);
- expect(report.strippedErrorChunks).toBe(1);
- expect(report.droppedEmptyMessages).toBe(1);
- expect(report.repairedCount).toBe(0);
- });
+ it("reconcile strips error-only trailing assistant message", () => {
+ // The 77574596/102587c0 shape: [user, assistant{error}] -> [user].
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hi" }] },
+ { role: "assistant", chunks: [{ type: "error", message: "boom" }] },
+ ];
+ const { messages: result, report } = reconcileWithReport(messages);
+ expect(result).toEqual([{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);
+ expect(report.strippedErrorChunks).toBe(1);
+ expect(report.droppedEmptyMessages).toBe(1);
+ expect(report.repairedCount).toBe(0);
+ });
- it("reconcile strips error chunk but keeps sibling text", () => {
- // assistant{text,error} -> assistant{text}.
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- { type: "text", text: "hello" },
- { type: "error", message: "boom" },
- ],
- },
- ];
- const { messages: result, report } = reconcileWithReport(messages);
- expect(result).toEqual([{ role: "assistant", chunks: [{ type: "text", text: "hello" }] }]);
- expect(report.strippedErrorChunks).toBe(1);
- expect(report.droppedEmptyMessages).toBe(0);
- expect(report.repairedCount).toBe(0);
- });
+ it("reconcile strips error chunk but keeps sibling text", () => {
+ // assistant{text,error} -> assistant{text}.
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ { type: "text", text: "hello" },
+ { type: "error", message: "boom" },
+ ],
+ },
+ ];
+ const { messages: result, report } = reconcileWithReport(messages);
+ expect(result).toEqual([{ role: "assistant", chunks: [{ type: "text", text: "hello" }] }]);
+ expect(report.strippedErrorChunks).toBe(1);
+ expect(report.droppedEmptyMessages).toBe(0);
+ expect(report.repairedCount).toBe(0);
+ });
- it("reconcile drops assistant message left empty after stripping error", () => {
- // assistant{error} only -> dropped entirely.
- const messages: ChatMessage[] = [
- { role: "assistant", chunks: [{ type: "error", message: "boom" }] },
- ];
- const { messages: result, report } = reconcileWithReport(messages);
- expect(result).toEqual([]);
- expect(report.strippedErrorChunks).toBe(1);
- expect(report.droppedEmptyMessages).toBe(1);
- expect(report.repairedCount).toBe(0);
- });
+ it("reconcile drops assistant message left empty after stripping error", () => {
+ // assistant{error} only -> dropped entirely.
+ const messages: ChatMessage[] = [
+ { role: "assistant", chunks: [{ type: "error", message: "boom" }] },
+ ];
+ const { messages: result, report } = reconcileWithReport(messages);
+ expect(result).toEqual([]);
+ expect(report.strippedErrorChunks).toBe(1);
+ expect(report.droppedEmptyMessages).toBe(1);
+ expect(report.repairedCount).toBe(0);
+ });
- it("reconcile keeps tool-call + strips error", () => {
- // assistant{tool-call,error} with a matching result -> assistant{tool-call}.
- const messages: ChatMessage[] = [
- {
- role: "assistant",
- chunks: [
- { type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} },
- { type: "error", message: "boom" },
- ],
- },
- {
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_1",
- toolName: "t",
- content: "ok",
- isError: false,
- },
- ],
- },
- ];
- const { messages: result, report } = reconcileWithReport(messages);
- expect(result).toEqual([
- {
- role: "assistant",
- chunks: [{ type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }],
- },
- {
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_1",
- toolName: "t",
- content: "ok",
- isError: false,
- },
- ],
- },
- ]);
- expect(report.strippedErrorChunks).toBe(1);
- expect(report.droppedEmptyMessages).toBe(0);
- expect(report.repairedCount).toBe(0); // the tool-call has a matching result
- });
+ it("reconcile keeps tool-call + strips error", () => {
+ // assistant{tool-call,error} with a matching result -> assistant{tool-call}.
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ { type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} },
+ { type: "error", message: "boom" },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "t",
+ content: "ok",
+ isError: false,
+ },
+ ],
+ },
+ ];
+ const { messages: result, report } = reconcileWithReport(messages);
+ expect(result).toEqual([
+ {
+ role: "assistant",
+ chunks: [{ type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "t",
+ content: "ok",
+ isError: false,
+ },
+ ],
+ },
+ ]);
+ expect(report.strippedErrorChunks).toBe(1);
+ expect(report.droppedEmptyMessages).toBe(0);
+ expect(report.repairedCount).toBe(0); // the tool-call has a matching result
+ });
- it("reconcile strips error and still synthesizes a result for an orphaned tool-call", () => {
- // Ordering guard: strip error chunks first, then run orphaned-tool-call
- // synthesis on what remains. assistant{tool-call,error} with NO result ->
- // the error is stripped, the tool-call survives, and a result is synthesized.
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "go" }] },
- {
- role: "assistant",
- chunks: [
- { type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} },
- { type: "error", message: "boom" },
- ],
- },
- ];
- const { messages: result, report } = reconcileWithReport(messages);
- expect(result).toEqual([
- { role: "user", chunks: [{ type: "text", text: "go" }] },
- {
- role: "assistant",
- chunks: [{ type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }],
- },
- {
- role: "tool",
- chunks: [
- {
- type: "tool-result",
- toolCallId: "call_orph",
- toolName: "t",
- content: "interrupted: tool execution did not complete",
- isError: true,
- },
- ],
- },
- ]);
- expect(report.strippedErrorChunks).toBe(1);
- expect(report.droppedEmptyMessages).toBe(0);
- expect(report.repairedCount).toBe(1);
- expect(report.repairedToolCallIds).toEqual(["call_orph"]);
- });
+ it("reconcile strips error and still synthesizes a result for an orphaned tool-call", () => {
+ // Ordering guard: strip error chunks first, then run orphaned-tool-call
+ // synthesis on what remains. assistant{tool-call,error} with NO result ->
+ // the error is stripped, the tool-call survives, and a result is synthesized.
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "go" }] },
+ {
+ role: "assistant",
+ chunks: [
+ { type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} },
+ { type: "error", message: "boom" },
+ ],
+ },
+ ];
+ const { messages: result, report } = reconcileWithReport(messages);
+ expect(result).toEqual([
+ { role: "user", chunks: [{ type: "text", text: "go" }] },
+ {
+ role: "assistant",
+ chunks: [{ type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_orph",
+ toolName: "t",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ },
+ ]);
+ expect(report.strippedErrorChunks).toBe(1);
+ expect(report.droppedEmptyMessages).toBe(0);
+ expect(report.repairedCount).toBe(1);
+ expect(report.repairedToolCallIds).toEqual(["call_orph"]);
+ });
+
+ it("reconcile preserves a thinking-only assistant message", () => {
+ // Regression: an assistant message with only a thinking chunk (no text,
+ // no tool-call) was being dropped by the hasContent check. Thinking IS
+ // valid content — the model's reasoning must survive a load/reconcile
+ // cycle so it appears in the conversation history.
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "thinking", text: "just thinking..." }] },
+ ];
+ const { messages: result, report } = reconcileWithReport(messages);
+ expect(result).toEqual(messages);
+ expect(report.droppedEmptyMessages).toBe(0);
+ });
});