diff --git a/CHANGELOG.md b/CHANGELOG.md index 21110535e..2aa59af7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +### A reply's tool calls fold into one line + +A Bot that searched, read two pages and checked Drive before answering drew four lines above its +answer. Adjacent connector calls and browser steps now fold into one line that says what was done, +such as "Searched the web and read 2 pages", with an icon for each kind of step. It names the step in +progress while the Bot works, and counts issues when a step was blocked or failed. Opened, it lists +each step with its query, page or connector, and each step opens to what it was given and what came +back. A single connector call keeps its own line, and approvals, connection requests and anything the +app draws itself stay outside the fold. + ### Public-web research works with Exa and Firecrawl, not only Parallel Four new catalogue entries give a Bot public-web search and page reading without its browser: diff --git a/app/src/components/channels/browser-activity.tsx b/app/src/components/channels/browser-activity.tsx index c0cae4c54..4995fdf2d 100644 --- a/app/src/components/channels/browser-activity.tsx +++ b/app/src/components/channels/browser-activity.tsx @@ -1,14 +1,6 @@ -import { PendingApprovals } from "@/components/approvals/inline-approval"; import type { VisibleChatItem } from "./chat-messages"; -import { ToolLine } from "./tool-line"; type BrowserStep = Extract; -export type BrowserGroup = { - kind: "browser"; - id: string; - steps: BrowserStep[]; -}; -export type TranscriptItem = VisibleChatItem | BrowserGroup; const labels: Record = { computer_navigate: "Opened page", @@ -20,24 +12,9 @@ const labels: Record = { computer_scroll: "Scrolled page", }; -/** Group adjacent browsing steps without moving messages or hiding requests for human help. */ -export function groupBrowserSteps( - items: readonly VisibleChatItem[], -): TranscriptItem[] { - const grouped: TranscriptItem[] = []; - for (const item of items) { - if ( - item.kind !== "tool" || - !Object.hasOwn(labels, item.toolCall.function.name) - ) { - grouped.push(item); - continue; - } - const last = grouped.at(-1); - if (last?.kind === "browser") last.steps.push(item); - else grouped.push({ kind: "browser", id: item.id, steps: [item] }); - } - return grouped; +/** Whether a tool call is one of the browser steps a tool group draws with its page link. */ +export function isBrowserStep(item: BrowserStep): boolean { + return Object.hasOwn(labels, item.toolCall.function.name); } function objectOf(value: string | undefined): Record { @@ -83,72 +60,3 @@ export function browserStepDetails(step: BrowserStep) { pending: step.result === undefined, }; } - -export function BrowserActivity({ - group, - active, -}: { - group: BrowserGroup; - active: boolean; -}) { - const steps = group.steps.map((step) => ({ - id: step.id, - ...browserStepDetails(step), - })); - const pages = steps.filter((step) => step.visited).length; - const issues = steps.filter((step) => step.failed).length; - const running = active && steps.some((step) => step.pending); - // The folded line draws its own steps, so a waiting approval gets its card beside it, not inside. - return ( - <> - -
    - {steps.map((step) => ( -
  1. -
    - - {step.label} - - {step.href ? ( - - {step.title ?? step.href} - - ) : null} - {step.pending && ( - - {active ? "In progress" : "Not completed"} - - )} -
    - {step.failed && ( -

    - {step.reason ?? "This step could not be completed."} -

    - )} -
  2. - ))} -
-
- step.toolCall.id)} - /> - - ); -} diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index d6ac4cd2f..e890e2ec0 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -62,10 +62,10 @@ import { readTeamBotConsent } from "@/lib/team-bots"; import { cn } from "@/lib/utils"; import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive"; import { - BrowserActivity, - groupBrowserSteps, + groupToolSteps, + ToolActivity, type TranscriptItem, -} from "./browser-activity"; +} from "./tool-activity"; import { attachmentModality, type SentAttachment, @@ -1525,7 +1525,7 @@ export function ChatTranscript({ * cost was markdown parsing and chart SVGs, and those are skipped by the memoised children below, * which is where the 25x came from. This runs per render and is not worth guarding. */ - const items = groupBrowserSteps(toVisibleChatItems(messages)); + const items = groupToolSteps(toVisibleChatItems(messages)); /* * ONLY WHILE THERE IS NOTHING ELSE TO LOOK AT. Once a reply starts streaming, or a tool line @@ -1668,12 +1668,12 @@ export function ChatTranscript({ {items.length === 0 && restoring ? : null} {items.map((item, index) => - item.kind === "browser" ? ( + item.kind === "tools" ? ( - diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx new file mode 100644 index 000000000..5fc9e69d5 --- /dev/null +++ b/app/src/components/channels/tool-activity.tsx @@ -0,0 +1,440 @@ +import { + IconFileText, + IconPlug, + IconSearch, + IconWorld, +} from "@tabler/icons-react"; +import { Streamdown } from "streamdown"; +import { PendingApprovals } from "@/components/approvals/inline-approval"; +import { markdownComponents } from "@/lib/markdown"; +import { readToolName } from "@/lib/plugins/tool-name"; +import { + asText, + forDisplay, + REFUSAL_MARKER, + toolResultFailed, +} from "@/lib/plugins/tool-result"; +import { readTeamBotConsent } from "@/lib/team-bots"; +import { researchRoleOfTool } from "@openbot/shared/research"; +import { browserStepDetails, isBrowserStep } from "./browser-activity"; +import type { VisibleChatItem } from "./chat-messages"; +import { ToolLine } from "./tool-line"; + +type ToolItem = Extract; + +/** Adjacent things a Bot did, drawn as one line that opens to each of them. */ +export type ToolGroup = { + kind: "tools"; + id: string; + steps: ToolItem[]; +}; +export type TranscriptItem = VisibleChatItem | ToolGroup; + +/** + * Fold adjacent tool calls into one row, so a reply that searched, read two pages and checked Drive + * reads as one thing the Bot did rather than four lines before the answer. + * + * Only calls drawn as a plain line are folded: browser steps and connector (MCP) tools. A tool the + * app draws itself, a chart or a request for help, stays where it is, and so does a refusal that + * asks the person to connect their own account, because a question for the person must never sit + * behind a disclosure. A connector call on its own keeps its own line; one browser step is still a + * group, as it always was, so the row does not change shape when the second step arrives. + * + * The group takes its first step's id, so the row keeps its identity while later steps stream in. + */ +export function groupToolSteps( + items: readonly VisibleChatItem[], +): TranscriptItem[] { + const grouped: TranscriptItem[] = []; + let run: ToolItem[] = []; + const close = () => { + const [first] = run; + if (!first) return; + if (run.length === 1 && !isBrowserStep(first)) grouped.push(first); + else grouped.push({ kind: "tools", id: first.id, steps: run }); + run = []; + }; + for (const item of items) { + if (item.kind === "tool" && foldable(item)) { + run.push(item); + continue; + } + close(); + grouped.push(item); + } + close(); + return grouped; +} + +function foldable(item: ToolItem): boolean { + if (isBrowserStep(item)) return true; + if (!item.toolCall.function.name.startsWith("mcp__")) return false; + return !asksForConsent(item.result); +} + +function asksForConsent(result: string | undefined): boolean { + if (result === undefined) return false; + const answer = asText(result); + if (!answer.startsWith(REFUSAL_MARKER)) return false; + return ( + readTeamBotConsent(answer.slice(REFUSAL_MARKER.length).trim()) !== null + ); +} + +type StepKind = "search" | "fetch" | "browser" | "connector"; + +export type StepDetails = { + id: string; + kind: StepKind; + label: string; + /** What it was done to: a query, a page, a connector. Muted beside the label. */ + detail?: string; + href?: string; + pending: boolean; + failed: boolean; + refused: boolean; + reason?: string; + /** Pages a browser step opened, or a fetch read; zero for anything else. */ + pages: number; + /** The connector a connector step reached, said the way a person would. */ + connector?: string; + inputs: [string, string][]; + output?: string; +}; + +function objectOf(value: string | undefined): Record { + try { + const parsed: unknown = JSON.parse(value ?? ""); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (Array.isArray(value)) { + const found = value.find( + (entry): entry is string => typeof entry === "string" && !!entry.trim(), + ); + if (found) return found.trim(); + } + } + return undefined; +} + +function urlsOf(args: Record): string[] { + const urls = Array.isArray(args.urls) ? args.urls : [args.url]; + return urls.filter( + (url): url is string => typeof url === "string" && !!url.trim(), + ); +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** "google-drive" as "Google Drive". */ +function connectorName(key: string): string { + return key + .split(/[-_]/) + .filter(Boolean) + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "); +} + +/** A value from a tool's arguments, short enough for one line. */ +function inputValue(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value); + return text.length > 200 ? `${text.slice(0, 199)}…` : text; +} + +export function stepDetails(step: ToolItem): StepDetails { + const args = objectOf(step.toolCall.function.arguments); + const inputs = Object.entries(args).map( + ([key, value]) => [key, inputValue(value)] as [string, string], + ); + if (isBrowserStep(step)) { + const browser = browserStepDetails(step); + return { + id: step.id, + kind: "browser", + label: browser.label ?? "Used the browser", + ...(browser.title ? { detail: browser.title } : {}), + ...(browser.href ? { href: browser.href } : {}), + pending: browser.pending, + failed: browser.failed, + refused: false, + ...(browser.reason ? { reason: browser.reason } : {}), + pages: browser.visited ? 1 : 0, + inputs, + }; + } + + const name = step.toolCall.function.name; + const [, server = "", ...rest] = name.split("__"); + const role = researchRoleOfTool(server, rest.join("__")); + const { label, detail } = readToolName(name); + const answer = step.result === undefined ? undefined : asText(step.result); + const refused = answer?.startsWith(REFUSAL_MARKER) ?? false; + const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer; + const urls = role === "fetch" ? urlsOf(args) : []; + const base = { + id: step.id, + label, + pending: step.result === undefined, + failed: !refused && toolResultFailed(step.result), + refused, + inputs, + ...(body ? { output: body } : {}), + }; + if (role === "search") { + const query = firstString( + args.query, + args.objective, + args.search_queries, + args.queries, + ); + return { + ...base, + kind: "search", + ...(query ? { detail: query } : {}), + pages: 0, + }; + } + if (role === "fetch") { + const [first, ...more] = urls; + const href = first && /^https?:\/\//.test(first) ? first : undefined; + return { + ...base, + kind: "fetch", + ...(first + ? { + detail: more.length + ? `${hostOf(first)} and ${more.length} more` + : hostOf(first), + } + : {}), + ...(href && !more.length ? { href } : {}), + pages: Math.max(urls.length, 1), + }; + } + return { + ...base, + kind: "connector", + ...(detail ? { detail } : {}), + pages: 0, + connector: connectorName(server), + }; +} + +function joinPhrases(phrases: string[]): string { + if (phrases.length <= 1) return phrases[0] ?? ""; + return `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}`; +} + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** + * What a group of steps did, as one phrase: "Searched the web and read 2 pages". + * + * Counted by kind rather than listed, because the list is what the line opens to. Research is + * never attributed to a provider; a connector is named, because which system was reached is the + * person's own fact. + */ +export function summarise(steps: readonly StepDetails[]): string { + const of = (kind: StepKind) => steps.filter((step) => step.kind === kind); + const phrases: string[] = []; + if (of("search").length) phrases.push("searched the web"); + const read = of("fetch").reduce((sum, step) => sum + step.pages, 0); + if (read) phrases.push(`read ${plural(read, "page", "pages")}`); + const browser = of("browser"); + const browsed = browser.reduce((sum, step) => sum + step.pages, 0); + if (browsed) phrases.push(`browsed ${plural(browsed, "page", "pages")}`); + else if (browser.length) phrases.push("used the browser"); + const connectors = [ + ...new Set(of("connector").map((step) => step.connector ?? "")), + ].filter(Boolean); + if (connectors.length === 1) phrases.push(`used ${connectors[0]}`); + else if (connectors.length > 1) + phrases.push( + `used ${plural(connectors.length, "connector", "connectors")}`, + ); + const sentence = joinPhrases(phrases); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); +} + +const KIND_ICONS = { + search: IconSearch, + fetch: IconFileText, + browser: IconWorld, + connector: IconPlug, +} as const; + +function StepIcon({ + kind, + size = "size-4", +}: { + kind: StepKind; + size?: string; +}) { + const Icon = KIND_ICONS[kind]; + return ; +} + +/** + * One small icon per kind of step, overlapped, in the order they first happened. + * + * The first is drawn on top. Drawn the other way, each circle's ring cuts into the icon before it, + * and a magnifying glass with its handle cut off reads as the letter C. + */ +function IconStack({ kinds }: { kinds: StepKind[] }) { + return ( + + {kinds.map((kind, index) => ( + 0 ? "-ml-1.5" : "" + }`} + > + + + ))} + + ); +} + +function StepStatus({ step, active }: { step: StepDetails; active: boolean }) { + if (step.pending) + return ( + + {active ? "In progress" : "Not completed"} + + ); + if (step.refused) + return Blocked; + if (step.failed) + return ( + + Didn't work + + ); + return null; +} + +function StepRow({ step, active }: { step: StepDetails; active: boolean }) { + const line = ( + + + {step.label} + {step.href ? ( + + {step.detail ?? step.href} + + ) : step.detail ? ( + {step.detail} + ) : null} + + + ); + const reason = + step.failed && step.kind === "browser" ? ( +

+ {step.reason ?? "This step could not be completed."} +

+ ) : null; + // A browser step says everything it has in its line. Anything else opens to what it was given + // and what came back, which no other place in the transcript shows. + if (step.kind === "browser" || (!step.inputs.length && !step.output)) + return ( +
  • + {line} + {reason} +
  • + ); + return ( +
  • +
    + + + ▸ + + {line} + +
    + {step.inputs.length ? ( +
    + {step.inputs.map(([key, value]) => ( +
    +
    {key}
    +
    {value}
    +
    + ))} +
    + ) : null} + {step.output ? ( +
    + + {forDisplay(step.output)} + +
    + ) : null} +
    +
    +
  • + ); +} + +export function ToolActivity({ + group, + active, +}: { + group: ToolGroup; + /** This is the newest row of a turn still in flight, so a step without a result is running. */ + active: boolean; +}) { + const steps = group.steps.map(stepDetails); + const running = active && steps.some((step) => step.pending); + const current = steps.find((step) => step.pending); + const issues = steps.filter((step) => step.failed || step.refused).length; + const kinds = [...new Set(steps.map((step) => step.kind))]; + // The folded line draws its own steps, so a waiting approval gets its card beside it, not inside. + return ( + <> + } + label={running && current ? `${current.label}…` : summarise(steps)} + detail={issues ? plural(issues, "issue", "issues") : undefined} + running={running} + > +
      + {steps.map((step) => ( + + ))} +
    +
    + step.toolCall.id)} + /> + + ); +} diff --git a/app/src/components/channels/tool-line.tsx b/app/src/components/channels/tool-line.tsx index 1e13bb3c3..91306c720 100644 --- a/app/src/components/channels/tool-line.tsx +++ b/app/src/components/channels/tool-line.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from "react"; * something with nothing to show yet. */ export function ToolLine({ + icon, label, detail, running, @@ -18,6 +19,8 @@ export function ToolLine({ failed, children, }: { + /** Drawn before the label, small and muted. A group of steps puts its kinds here. */ + icon?: ReactNode; /** What was done, in a couple of words: "Searched Slack", "Filled in", "Read the page". */ label: string; /** What it was done to. Muted, and truncated rather than wrapped. */ @@ -42,6 +45,7 @@ export function ToolLine({ running ? "tool-line-running" : "" }`} > + {icon ? {icon} : null} {refused ? `Blocked: ${label}` diff --git a/app/tests/approvals-rules.test.tsx b/app/tests/approvals-rules.test.tsx index 60d9da290..3b5521008 100644 --- a/app/tests/approvals-rules.test.tsx +++ b/app/tests/approvals-rules.test.tsx @@ -26,9 +26,9 @@ import userEvent from "@testing-library/user-event"; import { ApprovalSettings } from "@/components/approvals/settings"; import { InlineApproval } from "@/components/approvals/inline-approval"; import { - BrowserActivity, - groupBrowserSteps, -} from "@/components/channels/browser-activity"; + groupToolSteps, + ToolActivity, +} from "@/components/channels/tool-activity"; import { toVisibleChatItems } from "@/components/channels/chat-messages"; beforeAll(() => GlobalRegistrator.register()); @@ -382,7 +382,7 @@ test("a pending change is drawn in the conversation where the action was, and de test("in the real transcript shape a waiting click is folded into browser activity, so its card is drawn beside the group", async () => { // What the channel transcript builds from the conversation: a navigate that finished and the // click the chat stopped on, unanswered, grouped into one browser-activity line. - const items = groupBrowserSteps( + const items = groupToolSteps( toVisibleChatItems([ { id: "u1", role: "user", content: "Order a pizza on httpbin" }, { @@ -416,16 +416,16 @@ test("in the real transcript shape a waiting click is folded into browser activi }, ] as never), ); - const group = items.find((item) => item.kind === "browser"); - if (group?.kind !== "browser") throw new Error("no browser group"); + const group = items.find((item) => item.kind === "tools"); + if (group?.kind !== "tools") throw new Error("no tool group"); const view = render( - {/* BrowserActivity mounts the waiting cards beside its folded line itself. */} - + {/* ToolActivity mounts the waiting cards beside its folded line itself. */} + , ); await view.findByRole("region", { name: "Waiting for your approval" }); diff --git a/app/tests/browser-activity.test.ts b/app/tests/browser-activity.test.ts index 445761a5c..8b6ea5852 100644 --- a/app/tests/browser-activity.test.ts +++ b/app/tests/browser-activity.test.ts @@ -1,8 +1,6 @@ import { expect, test } from "bun:test"; -import { - browserStepDetails, - groupBrowserSteps, -} from "../src/components/channels/browser-activity"; +import { browserStepDetails } from "../src/components/channels/browser-activity"; +import { groupToolSteps } from "../src/components/channels/tool-activity"; import type { VisibleChatItem } from "../src/components/channels/chat-messages"; function step( @@ -25,13 +23,11 @@ function step( test("navigation and intervening browser actions become one stable row as results stream", () => { const first = step("one"); const items = [first, step("read", "computer_read"), step("two")]; - const grouped = groupBrowserSteps(items); - expect(grouped).toEqual([{ kind: "browser", id: "one", steps: items }]); + const grouped = groupToolSteps(items); + expect(grouped).toEqual([{ kind: "tools", id: "one", steps: items }]); expect( - groupBrowserSteps([ - { ...first, result: '{"ok":true}' }, - ...items.slice(1), - ])[0]?.id, + groupToolSteps([{ ...first, result: '{"ok":true}' }, ...items.slice(1)])[0] + ?.id, ).toBe("one"); expect(items).toHaveLength(3); }); @@ -50,7 +46,7 @@ test("grouping keeps messages, other tools, and human assistance requests in the step("shell", "computer_run_command"), ]; for (const boundary of boundaries) { - const grouped = groupBrowserSteps([step("one"), boundary, step("two")]); + const grouped = groupToolSteps([step("one"), boundary, step("two")]); expect(grouped.map((item) => item.id)).toEqual(["one", boundary.id, "two"]); expect(grouped[1]).toBe(boundary); } diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx new file mode 100644 index 000000000..16114b3d3 --- /dev/null +++ b/app/tests/tool-activity.test.tsx @@ -0,0 +1,148 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import type { VisibleChatItem } from "../src/components/channels/chat-messages"; +import { + groupToolSteps, + stepDetails, + summarise, + ToolActivity, + type ToolGroup, +} from "../src/components/channels/tool-activity"; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +type ToolItem = Extract; + +function call( + id: string, + name: string, + args: Record = {}, + result?: string, +): ToolItem { + return { + kind: "tool", + id, + toolCall: { + id, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ...(result === undefined ? {} : { result }), + }; +} + +const search = (id = "search", result = '"Results"') => + call(id, "mcp__exa__web_search_exa", { query: "top 5 GDP 2025" }, result); +const fetch = (urls: string[], id = "fetch") => + call(id, "mcp__exa__web_fetch_exa", { urls }, '"Page text"'); +const text = (id: string): VisibleChatItem => ({ + kind: "text", + id, + role: "assistant", + text: "Here is what I found.", +}); + +test("adjacent connector calls fold into one row named after the first", () => { + const items = [ + search(), + fetch(["https://imf.org/a"]), + call("drive", "mcp__google-drive__search_files", { q: "budget" }, '"x"'), + text("answer"), + ]; + const grouped = groupToolSteps(items); + expect(grouped.map((item) => item.kind)).toEqual(["tools", "text"]); + expect(grouped[0]).toMatchObject({ id: "search", steps: items.slice(0, 3) }); +}); + +test("one connector call keeps its own line, and anything the app draws stays where it is", () => { + expect(groupToolSteps([search()])).toEqual([search()]); + const chart = call("chart", "showBarChart"); + const help = call("help", "computer_request_help"); + for (const boundary of [chart, help, text("between")]) { + const grouped = groupToolSteps([search("a"), boundary, search("b")]); + expect(grouped.map((item) => item.id)).toEqual(["a", boundary.id, "b"]); + } +}); + +test("a request to connect the person's own account is never folded away", () => { + const consent = call( + "consent", + "mcp__gmail__send", + {}, + JSON.stringify( + 'Refused. [team-bot-consent]{"botId":"b","serverId":"gmail"} Use your own Gmail?', + ), + ); + const grouped = groupToolSteps([search("a"), consent, search("b")]); + expect(grouped.map((item) => item.id)).toContain("consent"); +}); + +test("the summary says what was done, never which provider searched", () => { + const steps = [ + search(), + fetch(["https://imf.org/a", "https://worldbank.org/b"]), + ].map(stepDetails); + expect(summarise(steps)).toBe("Searched the web and read 2 pages"); + expect(JSON.stringify(steps.map((step) => step.label))).not.toContain("exa"); + expect(steps[0]?.detail).toBe("top 5 GDP 2025"); + expect(steps[1]?.detail).toBe("imf.org and 1 more"); + expect( + summarise( + [ + call("d1", "mcp__google-drive__search_files", {}, '"x"'), + call("d2", "mcp__google-drive__read_file", {}, '"x"'), + call("n", "mcp__notion__search", {}, '"x"'), + ].map(stepDetails), + ), + ).toBe("Used 2 connectors"); + expect( + summarise( + [call("d", "mcp__google-drive__search_files", {}, '"x"')].map( + stepDetails, + ), + ), + ).toBe("Used Google Drive"); +}); + +test("a refusal and a failure each count as an issue on the folded line", () => { + const group: ToolGroup = { + kind: "tools", + id: "a", + steps: [ + search("a", JSON.stringify("Refused. Not allowed here.")), + search("b", JSON.stringify("That tool could not be called: timeout")), + ], + }; + const view = render( + + + , + ); + expect(view.getByText("2 issues")).toBeDefined(); + expect(view.getByText("Blocked")).toBeDefined(); + expect(view.getByText("Didn't work")).toBeDefined(); +}); + +test("while running, the line names the step in progress; opened, a step shows its inputs", () => { + const group: ToolGroup = { + kind: "tools", + id: "a", + steps: [ + search(), + call("f", "mcp__exa__web_fetch_exa", { urls: ["https://imf.org"] }), + ], + }; + const view = render( + + + , + ); + expect(view.getByText("Read web pages…")).toBeDefined(); + fireEvent.click(view.getAllByText("Search the web")[0] as HTMLElement); + expect(view.getByText("query")).toBeDefined(); + expect(view.getAllByText("top 5 GDP 2025").length).toBeGreaterThan(0); +});