From 2e2b10da947517e415fe4bd9675c5a4fc7fab487 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 12:23:57 -0300 Subject: [PATCH 1/2] Fold a reply's tool calls into one line Adjacent connector calls and browser steps used to draw a line each above the answer. They now fold into one row that says what was done ("Searched the web and read 2 pages"), with an icon per kind of step, the running step's name while it works, and a count of blocked or failed steps. Opened, each step shows its query, page or connector, and opens to its inputs and output. The browser grouping this replaces folded only browser steps; groupToolSteps folds both. A single connector call keeps its own line, and a Team Bot consent request, a tool the app draws itself, and any pending approval stay outside the fold. --- CHANGELOG.md | 10 + .../components/channels/browser-activity.tsx | 98 +--- .../components/channels/chat-transcript.tsx | 12 +- app/src/components/channels/tool-activity.tsx | 440 ++++++++++++++++++ app/src/components/channels/tool-line.tsx | 4 + app/tests/approvals-rules.test.tsx | 16 +- app/tests/browser-activity.test.ts | 18 +- app/tests/tool-activity.test.tsx | 148 ++++++ 8 files changed, 626 insertions(+), 120 deletions(-) create mode 100644 app/src/components/channels/tool-activity.tsx create mode 100644 app/tests/tool-activity.test.tsx 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..f63613371 --- /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-3.5", +}: { + 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" : "" + }`} + > + + + ))} + + ); +} + +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); +}); From da2e68b23421a3f2855d832064ecc8857273dc66 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 12:29:42 -0300 Subject: [PATCH 2/2] Draw a tool group's icons at a readable size The stack's icons were 11px in 20px circles and the step icons 13px, too small to tell a search from a page at a glance. Stack icons are now 15px in 24px circles and step icons 15px. --- app/src/components/channels/tool-activity.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx index f63613371..5fc9e69d5 100644 --- a/app/src/components/channels/tool-activity.tsx +++ b/app/src/components/channels/tool-activity.tsx @@ -282,7 +282,7 @@ const KIND_ICONS = { function StepIcon({ kind, - size = "size-3.5", + size = "size-4", }: { kind: StepKind; size?: string; @@ -304,11 +304,11 @@ function IconStack({ kinds }: { kinds: StepKind[] }) { 0 ? "-ml-1" : "" + className={`relative flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground ring-2 ring-background ${ + index > 0 ? "-ml-1.5" : "" }`} > - + ))} @@ -355,7 +355,7 @@ function StepRow({ step, active }: { step: StepDetails; active: boolean }) { ); const reason = step.failed && step.kind === "browser" ? ( -

    +

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

    ) : null;