From 2b0cfceca5e6cea668cb6d8d9cd7dce2f2a3de27 Mon Sep 17 00:00:00 2001 From: George Ng Date: Tue, 21 Jul 2026 16:53:45 -0700 Subject: [PATCH 1/8] Merge neighbor tools calls in reasoning agent response to cleanup UI --- .../dispatcher/src/reasoning/copilot.ts | 48 ++++++---- .../src/reasoning/reasoningLoopBase.ts | 45 ++++++++++ .../dispatcher/test/toolRunFolder.spec.ts | 88 +++++++++++++++++++ 3 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts index f7fd3132e8..a0501a768c 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts @@ -40,6 +40,7 @@ import { createLimiter } from "@typeagent/common-utils"; import { ReasoningTraceCollector } from "./tracing/traceCollector.js"; import { ReasoningRecipeGenerator } from "./recipeGenerator.js"; import { formatUserContextForPrompt } from "./userContextPrompt.js"; +import { ToolRunFolder } from "./reasoningLoopBase.js"; const debug = registerDebug("typeagent:dispatcher:reasoning:copilot"); @@ -1094,6 +1095,13 @@ async function executeReasoningWithoutPlanning( debug(`Executing reasoning request: ${originalRequest}`); context.actionIO.appendDisplay("Thinking...", "temporary"); const displayMode = resolveReasoningDisplayMode(context); + // Fold runs of identical, back-to-back tool-call lines into one "xN" line. + const toolFolder = new ToolRunFolder((content) => + context.actionIO.appendDisplay( + { type: "markdown", content, kind: "info" }, + displayMode, + ), + ); const client = await getCopilotClient(context.sessionContext.agentContext); const config = getCopilotSessionConfig(context); @@ -1154,6 +1162,7 @@ async function executeReasoningWithoutPlanning( "assistant.reasoning_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentReasoning += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1174,6 +1183,7 @@ async function executeReasoningWithoutPlanning( event.data.content !== lastReasoningContent ) { // Final reasoning content - display as permanent thinking block + toolFolder.flush(); lastReasoningContent = event.data.content; context.actionIO.appendDisplay( { @@ -1192,6 +1202,7 @@ async function executeReasoningWithoutPlanning( "assistant.message_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentContent += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1222,14 +1233,7 @@ async function executeReasoningWithoutPlanning( event.data?.parameters || {}; debug(`Tool execution started: ${toolName}`); - context.actionIO.appendDisplay( - { - type: "markdown", - content: formatToolCallDisplay(toolName, parameters), - kind: "info", - }, - displayMode, - ); + toolFolder.tool(formatToolCallDisplay(toolName, parameters)); }, ); @@ -1291,6 +1295,7 @@ async function executeReasoningWithoutPlanning( // streaming display). Prefer the authoritative final message over the // streamed accumulation, which can be stale/truncated when the model // interleaves tool calls mid-turn (which produced an "unfinished" answer). + toolFolder.flush(); const displayContent = finalResult || currentContent; if (displayContent) { context.actionIO.appendDisplay( @@ -1316,6 +1321,7 @@ async function executeReasoningWithoutPlanning( return result; } catch (error) { debug("Error during reasoning:", error); + toolFolder.flush(); context.actionIO.appendDisplay( { type: "text", @@ -1368,6 +1374,13 @@ async function executeReasoningWithTracing( debug(`Executing reasoning with tracing: ${originalRequest}`); context.actionIO.appendDisplay("Thinking...", "temporary"); const displayMode = resolveReasoningDisplayMode(context); + // Fold runs of identical, back-to-back tool-call lines into one "xN" line. + const toolFolder = new ToolRunFolder((content) => + context.actionIO.appendDisplay( + { type: "markdown", content, kind: "info" }, + displayMode, + ), + ); const client = await getCopilotClient( context.sessionContext.agentContext, @@ -1430,6 +1443,7 @@ async function executeReasoningWithTracing( "assistant.reasoning_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentReasoning += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1457,6 +1471,7 @@ async function executeReasoningWithTracing( ], }); + toolFolder.flush(); context.actionIO.appendDisplay( { type: "markdown", @@ -1473,6 +1488,7 @@ async function executeReasoningWithTracing( "assistant.message_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentContent += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1508,14 +1524,7 @@ async function executeReasoningWithTracing( // Record tool call for trace tracer.recordToolCall(toolName, parameters); - context.actionIO.appendDisplay( - { - type: "markdown", - content: formatToolCallDisplay(toolName, parameters), - kind: "info", - }, - displayMode, - ); + toolFolder.tool(formatToolCallDisplay(toolName, parameters)); }, ); @@ -1570,6 +1579,7 @@ async function executeReasoningWithTracing( // streaming display). Prefer the authoritative final message over // the streamed accumulation, which can be stale/truncated when the // model interleaves tool calls mid-turn. + toolFolder.flush(); const displayContent = finalResult || currentContent; if (displayContent) { context.actionIO.appendDisplay( @@ -1634,6 +1644,12 @@ async function executeReasoningWithTracing( ); return result; } finally { + // Emit any tool run still buffered when the inner try exits. On + // success the final-content flush above already drained it (no-op + // here); on an error thrown before that point (e.g. sendAndWait), + // this is the last chance to render it, since the outer catch is a + // separate scope and cannot see toolFolder. + toolFolder.flush(); unsubscribeReasoningDelta(); unsubscribeReasoning(); unsubscribeMessageDelta(); diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts index bda77fbf3f..bc0b1f7488 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts @@ -176,6 +176,51 @@ export function formatThinkingDisplay(thinkingText: string): string { ].join(""); } +/** + * Collapses runs of identical, back-to-back tool-call lines into a single line + * with an "xN" suffix (e.g. three identical calls render as "... x3"). Only + * direct neighbors merge: any other display between two identical calls (a + * thinking block, streamed text, a tool result, or a different tool call) ends + * the run and keeps the calls separate. + * + * Tool lines are buffered instead of emitted right away, because the count is + * not known until the run ends. Callers must: + * - route every tool-call line through tool() + * - call flush() before emitting any non-tool display + * - call flush() once more after the reasoning stream completes + */ +export class ToolRunFolder { + private pending: string | undefined; + private count = 0; + + constructor(private readonly emit: (content: string) => void) {} + + // Record a tool-call line, folding it into the current run when identical + // to the immediately preceding one. + tool(display: string): void { + if (this.pending === display) { + this.count++; + return; + } + this.flush(); + this.pending = display; + this.count = 1; + } + + // Emit the buffered run (if any) and reset. A run of two or more identical + // calls gets an "xN" suffix; a single call is emitted unchanged. + flush(): void { + if (this.pending === undefined) { + return; + } + const content = + this.count > 1 ? `${this.pending} x${this.count}` : this.pending; + this.pending = undefined; + this.count = 0; + this.emit(content); + } +} + /** * Process a reasoning session's event stream with display output and optional tracing. * Shared loop logic used by all reasoning adapters. diff --git a/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts b/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts new file mode 100644 index 0000000000..7371321e40 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ToolRunFolder } from "../src/reasoning/reasoningLoopBase.js"; + +// Collect the strings the folder emits so each test can assert on the exact +// sequence of rendered tool-call lines. +function makeFolder(): { folder: ToolRunFolder; emitted: string[] } { + const emitted: string[] = []; + const folder = new ToolRunFolder((content) => emitted.push(content)); + return { folder, emitted }; +} + +describe("ToolRunFolder", () => { + it("emits a single tool call unchanged (no multiplier)", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + expect(emitted).toEqual([]); // buffered until flushed + folder.flush(); + expect(emitted).toEqual(["A"]); + }); + + it("folds identical adjacent calls into one xN line", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + folder.tool("A"); + folder.tool("A"); + folder.flush(); + expect(emitted).toEqual(["A x3"]); + }); + + it("keeps duplicate calls separate when a different call splits them", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + folder.tool("B"); + folder.tool("A"); + folder.flush(); + expect(emitted).toEqual(["A", "B", "A"]); + }); + + it("does not merge across a flush (e.g. a thinking block between runs)", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + folder.flush(); // interrupted by a non-tool display + folder.tool("A"); + folder.flush(); + expect(emitted).toEqual(["A", "A"]); + }); + + it("folds multiple distinct runs independently", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + folder.tool("A"); + folder.tool("B"); + folder.tool("B"); + folder.tool("B"); + folder.flush(); + expect(emitted).toEqual(["A x2", "B x3"]); + }); + + it("emits the prior run immediately when a different call starts", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + folder.tool("A"); + // Switching tools flushes the buffered run without an explicit flush(). + folder.tool("B"); + expect(emitted).toEqual(["A x2"]); + folder.flush(); + expect(emitted).toEqual(["A x2", "B"]); + }); + + it("treats flush with nothing pending as a no-op", () => { + const { folder, emitted } = makeFolder(); + folder.flush(); + folder.flush(); + expect(emitted).toEqual([]); + }); + + it("resets the count after each flushed run", () => { + const { folder, emitted } = makeFolder(); + folder.tool("A"); + folder.tool("A"); + folder.flush(); + folder.tool("A"); + folder.flush(); + expect(emitted).toEqual(["A x2", "A"]); + }); +}); From 7a11b23d16147030224c419ae79127abeab4d2d0 Mon Sep 17 00:00:00 2001 From: George Ng Date: Wed, 22 Jul 2026 01:16:41 -0700 Subject: [PATCH 2/8] Add action view to the tools showing tool JSON --- ts/packages/chat-ui/src/chatPanel.ts | 48 +++++ ts/packages/chat-ui/styles/chat.css | 59 +++++- ts/packages/chat-ui/test/chatPanel.spec.ts | 124 +++++++++++ .../dispatcher/src/reasoning/copilot.ts | 44 ++-- .../src/reasoning/reasoningLoopBase.ts | 106 +++++++++- .../dispatcher/test/toolRunFolder.spec.ts | 198 ++++++++++++++---- .../shell/src/renderer/assets/styles.less | 41 ++++ .../vscode-shell/src/webview/vscode-theme.css | 27 +++ 8 files changed, 580 insertions(+), 67 deletions(-) diff --git a/ts/packages/chat-ui/src/chatPanel.ts b/ts/packages/chat-ui/src/chatPanel.ts index 80319f024a..ecfbf79d69 100644 --- a/ts/packages/chat-ui/src/chatPanel.ts +++ b/ts/packages/chat-ui/src/chatPanel.ts @@ -950,6 +950,7 @@ export class ChatPanel { this.setupContextMenu(); this.setupProviderAffordances(); this.setupImageLightbox(); + this.setupReasoningToolCall(); } /** @@ -971,6 +972,46 @@ export class ChatPanel { }); } + /** + * Wire a delegated click handler for logged tool-call blocks. The reasoning + * engine renders every tool call (single or folded) as a + *
with a clickable summary and a hidden + *
 holding only that call's own JSON (an object for one call, an array
+     * for a folded run). Clicking the summary toggles that 
; the JSON is
+     * syntax-highlighted the first time it is shown so it reads like the clickable
+     * action JSON view (same highlightJson tokens). Each block owns its JSON
+     * inline, independent of the enclosing action's JSON view. Shared by the
+     * Electron and VS Code shells (both host ChatPanel).
+     */
+    private setupReasoningToolCall() {
+        this.messageDiv.addEventListener("click", (e) => {
+            const summary = (e.target as HTMLElement)?.closest?.(
+                ".reasoning-tool-call-summary",
+            );
+            if (!summary) return;
+            const pre = summary.parentElement?.querySelector(
+                "pre.reasoning-tool-call-json",
+            );
+            if (!pre) return;
+            if (pre.hasAttribute("hidden")) {
+                pre.removeAttribute("hidden");
+                summary.setAttribute("aria-expanded", "true");
+                // Highlight once: the 
 starts as raw (escaped) JSON text;
+                // its textContent is the un-escaped JSON, which highlightJson
+                // re-escapes.
+                if (pre.dataset.highlighted !== "true") {
+                    pre.innerHTML = sanitize(
+                        highlightJson(pre.textContent ?? ""),
+                    );
+                    pre.dataset.highlighted = "true";
+                }
+            } else {
+                pre.setAttribute("hidden", "");
+                summary.setAttribute("aria-expanded", "false");
+            }
+        });
+    }
+
     /**
      * Open a full-window image viewer overlaying the chat. Supports
      * wheel-zoom centered on the cursor, drag-to-pan, double-click toggle,
@@ -5746,6 +5787,13 @@ class AgentMessageContainer {
         }
         if (!text) return undefined;
 
+        // Content that is already a self-contained HTML block (the reasoning
+        // engine's 
thinking and batched-tool blocks) is + // pre-structured: any JSON inside is intentional markup, not a trailing + // action payload to hoist into the details panel. Splitting it at a + // "{"/"[" line would tear the
/
 apart mid-element.
+        if (text.trimStart().startsWith("<")) return undefined;
+
         // Strip ANSI to find the JSON boundary
         const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
         const lines = stripped.split("\n");
diff --git a/ts/packages/chat-ui/styles/chat.css b/ts/packages/chat-ui/styles/chat.css
index 32fd2af489..47a58ceef4 100644
--- a/ts/packages/chat-ui/styles/chat.css
+++ b/ts/packages/chat-ui/styles/chat.css
@@ -1724,21 +1724,26 @@ table.sc-kv-table.sc-card-fields {
    `!important` because the host theme (e.g. vscode-shell's overlay)
    applies `color: ... !important` to the parent bubble; without it
    inheritance makes the spans render as the bubble's foreground color. */
-.chat-message-details pre.chat-json .json-key {
+.chat-message-details pre.chat-json .json-key,
+.reasoning-tool-call pre.chat-json .json-key {
   color: #9c27b0 !important;
   font-weight: 600;
 }
-.chat-message-details pre.chat-json .json-string {
+.chat-message-details pre.chat-json .json-string,
+.reasoning-tool-call pre.chat-json .json-string {
   color: #0a8043 !important;
 }
-.chat-message-details pre.chat-json .json-number {
+.chat-message-details pre.chat-json .json-number,
+.reasoning-tool-call pre.chat-json .json-number {
   color: #1976d2 !important;
 }
-.chat-message-details pre.chat-json .json-bool {
+.chat-message-details pre.chat-json .json-bool,
+.reasoning-tool-call pre.chat-json .json-bool {
   color: #b85c00 !important;
   font-weight: 600;
 }
-.chat-message-details pre.chat-json .json-null {
+.chat-message-details pre.chat-json .json-null,
+.reasoning-tool-call pre.chat-json .json-null {
   color: #888 !important;
   font-style: italic;
 }
@@ -1747,6 +1752,50 @@ table.sc-kv-table.sc-card-fields {
   display: block;
 }
 
+/* Logged tool calls inside a reasoning step. Every tool call (single or a folded
+   run "Tool:  xN") renders as a clickable line (no disclosure widget) whose
+   tool name is inline code; clicking it reveals only that call's own JSON (same
+   look as the clickable action JSON view), independent of the enclosing action's
+   JSON. */
+.reasoning-tool-call {
+  margin: 2px 0;
+}
+.reasoning-tool-call-summary {
+  cursor: pointer;
+  display: inline-block;
+}
+.reasoning-tool-call-summary strong {
+  font-weight: 600;
+}
+/* Tool name chip — light-gray background, yellow/gold text (matches how inline
+   code renders in the reasoning text and how a single tool call looks). */
+.reasoning-tool-call-summary code {
+  background: rgba(127, 127, 127, 0.18);
+  color: #9a7d0a;
+  padding: 0 4px;
+  border-radius: 3px;
+  font-family: Consolas, "Courier New", monospace;
+  font-size: 0.95em;
+}
+.reasoning-tool-call-json {
+  margin: 4px 0 0 0;
+  padding: 8px;
+  background: rgba(127, 127, 127, 0.1);
+  border-radius: 6px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  overflow-x: auto;
+  max-width: 100%;
+  max-height: 400px;
+  overflow-y: auto;
+  font-family: Consolas, "Courier New", monospace;
+  font-size: 12px;
+  line-height: 1.4;
+}
+.reasoning-tool-call-json[hidden] {
+  display: none;
+}
+
 /* =========================================================================
    Choice Panel
    ========================================================================= */
diff --git a/ts/packages/chat-ui/test/chatPanel.spec.ts b/ts/packages/chat-ui/test/chatPanel.spec.ts
index de719a70b6..367c7bc3cf 100644
--- a/ts/packages/chat-ui/test/chatPanel.spec.ts
+++ b/ts/packages/chat-ui/test/chatPanel.spec.ts
@@ -405,3 +405,127 @@ describe("notifications (persistent, dismissable)", () => {
         expect(remaining[0].textContent).toContain("two");
     });
 });
+
+describe("reasoning tool calls (single + folded)", () => {
+    // Mirrors what the reasoning engine emits for a logged tool call: a
+    // 
with a clickable summary (tool name as + // inline code) and a hidden
 holding only that call's own JSON. Sent as
+    // a markdown display message (MarkdownIt passes the block-level HTML through
+    // verbatim). Folded runs carry a JSON array; single calls a lone object.
+    const foldedHtml =
+        '
' + + 'Tool: ' + + "read_conversation x2" + + '
"; + + const singleHtml = + '
' + + 'Tool: ' + + "get_conversation_info" + + '
"; + + function addRun(panel: ChatPanel, html: string) { + panel.addUserMessage("run a tool", "req-1"); + panel.addAgentMessage( + { type: "markdown", content: html, kind: "info" }, + "dispatcher.reasoningAction.copilot", + undefined, + "step", + "req-1", + ); + } + + it("renders a folded run's clickable summary and hidden JSON array", () => { + const { root, panel } = makePanel(); + addRun(panel, foldedHtml); + + const summary = root.querySelector( + ".reasoning-tool-call-summary", + ); + expect(summary).not.toBeNull(); + // Tool name is inline code, not split apart by the action-JSON splitter. + expect(summary!.querySelector("code")!.textContent).toBe( + "read_conversation", + ); + expect(summary!.textContent).toContain("x2"); + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + ); + expect(pre).not.toBeNull(); + expect(pre!.hasAttribute("hidden")).toBe(true); + const parsed = JSON.parse(pre!.textContent ?? ""); + expect(parsed).toHaveLength(2); + expect(parsed[1].arguments.offset).toBe(6); + }); + + it("renders a single tool call as its own clickable block with object JSON", () => { + const { root, panel } = makePanel(); + addRun(panel, singleHtml); + + const summary = root.querySelector( + ".reasoning-tool-call-summary", + )!; + expect(summary.querySelector("code")!.textContent).toBe( + "get_conversation_info", + ); + expect(summary.textContent).not.toContain("x"); + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + )!; + expect(pre.hasAttribute("hidden")).toBe(true); + // Only the relevant JSON for this one call — a lone object. + expect(JSON.parse(pre.textContent ?? "")).toEqual({ + tool: "get_conversation_info", + arguments: { limit: 1 }, + }); + }); + + it("keeps each call's JSON inline, not in the action-data details panel", () => { + const { root, panel } = makePanel(); + addRun(panel, singleHtml); + + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + )!; + // The JSON lives in the message content, decoupled from the clickable + // action JSON view (.chat-message-details) of the reasoningAction bubble. + expect(pre.closest(".chat-message-content")).not.toBeNull(); + expect(pre.closest(".chat-message-details")).toBeNull(); + }); + + it("toggles + syntax-highlights the JSON on click, highlighting once", () => { + const { root, panel } = makePanel(); + addRun(panel, foldedHtml); + + const summary = root.querySelector( + ".reasoning-tool-call-summary", + )!; + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + )!; + expect(pre.querySelector(".json-key")).toBeNull(); + + // First click reveals + highlights the JSON. + summary.click(); + expect(pre.hasAttribute("hidden")).toBe(false); + expect(pre.dataset.highlighted).toBe("true"); + expect(pre.querySelector(".json-key")).not.toBeNull(); + expect(pre.querySelector(".json-string")).not.toBeNull(); + + // Second click hides it again without re-highlighting / duplicating. + const highlightedHtml = pre.innerHTML; + summary.click(); + expect(pre.hasAttribute("hidden")).toBe(true); + expect(pre.innerHTML).toBe(highlightedHtml); + + // Third click re-reveals the already-highlighted body. + summary.click(); + expect(pre.hasAttribute("hidden")).toBe(false); + expect(pre.innerHTML).toBe(highlightedHtml); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts index a0501a768c..47abe955e5 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts @@ -515,7 +515,7 @@ function formatExecuteActionDisplay(input: unknown): string { // actionName out directly so the label shows the real action, not "?". const action = parseActionInput(toolInput?.action); const actionName = getStringProp(action, "actionName") ?? "?"; - return `**Tool:** execute_action — \`${schema}.${actionName}\``; + return `**Tool:** \`execute_action\` — \`${schema}.${actionName}\``; } function getPrimaryToolArg(input: unknown): string | undefined { @@ -537,27 +537,29 @@ function formatToolCallDisplay(toolName: string, input: unknown): string { case "discover_actions": { const schema = getStringProp(toolInput, "schemaName") ?? JSON.stringify(input); - return `**Tool:** discover_actions — schema: \`${schema}\``; + return `**Tool:** \`discover_actions\` — schema: \`${schema}\``; } case "execute_action": return formatExecuteActionDisplay(input); case "search_memory": { const question = getStringProp(toolInput, "question") ?? JSON.stringify(input); - return `**Tool:** search_memory — \`${question}\``; + return `**Tool:** \`search_memory\` — \`${question}\``; } case "remember": - return `**Tool:** remember`; + return `**Tool:** \`remember\``; } // Built-in tools (shell, github/fs/*, github/search/*, ...): show the // primary argument so parallel or similar calls are distinguishable - // instead of rendering as identical "Tool: " bubbles. + // instead of rendering as identical "Tool: " bubbles. The tool name + // is rendered as inline code so it reads as a highlighted chip (matching a + // single tool call and the folded-batch summary). const primaryArg = getPrimaryToolArg(input); if (primaryArg) { - return `**Tool:** ${toolName} — \`${primaryArg}\``; + return `**Tool:** \`${toolName}\` — \`${primaryArg}\``; } - return `**Tool:** ${toolName}`; + return `**Tool:** \`${toolName}\``; } /** @@ -1096,11 +1098,13 @@ async function executeReasoningWithoutPlanning( context.actionIO.appendDisplay("Thinking...", "temporary"); const displayMode = resolveReasoningDisplayMode(context); // Fold runs of identical, back-to-back tool-call lines into one "xN" line. - const toolFolder = new ToolRunFolder((content) => - context.actionIO.appendDisplay( - { type: "markdown", content, kind: "info" }, - displayMode, - ), + const toolFolder = new ToolRunFolder( + (content) => + context.actionIO.appendDisplay( + { type: "markdown", content, kind: "info" }, + displayMode, + ), + formatToolCallDisplay, ); const client = await getCopilotClient(context.sessionContext.agentContext); @@ -1233,7 +1237,7 @@ async function executeReasoningWithoutPlanning( event.data?.parameters || {}; debug(`Tool execution started: ${toolName}`); - toolFolder.tool(formatToolCallDisplay(toolName, parameters)); + toolFolder.tool(toolName, parameters); }, ); @@ -1375,11 +1379,13 @@ async function executeReasoningWithTracing( context.actionIO.appendDisplay("Thinking...", "temporary"); const displayMode = resolveReasoningDisplayMode(context); // Fold runs of identical, back-to-back tool-call lines into one "xN" line. - const toolFolder = new ToolRunFolder((content) => - context.actionIO.appendDisplay( - { type: "markdown", content, kind: "info" }, - displayMode, - ), + const toolFolder = new ToolRunFolder( + (content) => + context.actionIO.appendDisplay( + { type: "markdown", content, kind: "info" }, + displayMode, + ), + formatToolCallDisplay, ); const client = await getCopilotClient( @@ -1524,7 +1530,7 @@ async function executeReasoningWithTracing( // Record tool call for trace tracer.recordToolCall(toolName, parameters); - toolFolder.tool(formatToolCallDisplay(toolName, parameters)); + toolFolder.tool(toolName, parameters); }, ); diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts index bc0b1f7488..4bbbc883b6 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts @@ -176,6 +176,76 @@ export function formatThinkingDisplay(thinkingText: string): string { ].join(""); } +// One logged tool call: the tool name and the arguments it was invoked with. +// Each call renders as a click-to-expand block; a folded run reveals a JSON +// array of its calls, a single call reveals just its own object. +export interface ToolCallDetail { + tool: string; + args: unknown; +} + +// Convert the limited markdown our tool-call display lines use (**bold** and +// `code`) to HTML so the folded line can be shown inside the summary. The input +// comes from formatToolCallDisplay (our own strings, not raw user text), so +// escaping first and then applying these two rules is safe. +function inlineMarkdownToHtml(text: string): string { + const escaped = text + .replace(/&/g, "&") + .replace(//g, ">"); + return escaped + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +function escapeHtmlText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** + * Render a logged tool call (single or folded) as a click-to-expand block. The + * summary shows the display line (tool name as inline code, plus "xN" when + * folded); clicking it reveals only that call's own JSON - a single object for + * one call, an array for a folded run. The
 starts hidden and is toggled by
+ * chat-ui's delegated click handler (shared by the Electron and VS Code shells),
+ * so there is no native 
disclosure widget and the JSON is never hoisted + * into the enclosing action's JSON view. + */ +export function formatToolRun( + displayLine: string, + details: ToolCallDetail[], +): string { + const payload = + details.length === 1 + ? { tool: details[0].tool, arguments: details[0].args } + : details.map((d) => ({ tool: d.tool, arguments: d.args })); + let json: string; + try { + json = JSON.stringify(payload, undefined, 2); + } catch { + // Arguments should always be JSON-serializable (they arrive as JSON + // from the reasoning SDK), but never let a bad payload break the run. + json = JSON.stringify( + details.length === 1 + ? { tool: details[0].tool } + : details.map((d) => ({ tool: d.tool })), + ); + } + return [ + `
`, + `${inlineMarkdownToHtml( + displayLine, + )}`, + ``, + `
`, + ].join(""); +} + /** * Collapses runs of identical, back-to-back tool-call lines into a single line * with an "xN" suffix (e.g. three identical calls render as "... x3"). Only @@ -188,36 +258,56 @@ export function formatThinkingDisplay(thinkingText: string): string { * - route every tool-call line through tool() * - call flush() before emitting any non-tool display * - call flush() once more after the reasoning stream completes + * + * Every run - single or folded - is emitted as its own click-to-expand block + * (see formatToolRun): the summary is the display line ("xN" only when folded) + * and the hidden body is that run's own JSON. */ export class ToolRunFolder { private pending: string | undefined; private count = 0; + private details: ToolCallDetail[] = []; - constructor(private readonly emit: (content: string) => void) {} + // `format` turns a raw tool call into its display line; it also serves as + // the folding key (identical display = same run). Injected rather than + // imported because each reasoning engine formats tool calls differently and + // its formatter lives in the engine module (copilot.ts), which depends on + // this file, not the other way around. + constructor( + private readonly emit: (content: string) => void, + private readonly format: (toolName: string, args: unknown) => string, + ) {} - // Record a tool-call line, folding it into the current run when identical - // to the immediately preceding one. - tool(display: string): void { + // Record a tool call, folding it into the current run when its display line + // matches the immediately preceding one. The raw name and arguments are + // buffered so the run can reveal the full JSON on demand. + tool(toolName: string, args: unknown): void { + const display = this.format(toolName, args); if (this.pending === display) { this.count++; + this.details.push({ tool: toolName, args }); return; } this.flush(); this.pending = display; this.count = 1; + this.details = [{ tool: toolName, args }]; } - // Emit the buffered run (if any) and reset. A run of two or more identical - // calls gets an "xN" suffix; a single call is emitted unchanged. + // Emit the buffered run (if any) as a click-to-expand block and reset. A run + // of two or more identical calls gets an "xN" summary; a single call shows + // its line unchanged. Both reveal their own JSON on click. flush(): void { if (this.pending === undefined) { return; } - const content = + const line = this.count > 1 ? `${this.pending} x${this.count}` : this.pending; + const details = this.details; this.pending = undefined; this.count = 0; - this.emit(content); + this.details = []; + this.emit(formatToolRun(line, details)); } } diff --git a/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts b/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts index 7371321e40..c133dee006 100644 --- a/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts @@ -1,72 +1,155 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ToolRunFolder } from "../src/reasoning/reasoningLoopBase.js"; +import { + ToolRunFolder, + formatToolRun, +} from "../src/reasoning/reasoningLoopBase.js"; // Collect the strings the folder emits so each test can assert on the exact -// sequence of rendered tool-call lines. -function makeFolder(): { folder: ToolRunFolder; emitted: string[] } { +// sequence of rendered tool-call blocks. The injected formatter defaults to the +// identity, so a call's tool name doubles as its display / folding key while its +// arguments vary independently (as real folded calls do). +function makeFolder( + format: (tool: string, args: unknown) => string = (t) => t, +): { folder: ToolRunFolder; emitted: string[] } { const emitted: string[] = []; - const folder = new ToolRunFolder((content) => emitted.push(content)); + const folder = new ToolRunFolder( + (content) => emitted.push(content), + format, + ); return { folder, emitted }; } +// Every tool call (single or folded) renders as a
+// with a clickable summary and a hidden
 holding that call's own JSON (an
+// object for one call, an array for a folded run). Parse both out for assertions.
+function parseToolRun(content: string): {
+    summary: string;
+    json: unknown;
+    tools: string[];
+} {
+    expect(content).toContain('
'); + expect(content).toContain(''); + expect(content).toContain( + '