The collapsed tool group now reads "N tool calls · Label 'arg'" so the operator sees the current command at a glance. While a turn is active, the most recent agent text stays outside the group as visible narration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import type { ReducedState, TranscriptRow } from "../../types.js";
|
||
import {
|
||
activityGroupsByTurn,
|
||
applyCommand,
|
||
commandsFromTranscript,
|
||
commandTokenAt,
|
||
contentBlockText,
|
||
filterCommands,
|
||
isStateOnlyNotice,
|
||
offeredDecisions,
|
||
orderConfigOptions,
|
||
pendingApprovalRowId,
|
||
splitToolTitle,
|
||
statusMeta,
|
||
toolDiffMeta,
|
||
toolKindLabel,
|
||
transcriptItems,
|
||
turnMeta,
|
||
} from "./model.js";
|
||
|
||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||
return { id, kind, data };
|
||
}
|
||
|
||
function tool(id: string): TranscriptRow {
|
||
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
|
||
}
|
||
|
||
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
|
||
return row(id, kind, { content: { type: "text", text: body } });
|
||
}
|
||
|
||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||
|
||
describe("row taxonomy", () => {
|
||
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
|
||
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
|
||
expect(splitToolTitle("something opaque", null, "search")).toEqual({
|
||
label: "Search",
|
||
arg: "something opaque",
|
||
});
|
||
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
|
||
});
|
||
|
||
it("labels an unknown tool kind rather than leaving it blank", () => {
|
||
expect(toolKindLabel("execute")).toBe("Execute");
|
||
expect(toolKindLabel("no_such_kind")).toBe("Tool");
|
||
expect(toolKindLabel(undefined)).toBe("Tool");
|
||
});
|
||
|
||
it("names non-text content blocks instead of dropping them", () => {
|
||
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
|
||
expect(contentBlockText({ type: "image" })).toBe("[image]");
|
||
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
|
||
expect(contentBlockText(undefined)).toBe("");
|
||
});
|
||
|
||
it("counts a tool call's diff lines", () => {
|
||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||
.toBe("+2 −1");
|
||
expect(toolDiffMeta([])).toBe("+0 −0");
|
||
});
|
||
|
||
it("shows a status only while it is worth showing", () => {
|
||
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
|
||
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
|
||
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
|
||
expect(statusMeta("completed", false)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("notices", () => {
|
||
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
|
||
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
|
||
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
|
||
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
|
||
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
|
||
});
|
||
|
||
it("marks the state-only notices the client consumes rather than shows", () => {
|
||
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
|
||
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
|
||
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("approvals", () => {
|
||
it("offers only decisions the request actually backs", () => {
|
||
expect(
|
||
offeredDecisions([
|
||
{ optionId: "a", name: "Allow once", kind: "allow_once" },
|
||
{ optionId: "r", name: "Reject", kind: "reject_once" },
|
||
]),
|
||
).toEqual(["allow_once", "deny"]);
|
||
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
|
||
"allow_for_session",
|
||
]);
|
||
expect(offeredDecisions([])).toEqual([]);
|
||
});
|
||
|
||
it("points a live pending approval at the newest approval row", () => {
|
||
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
|
||
const pending: ReducedState = {
|
||
...idle,
|
||
pending_approval: { nonce: "n", request: {} as never },
|
||
};
|
||
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
|
||
expect(pendingApprovalRowId(rows, idle)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("activity grouping", () => {
|
||
it("groups a turn's tools once there are two of them", () => {
|
||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
|
||
const groups = activityGroupsByTurn(rows, null, false);
|
||
expect([...groups.keys()]).toEqual(["turn:user:1"]);
|
||
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
|
||
});
|
||
|
||
it("leaves a single tool call standalone", () => {
|
||
const rows = [text("user:1", "user", "go"), tool("tool:1")];
|
||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||
});
|
||
|
||
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||
const rows = [
|
||
text("user:1", "user", "go"),
|
||
tool("tool:1"),
|
||
text("agent:1", "agent_text", "working"),
|
||
tool("tool:2"),
|
||
text("agent:2", "agent_text", "done"),
|
||
];
|
||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||
expect(group.toolCount).toBe(2);
|
||
});
|
||
|
||
it("keeps the newest thought outside while the turn is still live", () => {
|
||
const rows = [
|
||
text("user:1", "user", "go"),
|
||
tool("tool:1"),
|
||
tool("tool:2"),
|
||
text("thought:1", "agent_thought", "hmm"),
|
||
text("thought:2", "agent_thought", "aha"),
|
||
];
|
||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||
});
|
||
|
||
it("keeps the newest agent_text outside while the turn is still live", () => {
|
||
const rows = [
|
||
text("user:1", "user", "go"),
|
||
tool("tool:1"),
|
||
text("agent:1", "agent_text", "working on it"),
|
||
tool("tool:2"),
|
||
text("agent:2", "agent_text", "almost done"),
|
||
];
|
||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||
// "almost done" stays outside as visible narration while the turn is live
|
||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||
// Once ended, the final agent_text stays outside as the answer
|
||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||
});
|
||
|
||
it("tracks the last tool's label and argument for the collapsed header", () => {
|
||
const rows = [
|
||
text("user:1", "user", "go"),
|
||
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
|
||
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
|
||
];
|
||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||
expect(group.lastToolLabel).toBe("Execute");
|
||
expect(group.lastToolArg).toBe("git status");
|
||
});
|
||
|
||
it("absorbs a resolved approval, never the pending one", () => {
|
||
const rows = [
|
||
text("user:1", "user", "go"),
|
||
tool("tool:1"),
|
||
row("approval:1", "approval"),
|
||
tool("tool:2"),
|
||
row("approval:2", "approval"),
|
||
tool("tool:3"),
|
||
];
|
||
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
|
||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
|
||
});
|
||
});
|
||
|
||
describe("transcript render list", () => {
|
||
it("renders a group once, at its first member's position", () => {
|
||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
|
||
const items = transcriptItems(rows, null, false);
|
||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
|
||
"user:1",
|
||
"turn:user:1",
|
||
"agent:1",
|
||
]);
|
||
});
|
||
|
||
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
|
||
const rows = [
|
||
text("user:1", "user", "go"),
|
||
row("plan", "plan", { entries: [] }),
|
||
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
|
||
row("approval:1", "approval"),
|
||
text("agent:1", "agent_text", "done"),
|
||
];
|
||
const items = transcriptItems(rows, null, false);
|
||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
|
||
});
|
||
|
||
it("keeps a turn footer only while it is the final item", () => {
|
||
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
|
||
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
|
||
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
|
||
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
|
||
"user:1",
|
||
"user:2",
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("composer commands", () => {
|
||
const notice = row("notice:9", "notice", {
|
||
sessionUpdate: "available_commands_update",
|
||
availableCommands: [
|
||
{ name: "compact", description: "Compact the\n conversation" },
|
||
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
|
||
{ name: 42 },
|
||
],
|
||
});
|
||
|
||
it("takes the newest available_commands_update, skipping malformed entries", () => {
|
||
const stale = row("notice:1", "notice", {
|
||
sessionUpdate: "available_commands_update",
|
||
availableCommands: [{ name: "old", description: "gone" }],
|
||
});
|
||
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
|
||
expect(commandsFromTranscript([])).toEqual([]);
|
||
});
|
||
|
||
it("only matches a /token under the caret", () => {
|
||
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
|
||
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
|
||
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
|
||
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
|
||
});
|
||
|
||
it("ranks prefix matches ahead of substring matches", () => {
|
||
const commands = commandsFromTranscript([notice]);
|
||
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
|
||
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
|
||
});
|
||
|
||
it("replaces only the token under the caret", () => {
|
||
const token = commandTokenAt("say /co now", 7, 7)!;
|
||
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
|
||
const trailing = commandTokenAt("/co", 3, 3)!;
|
||
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
|
||
});
|
||
});
|
||
|
||
describe("session config", () => {
|
||
it("orders the menu by category, unknown categories last", () => {
|
||
const options = [
|
||
{ id: "d", category: null },
|
||
{ id: "b", category: "thought_level" },
|
||
{ id: "a", category: "model" },
|
||
{ id: "c", category: "mode" },
|
||
];
|
||
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
|
||
});
|
||
});
|