Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

378 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type {
AgentMessageContentVM,
AgentThinkingContentVM
} from "../contracts/agentMessageRowVM";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import {
computeAgentToolGroups,
type AgentComputedToolGroupVM
} from "./agentToolGroupingProjection";
import type { AgentTurnSequenceItemVM } from "./agentTurnSequenceProjection";
import {
renderRun,
tagOf,
thinkingAbsorbedByMessage,
toRenderUnits,
projectTurnRows,
type AgentRenderUnit
} from "./agentTurnRowProjection";
const TURN = "turn-1";
function thinking(
id: string,
ts: number | null = null
): AgentThinkingContentVM {
return {
kind: "thinking-content",
id,
turnId: TURN,
body: id,
occurredAtUnixMs: ts
};
}
function message(id: string, ts: number | null = null): AgentMessageContentVM {
return {
kind: "message-content",
id,
turnId: TURN,
body: id,
presentationKind: "content",
occurredAtUnixMs: ts
};
}
function toolCall(id: string): AgentToolCallVM {
return {
kind: "tool-call",
id,
turnId: TURN,
rendererKind: "default",
summary: id,
compactSummary: null,
occurredAtUnixMs: null
} as AgentToolCallVM;
}
const thinkingItem = (id: string): AgentTurnSequenceItemVM => ({
kind: "thinking",
thinking: thinking(id)
});
const messageItem = (id: string): AgentTurnSequenceItemVM => ({
kind: "assistant-message",
message: message(id)
});
const userMessageItem = (id: string): AgentTurnSequenceItemVM => ({
kind: "user-message",
row: {
kind: "message",
id: `message:user:${id}`,
turnId: TURN,
speaker: "user",
messages: [message(id)],
thinking: [],
occurredAtUnixMs: null
}
});
const toolItem = (id: string): AgentTurnSequenceItemVM => ({
kind: "tool-call",
call: toolCall(id)
});
const noGroups = new Map<number, AgentComputedToolGroupVM>();
const noSkips = new Set<number>();
describe("tagOf", () => {
const table: { item: AgentTurnSequenceItemVM; expected: string }[] = [
{ item: userMessageItem("u"), expected: "user-message" },
{ item: messageItem("m"), expected: "message" },
{ item: thinkingItem("t"), expected: "thinking" },
{ item: toolItem("c"), expected: "tool" }
];
for (const { item, expected } of table) {
it(`classifies ${item.kind} as ${expected}`, () => {
expect(tagOf(item)).toBe(expected);
});
}
});
describe("thinkingAbsorbedByMessage", () => {
const u = {
thinking: { tag: "thinking", thinking: thinking("t") } as AgentRenderUnit,
message: { tag: "message", message: message("m") } as AgentRenderUnit,
tool: { tag: "tool", call: toolCall("c") } as AgentRenderUnit
};
const table: {
previous: AgentRenderUnit;
current: AgentRenderUnit;
expected: boolean;
}[] = [
{ previous: u.thinking, current: u.message, expected: true },
{ previous: u.thinking, current: u.thinking, expected: false },
{ previous: u.thinking, current: u.tool, expected: false },
{ previous: u.message, current: u.message, expected: false },
{ previous: u.tool, current: u.message, expected: false }
];
for (const { previous, current, expected } of table) {
it(`${previous.tag} -> ${current.tag} = ${expected}`, () => {
expect(thinkingAbsorbedByMessage(previous, current)).toBe(expected);
});
}
});
describe("toRenderUnits", () => {
it("tags message/thinking/tool items in order", () => {
const units = toRenderUnits(
[thinkingItem("t"), messageItem("m"), toolItem("c")],
noGroups,
noSkips
);
expect(units.map((unit) => unit.tag)).toEqual([
"thinking",
"message",
"tool"
]);
});
it("keeps user messages in the turn sequence", () => {
const units = toRenderUnits(
[messageItem("m1"), userMessageItem("u"), messageItem("m2")],
noGroups,
noSkips
);
expect(units.map((unit) => unit.tag)).toEqual([
"message",
"user-message",
"message"
]);
});
it("emits one unit per group and skips its span", () => {
const sequence = [toolItem("c0"), toolItem("c1"), messageItem("m")];
const group: AgentComputedToolGroupVM = {
startIndex: 0,
endIndex: 1,
calls: [toolCall("c0"), toolCall("c1")],
entries: []
};
const units = toRenderUnits(
sequence,
new Map([[0, group]]),
new Set([0, 1])
);
expect(units.map((unit) => unit.tag)).toEqual(["tool-group", "message"]);
});
it("drops suppressed indices so neighbours become adjacent", () => {
// thinking, <suppressed tool>, message -> thinking is now adjacent to message
const units = toRenderUnits(
[thinkingItem("t"), toolItem("c"), messageItem("m")],
noGroups,
new Set([1])
);
expect(units.map((unit) => unit.tag)).toEqual(["thinking", "message"]);
});
});
describe("computeAgentToolGroups", () => {
it("does not group tool calls across a mid-turn user message", () => {
const result = computeAgentToolGroups(
[
toolItem("c0"),
toolItem("c1"),
userMessageItem("u"),
toolItem("c2"),
toolItem("c3")
],
{}
);
expect(
[...result.groups.values()].map((group) =>
group.calls.map((call) => call.id)
)
).toEqual([
["c0", "c1"],
["c2", "c3"]
]);
});
});
describe("renderRun", () => {
it("renders a tool group row", () => {
const group: AgentComputedToolGroupVM = {
startIndex: 0,
endIndex: 1,
calls: [toolCall("c0"), toolCall("c1")],
entries: []
};
const row = renderRun([{ tag: "tool-group", group }], TURN);
expect(row.kind).toBe("tool-group");
expect(row.id).toBe(`tool-group:${TURN}:c0+c1`);
});
it("renders a single tool row", () => {
const row = renderRun([{ tag: "tool", call: toolCall("c") }], TURN);
expect(row.kind).toBe("tool-group");
expect(row.id).toBe("tool-row:c");
});
it("renders a lone thinking row carrying its thinking", () => {
const row = renderRun(
[{ tag: "thinking", thinking: thinking("t", 5) }],
TURN
);
expect(row).toMatchObject({
kind: "message",
id: `message:thinking:${TURN}:t`,
messages: [],
occurredAtUnixMs: 5
});
expect(
row.kind === "message" && row.thinking.map((entry) => entry.id)
).toEqual(["t"]);
});
it("renders a message row that absorbs preceding thinking", () => {
const row = renderRun(
[
{ tag: "thinking", thinking: thinking("t") },
{ tag: "message", message: message("m", 9) }
],
TURN
);
expect(row).toMatchObject({
kind: "message",
id: "message:assistant:m",
occurredAtUnixMs: 9
});
expect(
row.kind === "message" && row.thinking.map((entry) => entry.id)
).toEqual(["t"]);
expect(
row.kind === "message" && row.messages.map((entry) => entry.id)
).toEqual(["m"]);
});
it("renders a message row with empty thinking when none precedes it", () => {
const row = renderRun([{ tag: "message", message: message("m") }], TURN);
expect(row.kind === "message" && row.thinking).toEqual([]);
});
it("renders a user message row without changing its identity", () => {
const item = userMessageItem("u");
if (item.kind !== "user-message") throw new Error("expected user message");
expect(renderRun([{ tag: "user-message", row: item.row }], TURN)).toBe(
item.row
);
});
});
describe("projectTurnRows (behaviour preserved end-to-end)", () => {
const rowShape = (rows: ReturnType<typeof projectTurnRows>) =>
rows.map((row) => ({
kind: row.kind,
id: row.id,
thinking:
row.kind === "message"
? row.thinking.map((entry) => entry.id)
: undefined
}));
it("absorbs a thinking immediately followed by a message", () => {
const rows = projectTurnRows(
[thinkingItem("t"), messageItem("m")],
noGroups,
noSkips,
TURN
);
expect(rowShape(rows)).toEqual([
{ kind: "message", id: "message:assistant:m", thinking: ["t"] }
]);
});
it("only the immediately-preceding thinking is absorbed; earlier ones stand alone", () => {
const rows = projectTurnRows(
[thinkingItem("t1"), thinkingItem("t2"), messageItem("m")],
noGroups,
noSkips,
TURN
);
expect(rowShape(rows)).toEqual([
{ kind: "message", id: `message:thinking:${TURN}:t1`, thinking: ["t1"] },
{ kind: "message", id: "message:assistant:m", thinking: ["t2"] }
]);
});
it("a thinking followed by a tool becomes its own row", () => {
const rows = projectTurnRows(
[thinkingItem("t"), toolItem("c")],
noGroups,
noSkips,
TURN
);
expect(rowShape(rows)).toEqual([
{ kind: "message", id: `message:thinking:${TURN}:t`, thinking: ["t"] },
{ kind: "tool-group", id: "tool-row:c", thinking: undefined }
]);
});
it("a group between thinking and message breaks absorption", () => {
const sequence = [
thinkingItem("t"),
toolItem("c0"),
toolItem("c1"),
messageItem("m")
];
const group: AgentComputedToolGroupVM = {
startIndex: 1,
endIndex: 2,
calls: [toolCall("c0"), toolCall("c1")],
entries: []
};
const rows = projectTurnRows(
sequence,
new Map([[1, group]]),
new Set([1, 2]),
TURN
);
expect(rowShape(rows)).toEqual([
{ kind: "message", id: `message:thinking:${TURN}:t`, thinking: ["t"] },
{
kind: "tool-group",
id: `tool-group:${TURN}:c0+c1`,
thinking: undefined
},
{ kind: "message", id: "message:assistant:m", thinking: [] }
]);
});
it("a suppressed tool between thinking and message keeps absorption", () => {
const rows = projectTurnRows(
[thinkingItem("t"), toolItem("c"), messageItem("m")],
noGroups,
new Set([1]),
TURN
);
expect(rowShape(rows)).toEqual([
{ kind: "message", id: "message:assistant:m", thinking: ["t"] }
]);
});
it("a user message is a visible boundary between assistant rows", () => {
const rows = projectTurnRows(
[messageItem("m1"), userMessageItem("u"), messageItem("m2")],
noGroups,
noSkips,
TURN
);
expect(rows.map((row) => row.id)).toEqual([
"message:assistant:m1",
"message:user:u",
"message:assistant:m2"
]);
});
});