chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user