161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
// Plan F.1: pure-function correctness tests for `mergeByIdReplaceWins`,
|
|
// the helper that combines `snapshot.messages` with `session.out` replay
|
|
// at run boot (plan section B.3). Replay wins on id collision because
|
|
// `session.out` carries the freshest representation of an assistant
|
|
// message.
|
|
|
|
import "../src/v3/test/index.js";
|
|
|
|
import type { UIMessage } from "ai";
|
|
import { describe, expect, it } from "vitest";
|
|
import { __mergeByIdReplaceWinsForTests as mergeByIdReplaceWins } from "../src/v3/ai.js";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
function userMessage(id: string, text: string): UIMessage {
|
|
return {
|
|
id,
|
|
role: "user",
|
|
parts: [{ type: "text", text }],
|
|
};
|
|
}
|
|
|
|
function assistantMessage(id: string, text: string): UIMessage {
|
|
return {
|
|
id,
|
|
role: "assistant",
|
|
parts: [{ type: "text", text }],
|
|
};
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
describe("mergeByIdReplaceWins", () => {
|
|
it("returns a copy of `a` when `b` is empty", () => {
|
|
const a = [userMessage("u-1", "hello")];
|
|
const result = mergeByIdReplaceWins(a, []);
|
|
expect(result).toEqual(a);
|
|
// Verify it's a copy (mutating result shouldn't touch a).
|
|
result.push(assistantMessage("a-1", "extra"));
|
|
expect(a).toHaveLength(1);
|
|
});
|
|
|
|
it("returns a copy of `b` when `a` is empty", () => {
|
|
const b = [assistantMessage("a-1", "world")];
|
|
const result = mergeByIdReplaceWins([], b);
|
|
expect(result).toEqual(b);
|
|
result.push(userMessage("u-extra", "extra"));
|
|
expect(b).toHaveLength(1);
|
|
});
|
|
|
|
it("returns [] when both inputs are empty", () => {
|
|
expect(mergeByIdReplaceWins([], [])).toEqual([]);
|
|
});
|
|
|
|
it("appends fresh ids from `b` after `a`'s entries", () => {
|
|
const a = [userMessage("u-1", "hi")];
|
|
const b = [assistantMessage("a-1", "ok")];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1"]);
|
|
expect(result[0]!.role).toBe("user");
|
|
expect(result[1]!.role).toBe("assistant");
|
|
});
|
|
|
|
it("replaces by id when `b` has a colliding entry — replay wins", () => {
|
|
const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale-version")];
|
|
const b = [assistantMessage("a-1", "fresh-version")];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[1]!.id).toBe("a-1");
|
|
expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-version");
|
|
});
|
|
|
|
it("preserves order from `a` even when entries are replaced", () => {
|
|
const a = [
|
|
userMessage("u-1", "first"),
|
|
assistantMessage("a-1", "stale"),
|
|
userMessage("u-2", "second"),
|
|
assistantMessage("a-2", "also-stale"),
|
|
];
|
|
const b = [assistantMessage("a-1", "fresh-1"), assistantMessage("a-2", "fresh-2")];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]);
|
|
expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-1");
|
|
expect((result[3]!.parts[0] as { text: string }).text).toBe("fresh-2");
|
|
});
|
|
|
|
it("appends `b` entries with no id collision after the merged set", () => {
|
|
const a = [userMessage("u-1", "first")];
|
|
const b = [
|
|
assistantMessage("a-1", "reply-1"),
|
|
userMessage("u-2", "second"),
|
|
assistantMessage("a-2", "reply-2"),
|
|
];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]);
|
|
});
|
|
|
|
it("treats messages without an id as always-append (no collision possible)", () => {
|
|
const a = [
|
|
userMessage("u-1", "first"),
|
|
// Synthetic message missing the id field — should append, never replace.
|
|
{
|
|
id: "" as string,
|
|
role: "assistant",
|
|
parts: [{ type: "text", text: "no-id-a" }],
|
|
} as UIMessage,
|
|
];
|
|
const b = [
|
|
{
|
|
id: "" as string,
|
|
role: "assistant",
|
|
parts: [{ type: "text", text: "no-id-b" }],
|
|
} as UIMessage,
|
|
];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
expect(result).toHaveLength(3);
|
|
// Both empty-id messages survive — no merge happens.
|
|
const noIdParts = result
|
|
.filter((m) => m.id === "")
|
|
.map((m) => (m.parts[0] as { text: string }).text);
|
|
expect(noIdParts).toEqual(["no-id-a", "no-id-b"]);
|
|
});
|
|
|
|
it("handles consecutive replays of the same id in `b` — last one wins", () => {
|
|
// Edge case: `b` has two entries with the same id (shouldn't happen
|
|
// for assistants in practice, but the helper must be deterministic).
|
|
const a = [assistantMessage("a-1", "v0")];
|
|
const b = [assistantMessage("a-1", "v1"), assistantMessage("a-1", "v2")];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
expect(result).toHaveLength(1);
|
|
expect((result[0]!.parts[0] as { text: string }).text).toBe("v2");
|
|
});
|
|
|
|
it("preserves user messages (only assistants come from replay) — semantic check", () => {
|
|
// The runtime contract: `session.out` contains assistant chunks only,
|
|
// so `b` should never contain user messages. If it does (defensively),
|
|
// the merge still works — but we lock down the typical pattern here.
|
|
const a = [
|
|
userMessage("u-1", "first"),
|
|
assistantMessage("a-1", "stale"),
|
|
userMessage("u-2", "second"),
|
|
];
|
|
const b = [assistantMessage("a-1", "fresh")];
|
|
const result = mergeByIdReplaceWins(a, b);
|
|
// User messages from snapshot survive untouched.
|
|
expect(result.filter((m) => m.role === "user").map((m) => m.id)).toEqual(["u-1", "u-2"]);
|
|
});
|
|
|
|
it("does not mutate either input array", () => {
|
|
const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale")];
|
|
const b = [assistantMessage("a-1", "fresh"), userMessage("u-2", "next")];
|
|
const aSnapshot = JSON.stringify(a);
|
|
const bSnapshot = JSON.stringify(b);
|
|
|
|
mergeByIdReplaceWins(a, b);
|
|
|
|
expect(JSON.stringify(a)).toBe(aSnapshot);
|
|
expect(JSON.stringify(b)).toBe(bSnapshot);
|
|
});
|
|
});
|