mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): task-chat tool taxonomy — pill verbs/icons, Shift+Enter to send, baseline-aligned elapsed
- new shared tool-taxonomy.ts (family/icon/verb per raw ACP tool name,
mcp__ prefix collapse) used by the status pill, chat tool rows, and the
classic transcript's previously hardcoded Wrench sites
- deriveRunStatusLabel: tail tool_call now yields the taxonomy verb
("Searching", "Running a command") with "Tool · target" detail and
the raw toolName so the pill shows the family icon beside the pulse dot
- live status line: label/elapsed/detail/tokens share one baseline row
- TaskChatComposer: Shift+Enter posts, plain Enter inserts a newline
- tests: tool-taxonomy unit tests, deriveRunStatusLabel/toolDisplayName
tests, composer Enter-behavior tests updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67e0713a2a
commit
ced262a0da
11 files changed
+111
-812
No files matched your search
@@ -310,7 +310,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued
|
||||
? { label: "Queued", detail: "Waiting to start" }
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined }
|
||||
: deriveRunStatusLabel(entries);
|
||||
out.push({
|
||||
id: `${liveRun.id}:status`,
|
||||
@@ -318,6 +318,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
status: "running",
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,14 +36,18 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function pressEnter(textarea: HTMLTextAreaElement) {
|
||||
function pressEnter(textarea: HTMLTextAreaElement, shiftKey = false) {
|
||||
flushSync(() => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent("keydown", { key: "Enter", shiftKey, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pressShiftEnter(textarea: HTMLTextAreaElement) {
|
||||
pressEnter(textarea, true);
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
@@ -57,7 +61,7 @@ function sendButton() {
|
||||
}
|
||||
|
||||
describe("TaskChatComposer", () => {
|
||||
it("submits the trimmed body on Enter and clears the draft", async () => {
|
||||
it("submits the trimmed body on Shift+Enter and clears the draft", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
@@ -65,13 +69,25 @@ describe("TaskChatComposer", () => {
|
||||
setTextareaValue(input(), " hello there ");
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
|
||||
pressEnter(input());
|
||||
pressShiftEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
|
||||
expect(input().value).toBe("");
|
||||
});
|
||||
|
||||
it("does not submit on plain Enter (newline stays in the draft)", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
setTextareaValue(input(), "line one");
|
||||
pressEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("line one");
|
||||
});
|
||||
|
||||
it("cycles the pending mode with Shift+Tab and applies it on submit", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
const onWorkModeChange = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -92,7 +108,7 @@ describe("TaskChatComposer", () => {
|
||||
expect(chip.textContent).toContain("Plan");
|
||||
|
||||
setTextareaValue(input(), "do the plan");
|
||||
pressEnter(input());
|
||||
pressShiftEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
|
||||
@@ -111,7 +127,7 @@ describe("TaskChatComposer", () => {
|
||||
);
|
||||
|
||||
setTextareaValue(input(), "wake up");
|
||||
pressEnter(input());
|
||||
pressShiftEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
|
||||
|
||||
@@ -272,7 +272,9 @@ export function TaskChatComposer({
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
// Shift+Enter posts; plain Enter inserts a newline (Enter alone was
|
||||
// too easy to trip while drafting).
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
@@ -417,7 +419,7 @@ export function TaskChatComposer({
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || submitting || body.trim().length === 0}
|
||||
title="Send"
|
||||
title="Send (Shift+Enter)"
|
||||
aria-label="Send"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground"
|
||||
data-testid="task-chat-composer-send"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
function elapsedLabel(ms?: number): string | null {
|
||||
if (ms == null) return null;
|
||||
@@ -59,26 +60,32 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP
|
||||
|
||||
if (live) {
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : null;
|
||||
return (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
<span className="shimmer-text shimmer-text-muted font-medium">{item.label}…</span>
|
||||
{liveElapsed ? (
|
||||
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
|
||||
{liveElapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{item.detail ? (
|
||||
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
|
||||
) : null}
|
||||
{item.tokens ? (
|
||||
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
{ToolIcon ? <ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
{/* Text cells share a baseline row so the smaller mono readouts don't
|
||||
float above the label's baseline. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">{item.label}…</span>
|
||||
{liveElapsed ? (
|
||||
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
|
||||
{liveElapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{item.detail ? (
|
||||
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
|
||||
) : null}
|
||||
{item.tokens ? (
|
||||
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,19 +3,13 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Loader2,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
SquareTerminal,
|
||||
BookOpen,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { TaskChatToolItem } from "./task-chat-model";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
const STATUS_ICON = {
|
||||
pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" },
|
||||
@@ -24,17 +18,6 @@ const STATUS_ICON = {
|
||||
failed: { Icon: X, spin: false, tone: "text-destructive" },
|
||||
} as const;
|
||||
|
||||
/** Tool name → row glyph (v7 grammar: read/search/terminal/edit families). */
|
||||
function toolIcon(name: string): LucideIcon {
|
||||
const n = name.toLowerCase();
|
||||
if (n === "read" || n === "notebookread") return BookOpen;
|
||||
if (n === "grep" || n === "glob" || n === "search" || n.includes("search")) return FileSearch;
|
||||
if (n === "bash" || n === "shell" || n.includes("terminal") || n === "run") return SquareTerminal;
|
||||
if (n === "edit" || n === "write" || n === "notebookedit" || n === "multiedit") return Pencil;
|
||||
if (n.includes("fetch") || n.includes("web") || n.includes("http")) return Globe;
|
||||
return Wrench;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] …
|
||||
* [hover chevron] [status]. No card chrome — activity is metadata, not
|
||||
@@ -43,7 +26,7 @@ function toolIcon(name: string): LucideIcon {
|
||||
*/
|
||||
export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
const { Icon, spin, tone } = STATUS_ICON[item.status];
|
||||
const RowIcon = toolIcon(item.name);
|
||||
const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon;
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const expandable = Boolean(item.detail);
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "st-working", kind: "status", status: "working", label: "Using edit", detail: "server/src/routes/auth.ts", startedAtMs: Date.now() - 4200 },
|
||||
{ id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
|
||||
],
|
||||
};
|
||||
case "running":
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface TaskChatToolItem {
|
||||
id: string;
|
||||
kind: "tool";
|
||||
name: string;
|
||||
/** Raw ACP tool name pre-collapse ("mcp__server__tool") for taxonomy lookup. */
|
||||
rawName?: string;
|
||||
/** ACP ToolKind. */
|
||||
toolKind?: string;
|
||||
status: TaskChatToolStatus;
|
||||
@@ -126,6 +128,8 @@ export interface TaskChatStatusItem {
|
||||
| "truncated";
|
||||
label: string;
|
||||
detail?: string;
|
||||
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
|
||||
toolName?: string;
|
||||
elapsedMs?: number;
|
||||
/** Run start epoch ms; live states tick their own elapsed from this. */
|
||||
startedAtMs?: number;
|
||||
|
||||
@@ -1,38 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
ChevronsLeftRightEllipsis,
|
||||
MessageSquareReply,
|
||||
Network,
|
||||
Search,
|
||||
SearchCode,
|
||||
Terminal,
|
||||
Bot,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Pencil,
|
||||
Plug,
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { McpIcon } from "./McpIcon";
|
||||
import { isGenericToolName, mcpToolSegment, statusLabelIcon, toolTaxonomy } from "./tool-taxonomy";
|
||||
import { mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
describe("toolTaxonomy", () => {
|
||||
it("maps each family to its icon and verb", () => {
|
||||
expect(toolTaxonomy("Bash")).toEqual({
|
||||
family: "terminal",
|
||||
icon: Terminal,
|
||||
icon: SquareTerminal,
|
||||
verbLabel: "Running a command",
|
||||
});
|
||||
expect(toolTaxonomy("Shell").family).toBe("terminal");
|
||||
|
||||
expect(toolTaxonomy("Grep")).toEqual({
|
||||
family: "grep",
|
||||
icon: SearchCode,
|
||||
verbLabel: "Grepping",
|
||||
});
|
||||
|
||||
expect(toolTaxonomy("Glob")).toEqual({
|
||||
family: "search",
|
||||
icon: Search,
|
||||
icon: FileSearch,
|
||||
verbLabel: "Searching",
|
||||
});
|
||||
expect(toolTaxonomy("Glob").family).toBe("search");
|
||||
expect(toolTaxonomy("WebSearch").family).toBe("search");
|
||||
|
||||
expect(toolTaxonomy("Read")).toEqual({
|
||||
@@ -42,10 +35,9 @@ describe("toolTaxonomy", () => {
|
||||
});
|
||||
expect(toolTaxonomy("NotebookRead").family).toBe("read");
|
||||
|
||||
// Round-4 board feedback: edits share the terminal glyph.
|
||||
expect(toolTaxonomy("Edit")).toEqual({
|
||||
family: "edit",
|
||||
icon: Terminal,
|
||||
icon: Pencil,
|
||||
verbLabel: "Editing files",
|
||||
});
|
||||
expect(toolTaxonomy("Write").family).toBe("edit");
|
||||
@@ -54,22 +46,22 @@ describe("toolTaxonomy", () => {
|
||||
|
||||
expect(toolTaxonomy("WebFetch")).toEqual({
|
||||
family: "web",
|
||||
icon: ChevronsLeftRightEllipsis,
|
||||
icon: Globe,
|
||||
verbLabel: "Fetching the web",
|
||||
});
|
||||
|
||||
expect(toolTaxonomy("Task")).toEqual({
|
||||
family: "agent",
|
||||
icon: Network,
|
||||
icon: Bot,
|
||||
verbLabel: "Delegating",
|
||||
});
|
||||
expect(toolTaxonomy("Agent").family).toBe("agent");
|
||||
});
|
||||
|
||||
it("collapses mcp__ names to the MCP logo with the tool segment verb", () => {
|
||||
it("collapses mcp__ names to a Plug entry with the tool segment verb", () => {
|
||||
const entry = toolTaxonomy("mcp__linear-server__search_issues");
|
||||
expect(entry.family).toBe("mcp");
|
||||
expect(entry.icon).toBe(McpIcon);
|
||||
expect(entry.icon).toBe(Plug);
|
||||
expect(entry.verbLabel).toBe("Using Search_issues");
|
||||
});
|
||||
|
||||
@@ -85,47 +77,6 @@ describe("toolTaxonomy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolTaxonomy multi-word ACP titles", () => {
|
||||
it("classifies by the first word", () => {
|
||||
expect(toolTaxonomy("Read File").icon).toBe(BookOpen);
|
||||
expect(toolTaxonomy("Edit File").icon).toBe(Terminal);
|
||||
expect(toolTaxonomy("Write File").icon).toBe(Terminal);
|
||||
expect(toolTaxonomy("Terminal").icon).toBe(Terminal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusLabelIcon", () => {
|
||||
it("gives the tool-free informative statuses their glyphs", () => {
|
||||
expect(statusLabelIcon("Thinking")).toBe(Brain);
|
||||
expect(statusLabelIcon("Responding")).toBe(MessageSquareReply);
|
||||
expect(statusLabelIcon("Responding (streaming)")).toBe(MessageSquareReply);
|
||||
});
|
||||
|
||||
it("leaves whimsified and generic labels glyph-free", () => {
|
||||
for (const label of ["Running", "Working", "Clipping", "Brewing", "Queued", "", undefined, null]) {
|
||||
expect(statusLabelIcon(label)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGenericToolName", () => {
|
||||
it("flags acpx placeholder names, including status-suffixed variants", () => {
|
||||
expect(isGenericToolName("tool call")).toBe(true);
|
||||
expect(isGenericToolName("tool call (completed)")).toBe(true);
|
||||
expect(isGenericToolName("Tool Call (failed)")).toBe(true);
|
||||
expect(isGenericToolName("acp_tool")).toBe(true);
|
||||
expect(isGenericToolName("tool")).toBe(true);
|
||||
expect(isGenericToolName("")).toBe(true);
|
||||
expect(isGenericToolName(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps real names", () => {
|
||||
expect(isGenericToolName("Terminal")).toBe(false);
|
||||
expect(isGenericToolName("Read")).toBe(false);
|
||||
expect(isGenericToolName("mcp__linear__search_issues")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpToolSegment", () => {
|
||||
it("extracts and capitalizes the tool segment", () => {
|
||||
expect(mcpToolSegment("mcp__linear-server__search_issues")).toBe("Search_issues");
|
||||
|
||||
@@ -7,24 +7,18 @@
|
||||
*/
|
||||
import {
|
||||
BookOpen,
|
||||
Brain,
|
||||
ChevronsLeftRightEllipsis,
|
||||
MessageSquareReply,
|
||||
Network,
|
||||
Search,
|
||||
SearchCode,
|
||||
Terminal,
|
||||
Bot,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Pencil,
|
||||
Plug,
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { McpIcon } from "./McpIcon";
|
||||
|
||||
/** Lucide icons and hand-rolled SVGs (the MCP logo) share this shape. */
|
||||
export type ToolIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type ToolFamily =
|
||||
| "terminal"
|
||||
| "grep"
|
||||
| "search"
|
||||
| "read"
|
||||
| "edit"
|
||||
@@ -35,27 +29,11 @@ export type ToolFamily =
|
||||
|
||||
export interface ToolTaxonomyEntry {
|
||||
family: ToolFamily;
|
||||
icon: ToolIcon;
|
||||
icon: LucideIcon;
|
||||
/** Progressive verb for the status pill, without the trailing ellipsis. */
|
||||
verbLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder names that carry no tool identity. acpx fills a tool_call_update
|
||||
* whose ACP payload omits `title` with the literal "tool call", and older
|
||||
* stored logs carry "tool call (completed)" / "acp_tool" variants — none of
|
||||
* these should ever displace a real name like "Terminal" or "Read".
|
||||
*/
|
||||
const GENERIC_TOOL_NAMES = new Set(["tool", "tool call", "tool_call", "acp_tool"]);
|
||||
|
||||
export function isGenericToolName(name: string | undefined | null): boolean {
|
||||
const raw = (name ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:pending|in_progress|completed|failed|cancelled)\)$/, "");
|
||||
return !raw || GENERIC_TOOL_NAMES.has(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse an MCP tool name ("mcp__server__tool") to its tool segment,
|
||||
* capitalized — the same rule toolDisplayName() applies. Returns null for
|
||||
@@ -69,34 +47,22 @@ export function mcpToolSegment(name: string): string | null {
|
||||
}
|
||||
|
||||
const FAMILY_META: Record<Exclude<ToolFamily, "mcp">, Omit<ToolTaxonomyEntry, "family">> = {
|
||||
terminal: { icon: Terminal, verbLabel: "Running a command" },
|
||||
grep: { icon: SearchCode, verbLabel: "Grepping" },
|
||||
search: { icon: Search, verbLabel: "Searching" },
|
||||
terminal: { icon: SquareTerminal, verbLabel: "Running a command" },
|
||||
search: { icon: FileSearch, verbLabel: "Searching" },
|
||||
read: { icon: BookOpen, verbLabel: "Reading files" },
|
||||
// Board round-4 feedback asked for the terminal glyph on edits as well.
|
||||
edit: { icon: Terminal, verbLabel: "Editing files" },
|
||||
web: { icon: ChevronsLeftRightEllipsis, verbLabel: "Fetching the web" },
|
||||
agent: { icon: Network, verbLabel: "Delegating" },
|
||||
edit: { icon: Pencil, verbLabel: "Editing files" },
|
||||
web: { icon: Globe, verbLabel: "Fetching the web" },
|
||||
agent: { icon: Bot, verbLabel: "Delegating" },
|
||||
other: { icon: Wrench, verbLabel: "Working" },
|
||||
};
|
||||
|
||||
function classify(n: string): Exclude<ToolFamily, "mcp"> {
|
||||
// ACP titles are often multi-word ("Read File", "Edit File") — the first
|
||||
// word carries the family.
|
||||
const first = n.split(/[\s_:-]+/, 1)[0] ?? "";
|
||||
if (first === "read" || n === "notebookread") return "read";
|
||||
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
|
||||
return "edit";
|
||||
}
|
||||
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
|
||||
return "terminal";
|
||||
}
|
||||
// Content grep is its own action (search-code glyph); file-name globbing and
|
||||
// generic searches stay under the plain Search family.
|
||||
if (first === "grep") return "grep";
|
||||
if (first === "glob" || n.includes("search")) return "search";
|
||||
if (n === "read" || n === "notebookread") return "read";
|
||||
if (n === "edit" || n === "write" || n === "multiedit" || n === "notebookedit") return "edit";
|
||||
if (n === "bash" || n === "shell" || n === "run" || n.includes("terminal")) return "terminal";
|
||||
if (n === "grep" || n === "glob" || n.includes("search")) return "search";
|
||||
if (n.includes("fetch") || n.includes("web") || n.includes("http")) return "web";
|
||||
if (first === "task" || first === "agent") return "agent";
|
||||
if (n === "task" || n === "agent") return "agent";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -105,19 +71,7 @@ export function toolTaxonomy(name: string | undefined | null): ToolTaxonomyEntry
|
||||
const raw = (name ?? "").trim();
|
||||
if (!raw) return { family: "other", ...FAMILY_META.other };
|
||||
const mcpTool = mcpToolSegment(raw);
|
||||
if (mcpTool) return { family: "mcp", icon: McpIcon, verbLabel: `Using ${mcpTool}` };
|
||||
if (mcpTool) return { family: "mcp", icon: Plug, verbLabel: `Using ${mcpTool}` };
|
||||
const family = classify(raw.toLowerCase());
|
||||
return { family, ...FAMILY_META[family] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Icons for the tool-free informative statuses ("Thinking", "Responding",
|
||||
* "Responding (streaming)"). Whimsified and generic labels get no glyph — the
|
||||
* pulse dot alone carries those.
|
||||
*/
|
||||
export function statusLabelIcon(label: string | undefined | null): ToolIcon | null {
|
||||
const raw = (label ?? "").trim().toLowerCase();
|
||||
if (raw === "thinking") return Brain;
|
||||
if (raw === "responding" || raw.startsWith("responding (")) return MessageSquareReply;
|
||||
return null;
|
||||
}
|
||||
@@ -1,23 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
attachSettledTurns,
|
||||
buildMergedTurnSummary,
|
||||
buildTurnSummary,
|
||||
coalesceSettledTurns,
|
||||
deriveRunStatusLabel,
|
||||
flattenSelfTalk,
|
||||
isNestableLiveChild,
|
||||
ISSUE_BRIEF_ITEM_ID,
|
||||
prependIssueBrief,
|
||||
settledRunChildren,
|
||||
toolDisplayName,
|
||||
transcriptToTaskChatItems,
|
||||
type SettledTurnMergeMeta,
|
||||
type ThreadBackboneEntry,
|
||||
} from "./transcript-adapter";
|
||||
import type { TaskChatItem, TaskChatTurnItem } from "./task-chat-model";
|
||||
import { deriveRunStatusLabel, toolDisplayName } from "./transcript-adapter";
|
||||
|
||||
const TS = "2026-07-31T12:00:00.000Z";
|
||||
|
||||
@@ -28,7 +11,7 @@ function toolCall(name: string, input?: unknown): TranscriptEntry {
|
||||
describe("deriveRunStatusLabel", () => {
|
||||
it("labels a tail tool_call with the taxonomy verb and tool · target detail", () => {
|
||||
const status = deriveRunStatusLabel([toolCall("Grep", { pattern: "ui/src/components" })]);
|
||||
expect(status.label).toBe("Grepping");
|
||||
expect(status.label).toBe("Searching");
|
||||
expect(status.detail).toBe("Grep · ui/src/components");
|
||||
expect(status.toolName).toBe("Grep");
|
||||
});
|
||||
@@ -75,620 +58,4 @@ describe("toolDisplayName", () => {
|
||||
expect(toolDisplayName("")).toBe("Tool");
|
||||
expect(toolDisplayName("tool")).toBe("Tool");
|
||||
});
|
||||
|
||||
it("maps acpx placeholder names to the generic Tool label", () => {
|
||||
expect(toolDisplayName("tool call")).toBe("Tool");
|
||||
expect(toolDisplayName("tool call (completed)")).toBe("Tool");
|
||||
expect(toolDisplayName("acp_tool")).toBe("Tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcriptToTaskChatItems tool_call updates", () => {
|
||||
const opts = { runId: "run-1", running: false };
|
||||
|
||||
function update(toolUseId: string, status: string): TranscriptEntry {
|
||||
// Mirrors what a persisted acpx tool_call_update line parses to: the
|
||||
// literal placeholder name plus a synthesized { text, status } input.
|
||||
return {
|
||||
kind: "tool_call",
|
||||
ts: TS,
|
||||
name: "tool call",
|
||||
toolUseId,
|
||||
input: { text: `tool call (${status})`, status },
|
||||
} as TranscriptEntry;
|
||||
}
|
||||
|
||||
it("keeps the initial real name and target when a generic update arrives", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
toolCall("Terminal", { command: "pnpm test" }),
|
||||
update("tool-Terminal", "completed"),
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: TS,
|
||||
toolUseId: "tool-Terminal",
|
||||
toolName: "tool call",
|
||||
content: "ok",
|
||||
} as TranscriptEntry,
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
const tool = items[0];
|
||||
expect(tool.kind).toBe("tool");
|
||||
if (tool.kind !== "tool") return;
|
||||
expect(tool.name).toBe("Terminal");
|
||||
expect(tool.rawName).toBe("Terminal");
|
||||
expect(tool.target).toBe("pnpm test");
|
||||
expect(tool.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps identity on retitle and uses the invocation as the target", () => {
|
||||
// Real stored sequence: "Terminal" (pending) → retitle to the command →
|
||||
// generic "tool call" completion updates.
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-2" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "ls -la", toolUseId: "tc-2" } as TranscriptEntry,
|
||||
update("tc-2", "completed"),
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0].kind !== "tool") return;
|
||||
expect(items[0].name).toBe("Terminal");
|
||||
expect(items[0].rawName).toBe("Terminal");
|
||||
expect(items[0].target).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("never renders the synthesized { text, status } input as a target", () => {
|
||||
const items = transcriptToTaskChatItems([update("tc-orphan", "pending")], opts);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0].kind !== "tool") return;
|
||||
expect(items[0].target).toBeUndefined();
|
||||
});
|
||||
|
||||
it("upgrades a generic-named call when a later entry carries the real name", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "tool_call", ts: TS, name: "tool call", toolUseId: "tc-1" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "tc-1" } as TranscriptEntry,
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0].kind !== "tool") return;
|
||||
expect(items[0].name).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTurnSummary tool counting", () => {
|
||||
function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
ts: TS,
|
||||
name: "Bash",
|
||||
toolUseId,
|
||||
input: { text: `tool call (${status})`, status },
|
||||
} as TranscriptEntry;
|
||||
}
|
||||
|
||||
it("counts unique tool calls, not per-status transcript entries", () => {
|
||||
// 4 real calls × 4 status changes each = 16 entries; the summary must
|
||||
// match the 4 rows the expanded list renders.
|
||||
const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) =>
|
||||
["pending", "in_progress", "in_progress", "completed"].map((status) =>
|
||||
statusEntry(id, status),
|
||||
),
|
||||
);
|
||||
expect(entries).toHaveLength(16);
|
||||
expect(buildTurnSummary(entries).toolCount).toBe(4);
|
||||
});
|
||||
|
||||
it("counts id-less legacy entries once each", () => {
|
||||
const entries = [
|
||||
statusEntry(undefined, "completed"),
|
||||
statusEntry(undefined, "completed"),
|
||||
toolCall("Read"),
|
||||
];
|
||||
expect(buildTurnSummary(entries).toolCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunStatusLabel with generic tail updates", () => {
|
||||
it("recovers the real tool name from the call's initial entry", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
toolCall("Terminal", { command: "ls -la" }),
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: TS,
|
||||
name: "tool call",
|
||||
toolUseId: "tool-Terminal",
|
||||
} as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Running a command");
|
||||
expect(status.toolName).toBe("Terminal");
|
||||
});
|
||||
|
||||
it("treats a tail retitle as the invocation, not the identity", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-3" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "pnpm test", toolUseId: "tc-3" } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Running a command");
|
||||
expect(status.toolName).toBe("Terminal");
|
||||
expect(status.detail).toBe("Terminal · pnpm test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNestableLiveChild", () => {
|
||||
const items: TaskChatItem[] = [
|
||||
{ id: "t", kind: "tool", name: "Read", status: "completed" },
|
||||
{ id: "th", kind: "thinking", lines: ["hm"] },
|
||||
{ id: "u", kind: "usage", usage: { used: 1, size: 2 } },
|
||||
{ id: "m", kind: "message", author: "agent", text: "finished self-talk", interstitial: true },
|
||||
{ id: "mk", kind: "marker", variant: "session_start", label: "Session started" },
|
||||
{ id: "s", kind: "status", status: "running", label: "Running" },
|
||||
{ id: "i", kind: "interaction", interaction: {} as never },
|
||||
];
|
||||
|
||||
it("nests only tool and usage rows inside the live parent row (PAP-361)", () => {
|
||||
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
|
||||
"tool",
|
||||
"usage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps messages, thinking, markers, statuses and interactions out", () => {
|
||||
// Interstitial updates — finished or streaming — never nest: they are
|
||||
// ephemeral, living on the live line only while streaming. Thinking never
|
||||
// nests either (its live signal is the pill's "Thinking…" state), and the
|
||||
// final reply keeps its bubble.
|
||||
for (const kind of ["message", "thinking", "marker", "status", "interaction"]) {
|
||||
const item = items.find((it) => it.kind === kind)!;
|
||||
expect(isNestableLiveChild(item)).toBe(false);
|
||||
}
|
||||
expect(
|
||||
isNestableLiveChild({
|
||||
id: "m2",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
text: "typing…",
|
||||
interstitial: true,
|
||||
streaming: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isNestableLiveChild({ id: "m3", kind: "message", author: "agent", text: "Final reply." }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interstitial self-talk classification (PAP-355)", () => {
|
||||
const stream: TranscriptEntry[] = [
|
||||
{ kind: "assistant", ts: TS, text: "Let me check the adapter." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
{ kind: "assistant", ts: "2026-07-31T12:00:05.000Z", text: "Found it — fixing now." } as TranscriptEntry,
|
||||
];
|
||||
|
||||
it("tags agent messages streamed inside a live run turn as interstitial", () => {
|
||||
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }).filter(
|
||||
(it) => it.kind === "message",
|
||||
);
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "message") continue;
|
||||
expect(m.interstitial).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks only the message still open at the tail as streaming", () => {
|
||||
const items = transcriptToTaskChatItems(stream, { runId: "run-1", running: true });
|
||||
const messages = items.filter((it) => it.kind === "message");
|
||||
if (messages[0].kind !== "message" || messages[1].kind !== "message") return;
|
||||
// The first message was closed by the tool call — finished self-talk.
|
||||
expect(messages[0].streaming).toBe(false);
|
||||
expect(messages[1].streaming).toBe(true);
|
||||
});
|
||||
|
||||
it("marks no message streaming when the tail is a tool call", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[...stream, toolCall("Bash", { command: "pnpm test" })],
|
||||
{ runId: "run-1", running: true },
|
||||
);
|
||||
for (const m of items) {
|
||||
if (m.kind === "message") expect(m.streaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("tags settled-history messages interstitial too, so live and history agree", () => {
|
||||
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter(
|
||||
(it) => it.kind === "message",
|
||||
);
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "message") continue;
|
||||
expect(m.interstitial).toBe(true);
|
||||
expect(m.streaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("stamps atMs from the first streamed chunk", () => {
|
||||
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter(
|
||||
(it) => it.kind === "message",
|
||||
);
|
||||
if (messages[0].kind !== "message" || messages[1].kind !== "message") return;
|
||||
expect(messages[0].atMs).toBe(Date.parse(TS));
|
||||
expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenSelfTalk", () => {
|
||||
it("strips markdown markers to one plain line", () => {
|
||||
expect(
|
||||
flattenSelfTalk("## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it"),
|
||||
).toBe("Plan I'll extend the ipRateLimit helper: read it patch it");
|
||||
});
|
||||
|
||||
it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => {
|
||||
expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe("see the docs for bol");
|
||||
expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => {
|
||||
it("returns the trailing message's flattened text with the Responding label", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "assistant", ts: TS, text: "I'll **extend** " } as TranscriptEntry,
|
||||
{ kind: "assistant", ts: TS, text: "the `ipRateLimit` helper." } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Responding");
|
||||
expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper.");
|
||||
expect(status.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("only accumulates the trailing message, not one closed by a tool call", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
{ kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.selfTalk).toBe("Fresh line.");
|
||||
});
|
||||
|
||||
it("carries no selfTalk for tool or thinking tails", () => {
|
||||
expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined();
|
||||
expect(
|
||||
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).selfTalk,
|
||||
).toBeUndefined();
|
||||
expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settledRunChildren (PAP-361)", () => {
|
||||
const transcript: TranscriptEntry[] = [
|
||||
{ kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
{ kind: "thinking", ts: TS, text: "wire it in" } as TranscriptEntry,
|
||||
toolCall("Edit", { file_path: "a.ts" }),
|
||||
{ kind: "assistant", ts: TS, text: "Done — the limiter is wired in." } as TranscriptEntry,
|
||||
];
|
||||
const parsed = transcriptToTaskChatItems(transcript, { runId: "run-1", running: false });
|
||||
|
||||
it("keeps exactly the tool rows — messages AND thinking are excluded", () => {
|
||||
const children = settledRunChildren(parsed);
|
||||
expect(children.map((c) => c.kind)).toEqual(["tool", "tool"]);
|
||||
});
|
||||
|
||||
it("matches the folded summary's tool count exactly (row-count parity)", () => {
|
||||
const children = settledRunChildren(parsed);
|
||||
const summary = buildTurnSummary(transcript);
|
||||
expect(children.filter((c) => c.kind === "tool")).toHaveLength(summary.toolCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMergedTurnSummary (PAP-362)", () => {
|
||||
const runA: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: "2026-07-31T12:00:05.000Z", name: "Edit", toolUseId: "a-2" } as TranscriptEntry,
|
||||
{ kind: "diff", ts: "2026-07-31T12:00:06.000Z", changeType: "add", text: "+x" } as TranscriptEntry,
|
||||
{ kind: "result", ts: "2026-07-31T12:00:10.000Z", inputTokens: 500, outputTokens: 300 } as TranscriptEntry,
|
||||
];
|
||||
const runB: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: "2026-07-31T12:30:00.000Z", name: "Bash", toolUseId: "b-1" } as TranscriptEntry,
|
||||
{ kind: "result", ts: "2026-07-31T12:30:05.000Z", inputTokens: 300, outputTokens: 100 } as TranscriptEntry,
|
||||
];
|
||||
|
||||
it("sums tool count, diffs, tokens, and duration across runs", () => {
|
||||
const summary = buildMergedTurnSummary([
|
||||
{ entries: runA, durationMs: 30_000 },
|
||||
{ entries: runB, durationMs: 15_000 },
|
||||
]);
|
||||
expect(summary.toolCount).toBe(3);
|
||||
expect(summary.added).toBe(1);
|
||||
expect(summary.tokensLabel).toBe("1.2k tokens");
|
||||
expect(summary.durationLabel).toBe("45s");
|
||||
expect(summary.failed).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sums per-run ts spans — never the idle gap between runs", () => {
|
||||
// runA spans 10s, runB spans 5s, the gap between them is ~30min.
|
||||
const summary = buildMergedTurnSummary([{ entries: runA }, { entries: runB }]);
|
||||
expect(summary.durationLabel).toBe("15s");
|
||||
});
|
||||
|
||||
it("marks the merged turn failed when any run failed", () => {
|
||||
const summary = buildMergedTurnSummary([
|
||||
{ entries: runA, durationMs: 1000 },
|
||||
{ entries: runB, durationMs: 1000, failed: true },
|
||||
]);
|
||||
expect(summary.failed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coalesceSettledTurns (PAP-362)", () => {
|
||||
const runA: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "a-2" } as TranscriptEntry,
|
||||
];
|
||||
const runB: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "b-1" } as TranscriptEntry,
|
||||
];
|
||||
|
||||
function agentBubble(id: string): TaskChatItem {
|
||||
return { id, kind: "message", author: "agent", authorName: "CEO", text: "Done." };
|
||||
}
|
||||
function turn(id: string, entries: TranscriptEntry[], durationMs: number): TaskChatItem {
|
||||
return {
|
||||
id,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
items: entries.map((e, i) => ({
|
||||
id: `${id}:tool:${i}`,
|
||||
kind: "tool" as const,
|
||||
name: (e as { name: string }).name,
|
||||
status: "completed" as const,
|
||||
})),
|
||||
summary: buildTurnSummary(entries, { durationMs }),
|
||||
};
|
||||
}
|
||||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||||
["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }],
|
||||
["run-b:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runB, durationMs: 15_000 }] }],
|
||||
]);
|
||||
|
||||
it("merges two back-to-back runs into ONE Worked row summing tools and duration", () => {
|
||||
// Two terminal runs, same agent, consecutive comments, no user message
|
||||
// between → the merged turn sits below the LAST bubble.
|
||||
const items: TaskChatItem[] = [
|
||||
agentBubble("msg-a"),
|
||||
turn("run-a:turn", runA, 30_000),
|
||||
agentBubble("msg-b"),
|
||||
turn("run-b:turn", runB, 15_000),
|
||||
];
|
||||
const out = coalesceSettledTurns(items, metaById);
|
||||
expect(out.map((i) => i.kind)).toEqual(["message", "message", "turn"]);
|
||||
const merged = out[2] as Extract<TaskChatItem, { kind: "turn" }>;
|
||||
expect(merged.id).toBe("run-a:turn"); // stable: keeps the first run's id
|
||||
expect(merged.summary.toolCount).toBe(3);
|
||||
expect(merged.summary.durationLabel).toBe("45s");
|
||||
expect(merged.items.map((c) => c.id)).toEqual([
|
||||
"run-a:turn:tool:0",
|
||||
"run-a:turn:tool:1",
|
||||
"run-b:turn:tool:0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps two rows when a user message sits between the runs", () => {
|
||||
const items: TaskChatItem[] = [
|
||||
agentBubble("msg-a"),
|
||||
turn("run-a:turn", runA, 30_000),
|
||||
{ id: "msg-u", kind: "message", author: "human", text: "also do X" },
|
||||
agentBubble("msg-b"),
|
||||
turn("run-b:turn", runB, 15_000),
|
||||
];
|
||||
const out = coalesceSettledTurns(items, metaById);
|
||||
expect(out.filter((i) => i.kind === "turn")).toHaveLength(2);
|
||||
expect(out.map((i) => i.id)).toEqual(["msg-a", "run-a:turn", "msg-u", "msg-b", "run-b:turn"]);
|
||||
});
|
||||
|
||||
it("does not merge across an interaction or into another agent's turn", () => {
|
||||
const withInteraction: TaskChatItem[] = [
|
||||
turn("run-a:turn", runA, 30_000),
|
||||
{ id: "int-1", kind: "interaction", interaction: {} } as TaskChatItem,
|
||||
turn("run-b:turn", runB, 15_000),
|
||||
];
|
||||
expect(coalesceSettledTurns(withInteraction, metaById).filter((i) => i.kind === "turn")).toHaveLength(2);
|
||||
|
||||
const otherAgent = new Map(metaById);
|
||||
otherAgent.set("run-b:turn", { agentKey: "agent-2", agentName: "QA", parts: [{ entries: runB }] });
|
||||
const sameShape: TaskChatItem[] = [turn("run-a:turn", runA, 30_000), turn("run-b:turn", runB, 15_000)];
|
||||
expect(coalesceSettledTurns(sameShape, otherAgent).filter((i) => i.kind === "turn")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never merges the live (unsettled) turn and preserves animateFold", () => {
|
||||
const live: TaskChatItem = { id: "run-b:turn", kind: "turn", settled: false, items: [], summary: buildTurnSummary([]) };
|
||||
const out = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), live], metaById);
|
||||
expect(out).toHaveLength(2);
|
||||
|
||||
const seenLive = { ...turn("run-b:turn", runB, 15_000), animateFold: true } as TaskChatItem;
|
||||
const merged = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), seenLive], metaById);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect((merged[0] as Extract<TaskChatItem, { kind: "turn" }>).animateFold).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachSettledTurns (round 9)", () => {
|
||||
const runA: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||||
];
|
||||
|
||||
function agentBubble(id: string, authorName = "CEO"): TaskChatItem {
|
||||
return { id, kind: "message", author: "agent", authorName, text: "Done." };
|
||||
}
|
||||
function settledTurn(id: string): TaskChatItem {
|
||||
return {
|
||||
id,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||||
summary: buildTurnSummary(runA, { durationMs: 30_000 }),
|
||||
};
|
||||
}
|
||||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||||
["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }],
|
||||
]);
|
||||
|
||||
it("folds a settled turn into the agent reply bubble directly above it", () => {
|
||||
const out = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-a:turn")], metaById);
|
||||
expect(out).toHaveLength(1);
|
||||
const bubble = out[0] as Extract<TaskChatItem, { kind: "message" }>;
|
||||
expect(bubble.id).toBe("msg-a");
|
||||
expect(bubble.attachedTurn?.id).toBe("run-a:turn");
|
||||
expect(bubble.attachedTurn?.summary.durationLabel).toBe("30s");
|
||||
});
|
||||
|
||||
it("keeps the standalone row when the preceding item is not that agent's bubble", () => {
|
||||
// Human message directly above.
|
||||
const afterHuman = attachSettledTurns(
|
||||
[{ id: "msg-u", kind: "message", author: "human", text: "hi" }, settledTurn("run-a:turn")],
|
||||
metaById,
|
||||
);
|
||||
expect(afterHuman.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
// Another agent's bubble above.
|
||||
const otherAgent = attachSettledTurns([agentBubble("msg-q", "QA"), settledTurn("run-a:turn")], metaById);
|
||||
expect(otherAgent.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
// No meta (unknown identity) → conservative standalone fallback.
|
||||
const noMeta = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-x:turn")], metaById);
|
||||
expect(noMeta.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
// Nothing above at all.
|
||||
const first = attachSettledTurns([settledTurn("run-a:turn")], metaById);
|
||||
expect(first.map((i) => i.kind)).toEqual(["turn"]);
|
||||
});
|
||||
|
||||
it("never attaches the live (unsettled) turn and attaches at most one turn per bubble", () => {
|
||||
const live: TaskChatItem = {
|
||||
id: "run-live:turn",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
items: [],
|
||||
summary: buildTurnSummary([]),
|
||||
};
|
||||
const out = attachSettledTurns([agentBubble("msg-a"), live], metaById);
|
||||
expect(out.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
|
||||
// A second settled turn behind an already-attached bubble stays standalone
|
||||
// (coalesceSettledTurns runs first, so same-agent turns arrive pre-merged).
|
||||
const twice = attachSettledTurns(
|
||||
[agentBubble("msg-a"), settledTurn("run-a:turn"), settledTurn("run-a:turn")],
|
||||
metaById,
|
||||
);
|
||||
expect(twice.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
expect((twice[0] as Extract<TaskChatItem, { kind: "message" }>).attachedTurn?.id).toBe("run-a:turn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleThreadItems (PAP-367)", () => {
|
||||
function entry(id: string, ms: number, item?: Partial<TaskChatItem>): ThreadBackboneEntry {
|
||||
return {
|
||||
ms,
|
||||
id,
|
||||
item: { id, kind: "message", author: "human", text: `msg ${id}`, ...item } as TaskChatItem,
|
||||
};
|
||||
}
|
||||
function settledTurn(id: string): TaskChatTurnItem {
|
||||
return {
|
||||
id,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||||
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
|
||||
};
|
||||
}
|
||||
const noAnchors = new Map<string, TaskChatTurnItem[]>();
|
||||
|
||||
it("inserts a comment-less settled turn chronologically at its run's start time", () => {
|
||||
const backbone = [entry("u1", 1_000), entry("a1", 2_000, { author: "agent", authorName: "CEO" }), entry("u2", 3_000)];
|
||||
const out = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 2_500 }]);
|
||||
expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-x:turn", "u2"]);
|
||||
});
|
||||
|
||||
it("does not append a comment-less turn after the newest user message (the P11 bug)", () => {
|
||||
// The screenshot bug: an OLD stopped run (start 1_500) re-pinning its
|
||||
// "Worked · 34s" row under every NEW user bubble (3_000).
|
||||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||||
const out = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 1_500 }]);
|
||||
expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]);
|
||||
expect(out[out.length - 1].id).toBe("u2");
|
||||
});
|
||||
|
||||
it("keeps anchored turns directly after their run's reply comment", () => {
|
||||
const backbone = [entry("u1", 1_000), entry("a1", 2_000, { author: "agent", authorName: "CEO" }), entry("u2", 3_000)];
|
||||
const anchors = new Map([["a1", [settledTurn("run-a:turn")]]]);
|
||||
const out = assembleThreadItems(backbone, anchors, []);
|
||||
expect(out.map((i) => i.id)).toEqual(["u1", "a1", "run-a:turn", "u2"]);
|
||||
});
|
||||
|
||||
it("keeps the tail slot for a turn with unknown start, and puts ties after the entry", () => {
|
||||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||||
// Unknown start (no linked meta yet — just-settled run): Infinity → tail.
|
||||
const tail = assembleThreadItems(backbone, noAnchors, [
|
||||
{ turn: settledTurn("run-x:turn"), startMs: Number.POSITIVE_INFINITY },
|
||||
]);
|
||||
expect(tail.map((i) => i.id)).toEqual(["u1", "u2", "run-x:turn"]);
|
||||
// Tie with the trigger comment: comment renders first.
|
||||
const tie = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-y:turn"), startMs: 1_000 }]);
|
||||
expect(tie.map((i) => i.id)).toEqual(["u1", "run-y:turn", "u2"]);
|
||||
});
|
||||
|
||||
it("chronological insertion after a user message stays a standalone row through coalesce+attach", () => {
|
||||
// Interplay guard: the inserted turn sits below a HUMAN bubble, so the
|
||||
// later passes must neither merge it into anything nor attach it.
|
||||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||||
["run-x:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: [toolCall("Read")], durationMs: 34_000 }] }],
|
||||
]);
|
||||
const assembled = assembleThreadItems(backbone, noAnchors, [{ turn: settledTurn("run-x:turn"), startMs: 1_500 }]);
|
||||
const out = attachSettledTurns(coalesceSettledTurns(assembled, metaById), metaById);
|
||||
expect(out.map((i) => i.id)).toEqual(["u1", "run-x:turn", "u2"]);
|
||||
expect(out.every((i) => i.kind !== "message" || (i as Extract<TaskChatItem, { kind: "message" }>).attachedTurn == null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prependIssueBrief (PAP-375)", () => {
|
||||
function entry(id: string, ms: number): ThreadBackboneEntry {
|
||||
return {
|
||||
ms,
|
||||
id,
|
||||
item: { id, kind: "message", author: "human", text: `msg ${id}` } as TaskChatItem,
|
||||
};
|
||||
}
|
||||
function settledTurn(id: string): TaskChatTurnItem {
|
||||
return {
|
||||
id,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||||
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps the description bubble above an unanchored turn that predates every backbone entry (F15)", () => {
|
||||
// A stopped run whose startMs (0) is EARLIER than the oldest comment: it
|
||||
// sorts to the very top of the assembled thread — the brief must still
|
||||
// render above it, because the prepend runs after assembly.
|
||||
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
|
||||
const assembled = assembleThreadItems(backbone, new Map(), [
|
||||
{ turn: settledTurn("run-x:turn"), startMs: 0 },
|
||||
]);
|
||||
const out = prependIssueBrief(assembled, true);
|
||||
expect(out.map((i) => i.id)).toEqual([ISSUE_BRIEF_ITEM_ID, "run-x:turn", "u1", "u2"]);
|
||||
expect(out[0].kind).toBe("brief");
|
||||
});
|
||||
|
||||
it("is the first item of an otherwise empty thread, and a no-op without a brief", () => {
|
||||
expect(prependIssueBrief([], true).map((i) => i.kind)).toEqual(["brief"]);
|
||||
const items = [entry("u1", 1_000).item];
|
||||
expect(prependIssueBrief(items, false)).toBe(items);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
TaskChatToolItem,
|
||||
TaskChatTurnItem,
|
||||
} from "./task-chat-model";
|
||||
import { mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
"failed",
|
||||
@@ -82,9 +83,9 @@ export function summarizeToolInput(input: unknown): string | undefined {
|
||||
export function toolDisplayName(name: string | undefined | null): string {
|
||||
const raw = (name ?? "").trim();
|
||||
if (!raw || raw === "tool") return "Tool";
|
||||
const mcp = raw.match(/^mcp__[^_]+(?:_[^_]+)*__(.+)$/);
|
||||
const base = mcp?.[1] ?? raw;
|
||||
return base.charAt(0).toUpperCase() + base.slice(1);
|
||||
const mcp = mcpToolSegment(raw);
|
||||
if (mcp) return mcp;
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
}
|
||||
|
||||
/** "Thought for Ns" once a coalesced thinking group spans ≥1s. */
|
||||
@@ -190,6 +191,7 @@ export function transcriptToTaskChatItems(
|
||||
id: `${runId}:tool:${toolCallId}`,
|
||||
kind: "tool",
|
||||
name: toolDisplayName(entry.name),
|
||||
rawName: entry.name ?? undefined,
|
||||
target: summarizeToolInput(entry.input),
|
||||
status: "in_progress",
|
||||
};
|
||||
@@ -213,6 +215,7 @@ export function transcriptToTaskChatItems(
|
||||
existing.status = entry.isError ? "failed" : "completed";
|
||||
if (existing.name === "Tool" && entry.toolName) {
|
||||
existing.name = toolDisplayName(entry.toolName);
|
||||
existing.rawName = entry.toolName;
|
||||
}
|
||||
if (!existing.detail) {
|
||||
const detail = formatToolResultDetail(entry.content);
|
||||
@@ -314,15 +317,26 @@ export function buildTurnSummary(
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable label for the live status pill from the tail of a transcript. */
|
||||
/**
|
||||
* Human-readable label for the live status pill from the tail of a transcript.
|
||||
* A tail tool_call yields the taxonomy verb ("Searching", "Running a command")
|
||||
* with tool + target as detail; `toolName` lets the pill show the family icon.
|
||||
*/
|
||||
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
label: string;
|
||||
detail?: string;
|
||||
toolName?: string;
|
||||
} {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.kind === "tool_call") {
|
||||
return { label: "Working", detail: toolDisplayName(entry.name) };
|
||||
const target = summarizeToolInput(entry.input);
|
||||
const display = toolDisplayName(entry.name);
|
||||
return {
|
||||
label: toolTaxonomy(entry.name).verbLabel,
|
||||
detail: target ? `${display} · ${target}` : display,
|
||||
toolName: entry.name ?? undefined,
|
||||
};
|
||||
}
|
||||
if (entry.kind === "tool_result") break;
|
||||
if (entry.kind === "assistant") return { label: "Responding" };
|
||||
|
||||
Reference in new issue
Block a user