diff --git a/src/tui-opentui/mcp-view.test.ts b/src/tui-opentui/mcp-view.test.ts index 403b603ad..d9d9a9276 100644 --- a/src/tui-opentui/mcp-view.test.ts +++ b/src/tui-opentui/mcp-view.test.ts @@ -214,6 +214,36 @@ describe("collapsed tool results", () => { expect(row.summary).toBe("Read Linear project Alpha") }) + test("a sub-agent report collapses to its summary line, envelope and headings stripped from the detail", () => { + const row = toolResultRow({ + name: "task", + content: + 'Sub-agent "explore callers" reported:\n\n## Summary\nFound 3 call sites in src/tui-opentui.\n\n## Findings\n- shell.ts line 40\n- diff.ts line 12', + }) + expect(row.summary).toBe("Found 3 call sites in src/tui-opentui.") + expect(isCollapsibleRow(row)).toBe(true) + const detailText = (row.detail ?? []) + .map((line) => line.map((seg) => seg.text).join("")) + .join("\n") + expect(detailText).not.toContain("Sub-agent") + expect(detailText).not.toContain("## ") + expect(detailText).toContain("Found 3 call sites in src/tui-opentui.") + expect(detailText).toContain("Findings") + }) + + test("a short sub-agent report is still curated, not left as raw envelope", () => { + const row = toolResultRow({ + name: "task", + content: 'Sub-agent "quick check" reported:\n\n## Summary\nAll clear.', + }) + expect(row.summary).toBe("All clear.") + const detailText = (row.detail ?? []) + .map((line) => line.map((seg) => seg.text).join("")) + .join("\n") + expect(detailText).not.toContain("Sub-agent") + expect(detailText).not.toContain("## ") + }) + test("an error result is neither summarised nor collapsed", () => { const row = toolResultRow({ name: "mcp__linear__list_projects", diff --git a/src/tui-opentui/mcp-view.ts b/src/tui-opentui/mcp-view.ts index ba5dbb36f..b1f1ff892 100644 --- a/src/tui-opentui/mcp-view.ts +++ b/src/tui-opentui/mcp-view.ts @@ -26,7 +26,7 @@ import { recordScalar, type McpRecords, } from "../tui/mcp-result-format.js" -import { summarizeToolResult } from "../tui/tool-formatter.js" +import { stripTaskReportEnvelope, summarizeToolResult } from "../tui/tool-formatter.js" import type { StreamRow, StyledBodyLine } from "./stream.js" export type McpTone = @@ -280,6 +280,9 @@ const DETAIL_TEXT_MAX = 72 /** The tool a capability search arrives through; its result is a catalogue. */ const TOOL_SEARCH_TOOL = "tool_search" +/** The tool a sub-agent dispatch arrives through; its result is a report. */ +const TASK_TOOL = "task" + function titleCase(word: string): string { return word.length === 0 ? word : `${word[0]!.toUpperCase()}${word.slice(1)}` } @@ -448,6 +451,13 @@ function recordSummary( */ function resultSummary(input: ToolResultRowInput): ResultSummary | null { const content = input.content + if (input.name === TASK_TOOL) { + // A worker's reply wraps a "Sub-agent ... reported:" / "## Summary" + // envelope. The one-line preview already strips it; the expanded detail + // must too, or the raw envelope and heading markers leak as plain text. + const { preview } = summarizeToolResult(input.name, content) + return { summary: preview, detail: bodyLines(stripTaskReportEnvelope(content)) } + } if (input.name === TOOL_SEARCH_TOOL) { const catalogue = toolCatalogueSummary(content) if (catalogue !== null) return catalogue diff --git a/src/tui/tool-formatter.ts b/src/tui/tool-formatter.ts index 102ab3d4b..4fda0d815 100644 --- a/src/tui/tool-formatter.ts +++ b/src/tui/tool-formatter.ts @@ -424,6 +424,22 @@ function pathFromResult(toolName: string, content: string): string | null { return null; } +/** + * Task tool results wrap a sub-agent's reply in `Sub-agent "desc" reported:` + * plus raw `## ` markdown headings. The transcript's expanded detail view + * renders plain text, so an unstripped heading would show its literal `##` — + * drop the envelope and heading markers, keeping the report's own words. + */ +export function stripTaskReportEnvelope(content: string): string { + const trimmed = content.trim(); + const reported = trimmed.match(/^Sub-agent "([^"]*)" reported:\s*([\s\S]*)$/i); + const body = (reported?.[2] ?? trimmed).trim(); + return body + .split("\n") + .map((line) => line.replace(/^##\s+/, "")) + .join("\n"); +} + // Task tool results are either "Sub-agent \"desc\" reported:\n\n## Summary\n..." // or a cancel notice. Pull a one-line human preview without leaking markdown headers. function summarizeTaskResultPreview(content: string): string {