481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
sessionId as toSessionId,
|
|
taskId as toTaskId,
|
|
type Message,
|
|
type MessageType,
|
|
} from "@/lib/types/http";
|
|
import type { RichMetadata } from "@/components/task/chat/types";
|
|
import {
|
|
buildGroupedRenderItems,
|
|
collapseTodoSnapshotsPerTurn,
|
|
deduplicateAgentBootResumes,
|
|
insertLastAgentErrorItem,
|
|
isAgentBootResumeMessage,
|
|
isSetupScriptMessage,
|
|
messageListMapsEqual,
|
|
messageMapsEqualByIdentity,
|
|
reconcileRenderItems,
|
|
type RenderItem,
|
|
} from "./use-processed-messages";
|
|
|
|
function makeMessage(
|
|
id: string,
|
|
type: MessageType,
|
|
metadata?: Record<string, unknown>,
|
|
content = "",
|
|
): Message {
|
|
return {
|
|
id,
|
|
session_id: toSessionId("s1"),
|
|
task_id: toTaskId("t1"),
|
|
author_type: "agent",
|
|
content,
|
|
type,
|
|
metadata,
|
|
created_at: "",
|
|
};
|
|
}
|
|
|
|
function makeTodo(
|
|
id: string,
|
|
turnId: string | undefined,
|
|
todos: Array<{ text: string; done?: boolean }>,
|
|
): Message {
|
|
return { ...makeMessage(id, "todo", { todos }), turn_id: turnId };
|
|
}
|
|
|
|
function toolExecute(id: string, turnId = "turn-1"): Message {
|
|
return {
|
|
...makeMessage(id, "tool_execute", {
|
|
status: "complete",
|
|
normalized: { shell_exec: { command: "gh pr checks", output: { exit_code: 0 } } },
|
|
}),
|
|
content: "gh pr checks",
|
|
turn_id: turnId,
|
|
};
|
|
}
|
|
|
|
function bootStarted(id: string): Message {
|
|
return makeMessage(id, "script_execution", {
|
|
script_type: "agent_boot",
|
|
agent_name: "Mock",
|
|
is_resuming: false,
|
|
status: "exited",
|
|
});
|
|
}
|
|
|
|
function bootResumed(id: string): Message {
|
|
return makeMessage(id, "script_execution", {
|
|
script_type: "agent_boot",
|
|
agent_name: "Mock",
|
|
is_resuming: true,
|
|
status: "exited",
|
|
});
|
|
}
|
|
|
|
describe("isAgentBootResumeMessage", () => {
|
|
it("returns true for script_execution agent_boot with is_resuming=true", () => {
|
|
expect(isAgentBootResumeMessage(bootResumed("r1"))).toBe(true);
|
|
});
|
|
|
|
it("returns false for a Started (non-resuming) agent_boot", () => {
|
|
expect(isAgentBootResumeMessage(bootStarted("s1"))).toBe(false);
|
|
});
|
|
|
|
it("returns false for a setup/cleanup script", () => {
|
|
const setup = makeMessage("x", "script_execution", {
|
|
script_type: "setup",
|
|
is_resuming: true,
|
|
});
|
|
expect(isAgentBootResumeMessage(setup)).toBe(false);
|
|
});
|
|
|
|
it("returns false for unrelated message types", () => {
|
|
expect(isAgentBootResumeMessage(makeMessage("m1", "message"))).toBe(false);
|
|
});
|
|
|
|
it("returns false when metadata is missing", () => {
|
|
const msg = makeMessage("x", "script_execution");
|
|
expect(isAgentBootResumeMessage(msg)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isSetupScriptMessage", () => {
|
|
it("returns true for a script_execution with script_type=setup", () => {
|
|
const msg = makeMessage("x", "script_execution", { script_type: "setup", status: "exited" });
|
|
expect(isSetupScriptMessage(msg)).toBe(true);
|
|
});
|
|
|
|
it("returns false for agent_boot and cleanup scripts", () => {
|
|
expect(isSetupScriptMessage(bootStarted("s1"))).toBe(false);
|
|
const cleanup = makeMessage("c1", "script_execution", { script_type: "cleanup" });
|
|
expect(isSetupScriptMessage(cleanup)).toBe(false);
|
|
});
|
|
|
|
it("returns false for non-script messages", () => {
|
|
expect(isSetupScriptMessage(makeMessage("m1", "message"))).toBe(false);
|
|
});
|
|
|
|
it("returns false when metadata is missing", () => {
|
|
expect(isSetupScriptMessage(makeMessage("x", "script_execution"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("deduplicateAgentBootResumes", () => {
|
|
it("returns an empty list unchanged", () => {
|
|
expect(deduplicateAgentBootResumes([])).toEqual([]);
|
|
});
|
|
|
|
it("returns the list unchanged when there are no resume messages", () => {
|
|
const messages = [bootStarted("s1"), makeMessage("m1", "message", undefined, "hi")];
|
|
expect(deduplicateAgentBootResumes(messages)).toEqual(messages);
|
|
});
|
|
|
|
it("returns the list unchanged when there is exactly one resume message", () => {
|
|
const messages = [
|
|
bootStarted("s1"),
|
|
makeMessage("m1", "message", undefined, "hi"),
|
|
bootResumed("r1"),
|
|
];
|
|
expect(deduplicateAgentBootResumes(messages)).toEqual(messages);
|
|
});
|
|
|
|
it("keeps only the last resume message when multiple exist", () => {
|
|
const messages = [bootResumed("r1"), bootResumed("r2"), bootResumed("r3")];
|
|
const result = deduplicateAgentBootResumes(messages);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe("r3");
|
|
});
|
|
|
|
it("preserves Started and non-boot messages while deduping resumes", () => {
|
|
const started = bootStarted("s1");
|
|
const userMsg = makeMessage("m1", "message", undefined, "hello");
|
|
const r1 = bootResumed("r1");
|
|
const r2 = bootResumed("r2");
|
|
const agentMsg = makeMessage("m2", "message", undefined, "reply");
|
|
const r3 = bootResumed("r3");
|
|
|
|
const result = deduplicateAgentBootResumes([started, userMsg, r1, r2, agentMsg, r3]);
|
|
|
|
expect(result.map((m) => m.id)).toEqual(["s1", "m1", "m2", "r3"]);
|
|
});
|
|
|
|
it("does not touch setup/cleanup script executions", () => {
|
|
const setup = makeMessage("x", "script_execution", {
|
|
script_type: "setup",
|
|
status: "exited",
|
|
});
|
|
const messages = [setup, bootResumed("r1"), bootResumed("r2")];
|
|
const result = deduplicateAgentBootResumes(messages);
|
|
expect(result.map((m) => m.id)).toEqual(["x", "r2"]);
|
|
});
|
|
});
|
|
|
|
describe("collapseTodoSnapshotsPerTurn", () => {
|
|
it("returns the list unchanged when there are no todo messages", () => {
|
|
const messages = [makeMessage("m1", "message", undefined, "hi")];
|
|
expect(collapseTodoSnapshotsPerTurn(messages)).toEqual(messages);
|
|
});
|
|
|
|
it("returns a single todo message unchanged", () => {
|
|
const todo = makeTodo("t1", "turn-1", [{ text: "step 1" }]);
|
|
const result = collapseTodoSnapshotsPerTurn([todo]);
|
|
expect(result).toEqual([todo]);
|
|
expect((result[0].metadata as RichMetadata).previous_todo_snapshots).toBeUndefined();
|
|
});
|
|
|
|
it("keeps only the latest todo per turn and attaches prior snapshots", () => {
|
|
const userMsg = makeMessage("u1", "message", undefined, "go");
|
|
const t1 = makeTodo("t1", "turn-1", [{ text: "a" }]);
|
|
const t2 = makeTodo("t2", "turn-1", [{ text: "a", done: true }, { text: "b" }]);
|
|
const t3 = makeTodo("t3", "turn-1", [
|
|
{ text: "a", done: true },
|
|
{ text: "b", done: true },
|
|
{ text: "c" },
|
|
]);
|
|
const result = collapseTodoSnapshotsPerTurn([userMsg, t1, t2, t3]);
|
|
|
|
expect(result.map((m) => m.id)).toEqual(["u1", "t3"]);
|
|
const meta = result[1].metadata as RichMetadata;
|
|
expect(meta.previous_todo_snapshots).toHaveLength(2);
|
|
expect(meta.previous_todo_snapshots?.[0].todos).toEqual([{ text: "a" }]);
|
|
expect(meta.previous_todo_snapshots?.[1].todos).toEqual([
|
|
{ text: "a", done: true },
|
|
{ text: "b" },
|
|
]);
|
|
// Latest todos remain reachable on the kept message.
|
|
expect(meta.todos).toEqual([
|
|
{ text: "a", done: true },
|
|
{ text: "b", done: true },
|
|
{ text: "c" },
|
|
]);
|
|
});
|
|
|
|
it("collapses todos independently per turn", () => {
|
|
const a1 = makeTodo("a1", "turn-A", [{ text: "x" }]);
|
|
const a2 = makeTodo("a2", "turn-A", [{ text: "x", done: true }]);
|
|
const b1 = makeTodo("b1", "turn-B", [{ text: "y" }]);
|
|
const result = collapseTodoSnapshotsPerTurn([a1, a2, b1]);
|
|
|
|
expect(result.map((m) => m.id)).toEqual(["a2", "b1"]);
|
|
expect((result[0].metadata as RichMetadata).previous_todo_snapshots).toHaveLength(1);
|
|
expect((result[1].metadata as RichMetadata).previous_todo_snapshots).toBeUndefined();
|
|
});
|
|
|
|
it("preserves todo messages without a turn_id", () => {
|
|
const orphan = makeTodo("o1", undefined, [{ text: "orphan" }]);
|
|
const t1 = makeTodo("t1", "turn-1", [{ text: "a" }]);
|
|
const t2 = makeTodo("t2", "turn-1", [{ text: "a", done: true }]);
|
|
const result = collapseTodoSnapshotsPerTurn([orphan, t1, t2]);
|
|
|
|
expect(result.map((m) => m.id)).toEqual(["o1", "t2"]);
|
|
});
|
|
|
|
it("does not mutate input messages", () => {
|
|
const t1 = makeTodo("t1", "turn-1", [{ text: "a" }]);
|
|
const t2 = makeTodo("t2", "turn-1", [{ text: "a", done: true }]);
|
|
collapseTodoSnapshotsPerTurn([t1, t2]);
|
|
expect(t1.metadata).toEqual({ todos: [{ text: "a" }] });
|
|
expect(t2.metadata).toEqual({ todos: [{ text: "a", done: true }] });
|
|
});
|
|
});
|
|
|
|
describe("buildGroupedRenderItems prepare progress placement", () => {
|
|
it("does not inject prepare progress into a partial tool-only history window", () => {
|
|
const partialWindow = [toolExecute("tool-1"), toolExecute("tool-2")];
|
|
|
|
const result = buildGroupedRenderItems(partialWindow, "s1", {
|
|
canAnchorPrepareProgress: false,
|
|
});
|
|
|
|
expect(result.map((item) => item.type)).toEqual(["turn_group"]);
|
|
});
|
|
|
|
it("injects prepare progress when the session start is loaded", () => {
|
|
const initialPrompt = makeMessage("user-1", "message", undefined, "start");
|
|
|
|
const result = buildGroupedRenderItems([initialPrompt], "s1", {
|
|
canAnchorPrepareProgress: true,
|
|
});
|
|
|
|
expect(result.map((item) => item.type)).toEqual(["message", "prepare_progress"]);
|
|
});
|
|
});
|
|
|
|
describe("insertLastAgentErrorItem", () => {
|
|
it("inserts the notice after the nearest message before the error timestamp", () => {
|
|
const before = {
|
|
...makeMessage("before", "message", undefined, "before"),
|
|
created_at: "2026-06-14T10:00:00Z",
|
|
};
|
|
const after = {
|
|
...makeMessage("after", "message", undefined, "after"),
|
|
created_at: "2026-06-14T10:10:00Z",
|
|
};
|
|
const items = buildGroupedRenderItems([before, after], "s1", {
|
|
canAnchorPrepareProgress: false,
|
|
});
|
|
|
|
const result = insertLastAgentErrorItem(items, "s1", {
|
|
message: "peer disconnected before response",
|
|
occurredAt: "2026-06-14T10:05:00Z",
|
|
});
|
|
|
|
expect(result.map((item) => item.type)).toEqual(["message", "agent_error_notice", "message"]);
|
|
});
|
|
|
|
it("uses the notice as the only item when there are no messages", () => {
|
|
const result = insertLastAgentErrorItem([], "s1", {
|
|
message: "peer disconnected before response",
|
|
occurredAt: "2026-06-14T10:05:00Z",
|
|
});
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
type: "agent_error_notice",
|
|
sessionId: "s1",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("appends the notice when existing items have no usable timestamps", () => {
|
|
const untimed = makeMessage("untimed", "message", undefined, "untimed");
|
|
const items = buildGroupedRenderItems([untimed], "s1", {
|
|
canAnchorPrepareProgress: false,
|
|
});
|
|
|
|
const result = insertLastAgentErrorItem(items, "s1", {
|
|
message: "peer disconnected before response",
|
|
occurredAt: "2026-06-14T10:05:00Z",
|
|
});
|
|
|
|
expect(result.map((item) => item.type)).toEqual(["message", "agent_error_notice"]);
|
|
});
|
|
});
|
|
|
|
describe("messageMapsEqualByIdentity", () => {
|
|
it("treats maps with the same keys and identical Message refs as equal", () => {
|
|
const a = makeMessage("perm-1", "permission_request");
|
|
const b = makeMessage("perm-2", "permission_request");
|
|
const first = new Map<string, Message>([
|
|
["tc-1", a],
|
|
["tc-2", b],
|
|
]);
|
|
const second = new Map<string, Message>([
|
|
["tc-1", a],
|
|
["tc-2", b],
|
|
]);
|
|
expect(messageMapsEqualByIdentity(first, second)).toBe(true);
|
|
});
|
|
|
|
it("is false when sizes differ", () => {
|
|
const a = makeMessage("perm-1", "permission_request");
|
|
const first = new Map<string, Message>([["tc-1", a]]);
|
|
const second = new Map<string, Message>([
|
|
["tc-1", a],
|
|
["tc-2", makeMessage("perm-2", "permission_request")],
|
|
]);
|
|
expect(messageMapsEqualByIdentity(first, second)).toBe(false);
|
|
});
|
|
|
|
it("is false when a key maps to a different Message ref (e.g. status changed)", () => {
|
|
const first = new Map<string, Message>([["tc-1", makeMessage("perm-1", "permission_request")]]);
|
|
const second = new Map<string, Message>([
|
|
["tc-1", makeMessage("perm-1", "permission_request")],
|
|
]);
|
|
expect(messageMapsEqualByIdentity(first, second)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("messageListMapsEqual", () => {
|
|
it("treats maps whose array values share positional Message refs as equal", () => {
|
|
const c1 = makeMessage("child-1", "tool_call");
|
|
const c2 = makeMessage("child-2", "tool_call");
|
|
const first = new Map<string, Message[]>([["parent-1", [c1, c2]]]);
|
|
const second = new Map<string, Message[]>([["parent-1", [c1, c2]]]);
|
|
expect(messageListMapsEqual(first, second)).toBe(true);
|
|
});
|
|
|
|
it("is false when an array length changes (new child appended)", () => {
|
|
const c1 = makeMessage("child-1", "tool_call");
|
|
const first = new Map<string, Message[]>([["parent-1", [c1]]]);
|
|
const second = new Map<string, Message[]>([
|
|
["parent-1", [c1, makeMessage("child-2", "tool_call")]],
|
|
]);
|
|
expect(messageListMapsEqual(first, second)).toBe(false);
|
|
});
|
|
|
|
it("is false when a child Message ref changes positionally", () => {
|
|
const c1 = makeMessage("child-1", "tool_call");
|
|
const first = new Map<string, Message[]>([["parent-1", [c1]]]);
|
|
const second = new Map<string, Message[]>([
|
|
["parent-1", [makeMessage("child-1", "tool_call")]],
|
|
]);
|
|
expect(messageListMapsEqual(first, second)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("reconcileRenderItems", () => {
|
|
const turnGroup = (id: string, messages: Message[]): RenderItem => ({
|
|
type: "turn_group",
|
|
id,
|
|
turnId: messages[0]?.turn_id ?? null,
|
|
messages,
|
|
});
|
|
const messageItem = (message: Message): RenderItem => ({ type: "message", message });
|
|
|
|
it("returns the prior array unchanged when nothing changed", () => {
|
|
const m1 = makeMessage("m1", "message");
|
|
const prev = [messageItem(m1)];
|
|
const next = [messageItem(m1)];
|
|
expect(reconcileRenderItems(prev, next)).toBe(prev);
|
|
});
|
|
|
|
it("reuses unchanged turn-group wrappers while replacing the changed one", () => {
|
|
const a1 = makeMessage("a1", "tool_call");
|
|
const a2 = makeMessage("a2", "tool_call");
|
|
const b1 = makeMessage("b1", "tool_call");
|
|
const b2 = makeMessage("b2", "tool_call");
|
|
const groupA = turnGroup("turn-group-a1", [a1, a2]);
|
|
const groupBPrev = turnGroup("turn-group-b1", [b1]);
|
|
const prev = [groupA, groupBPrev];
|
|
|
|
// groupA identical content (rebuilt wrapper); groupB grows by a token.
|
|
const groupBNext = turnGroup("turn-group-b1", [b1, b2]);
|
|
const next = [turnGroup("turn-group-a1", [a1, a2]), groupBNext];
|
|
|
|
const result = reconcileRenderItems(prev, next);
|
|
expect(result[0]).toBe(groupA); // stable → memo holds
|
|
expect(result[1]).toBe(groupBNext); // changed → fresh wrapper
|
|
});
|
|
|
|
it("keeps stable message wrappers and appends a new item", () => {
|
|
const m1 = makeMessage("m1", "message");
|
|
const prev = [messageItem(m1)];
|
|
const m2 = makeMessage("m2", "message");
|
|
const next = [messageItem(m1), messageItem(m2)];
|
|
const result = reconcileRenderItems(prev, next);
|
|
expect(result).not.toBe(prev);
|
|
expect(result[0]).toBe(prev[0]);
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
function subagentCall(id: string, subagentType: string, turnId = "turn-1"): Message {
|
|
return {
|
|
...makeMessage(id, "tool_call", {
|
|
status: "complete",
|
|
normalized: { kind: "subagent_task", subagent_task: { subagent_type: subagentType } },
|
|
}),
|
|
content: `${subagentType} on diff`,
|
|
turn_id: turnId,
|
|
};
|
|
}
|
|
|
|
// A subagent is another agent's turn, not a tool the agent used. Collapsing one
|
|
// into a turn group hid entire review waves behind a "4 tool calls, 2 subagents"
|
|
// row 50 messages up the transcript.
|
|
describe("buildGroupedRenderItems subagent hoisting", () => {
|
|
it("renders subagents at top level and groups only the remaining tool calls", () => {
|
|
const items = buildGroupedRenderItems(
|
|
[
|
|
toolExecute("t1"),
|
|
toolExecute("t2"),
|
|
subagentCall("s1", "code-reviewer"),
|
|
subagentCall("s2", "test-supervisor"),
|
|
],
|
|
"s1",
|
|
{ canAnchorPrepareProgress: false },
|
|
);
|
|
expect(items.map((i) => i.type)).toEqual(["turn_group", "message", "message"]);
|
|
const group = items[0] as Extract<RenderItem, { type: "turn_group" }>;
|
|
expect(group.messages.map((m) => m.id)).toEqual(["t1", "t2"]);
|
|
expect(items.slice(1).map((i) => (i as { message: Message }).message.id)).toEqual(["s1", "s2"]);
|
|
});
|
|
|
|
it("emits no group when a turn's only activity is subagents", () => {
|
|
const items = buildGroupedRenderItems(
|
|
[subagentCall("s1", "code-reviewer"), subagentCall("s2", "test-supervisor")],
|
|
"s1",
|
|
{ canAnchorPrepareProgress: false },
|
|
);
|
|
expect(items.map((i) => i.type)).toEqual(["message", "message"]);
|
|
});
|
|
|
|
it("keeps tool calls on both sides of a subagent grouped separately", () => {
|
|
const items = buildGroupedRenderItems(
|
|
[
|
|
toolExecute("t1"),
|
|
toolExecute("t2"),
|
|
subagentCall("s1", "code-reviewer"),
|
|
toolExecute("t3"),
|
|
toolExecute("t4"),
|
|
],
|
|
"s1",
|
|
{ canAnchorPrepareProgress: false },
|
|
);
|
|
expect(items.map((i) => i.type)).toEqual(["turn_group", "message", "turn_group"]);
|
|
});
|
|
});
|