From f0ff398607093166bf215ecb8ff0661b809c74e8 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 15 May 2026 03:14:52 +0200 Subject: [PATCH] fix(coding-agent/tools): stripped duplicate output notices from TUI tool renderers - Added stripOutputNotice to output-meta to remove appended truncation notices when output metadata is available. - Updated bash, eval, browser, read, and ssh renderers to strip the notice before display so the styled warning line is not duplicated. - Left fallback behavior unchanged so outputs without a notice continue through unchanged. --- packages/coding-agent/src/tools/bash.ts | 9 +- .../coding-agent/src/tools/browser/render.ts | 4 +- packages/coding-agent/src/tools/eval.ts | 12 +- .../coding-agent/src/tools/output-meta.ts | 26 ++++ packages/coding-agent/src/tools/read.ts | 5 +- packages/coding-agent/src/tools/ssh.ts | 5 +- .../test/tools/strip-output-notice.test.ts | 112 ++++++++++++++++++ 7 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/tools/strip-output-notice.test.ts diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 41563a0cd..f2a73306f 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -21,7 +21,7 @@ import { applyBashFixups, formatBashFixupNotice } from "./bash-command-fixup"; import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive"; import { checkBashInterception } from "./bash-interceptor"; import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls"; -import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; +import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta"; import { resolveToCwd } from "./path-utils"; import { formatToolWorkingDirectory, replaceTabs } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; @@ -977,8 +977,11 @@ export function createShellRenderer(config: ShellRendererConfig) { const expanded = renderContext?.expanded ?? options.expanded; const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES; - // Get output from context (preferred) or fall back to result content - const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? ""; + // Get output from context (preferred) or fall back to result content. + // Strip the LLM-facing notice appended by wrappedExecute so we don't + // double-print it alongside the styled warning line below. + const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? ""; + const output = stripOutputNotice(rawOutput, details?.meta); const displayOutput = output.trimEnd(); const showingFullOutput = expanded && renderContext?.isFullOutput === true; diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index 8eda81b4f..91c362917 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../../extensibility/custom-tools/types import type { Theme } from "../../modes/theme/theme"; import { Hasher, renderCodeCell, renderStatusLine } from "../../tui"; import type { BrowserToolDetails } from "../browser"; -import { formatStyledTruncationWarning } from "../output-meta"; +import { formatStyledTruncationWarning, stripOutputNotice } from "../output-meta"; import { replaceTabs, shortenPath } from "../render-utils"; const BROWSER_DEFAULT_PREVIEW_LINES = 10; @@ -195,7 +195,7 @@ export const browserToolRenderer = { const details = result.details; const action = details?.action ?? argsObj.action; const isError = result.isError === true; - const output = extractTextOutput(result.content); + const output = stripOutputNotice(extractTextOutput(result.content), details?.meta); if (action === "run") { let component = renderRunCell(argsObj, details, options, output, isError, theme); diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index f8ed5c0ea..85adcce1d 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -16,7 +16,12 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" }; import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output"; import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui"; import { resolveEvalBackends, type ToolSession } from "."; -import { formatStyledTruncationWarning, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta"; +import { + formatStyledTruncationWarning, + resolveOutputMaxColumns, + resolveOutputSinkHeadBytes, + stripOutputNotice, +} from "./output-meta"; import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -922,8 +927,11 @@ export const evalToolRenderer = { ): Component { const details = result.details; - const output = + const rawOutput = options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd(); + // Strip the LLM-facing notice (appended by wrappedExecute) before display; + // the styled `warningLine` below carries the same text in ⟨…⟩ form. + const output = stripOutputNotice(rawOutput, details?.meta).trimEnd(); const jsonOutputs = details?.jsonOutputs ?? []; const jsonLines = jsonOutputs.flatMap((value, index) => { diff --git a/packages/coding-agent/src/tools/output-meta.ts b/packages/coding-agent/src/tools/output-meta.ts index 04dca5dff..942328072 100644 --- a/packages/coding-agent/src/tools/output-meta.ts +++ b/packages/coding-agent/src/tools/output-meta.ts @@ -489,6 +489,32 @@ export function formatStyledTruncationWarning(meta: OutputMeta | undefined, them return theme.fg("warning", wrapBrackets(message, theme)); } +/** + * Strip the trailing notice that {@link appendOutputNotice} bakes into the + * LLM-facing content body. Renderers should call this before printing + * `result.content` text in the TUI, because they emit a styled warning line of + * their own; without this, users see the same `[Showing lines …]` string twice + * (once verbatim from the body, once as the styled `⟨…⟩` warning). + * + * Safe to call eagerly: returns the input unchanged when no notice is present + * (e.g. during streaming, before {@link wrappedExecute} runs). + */ +export function stripOutputNotice(text: string, meta: OutputMeta | undefined): string { + const notice = formatOutputNotice(meta); + if (!notice) return text; + // Trim trailing whitespace from `text` and from the notice itself so we + // match regardless of whether: (a) the caller already trimEnd()'d, (b) + // extra blank lines slipped in after the notice (diagnostics blocks add + // `\n\n` between sections, OutputSink may pad), or (c) neither. Returns + // the prefix before the notice so the caller can re-trim as needed. + const trimmedText = text.trimEnd(); + const trimmedNotice = notice.trimEnd(); + if (trimmedText.endsWith(trimmedNotice)) { + return trimmedText.slice(0, -trimmedNotice.length); + } + return text; +} + // ============================================================================= // Tool wrapper // ============================================================================= diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 9f89f611e..687fba964 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -60,6 +60,7 @@ import { formatStyledTruncationWarning, type OutputMeta, resolveOutputMaxColumns, + stripOutputNotice, } from "./output-meta"; import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils"; import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils"; @@ -2194,7 +2195,9 @@ export const readToolRenderer = { const rawText = result.content?.find(c => c.type === "text")?.text ?? ""; // Prefer structured `displayContent` from details when available so the TUI // shows clean file content (no model-only hashline anchors) without parsing the formatted text. - const contentText = details?.displayContent?.text ?? rawText; + // Fall back to the raw text, but strip the LLM-facing notice so it doesn't + // echo next to the styled warning line below. + const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta); const imageContent = result.content?.find(c => c.type === "image"); const rawPath = args?.file_path || args?.path || ""; const filePath = shortenPath(rawPath); diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 8175c15a1..d4dcfa31e 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -16,7 +16,7 @@ import { executeSSH } from "../ssh/ssh-executor"; import { renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; import type { ToolSession } from "."; -import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; +import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta"; import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; @@ -253,7 +253,8 @@ export const sshToolRenderer = { render: (width: number): string[] => { // REACTIVE: read mutable options at render time const { expanded, renderContext } = options; - const output = textContent.trimEnd(); + // Strip LLM-facing notice so we don't echo it next to the styled warning. + const output = stripOutputNotice(textContent, details?.meta).trimEnd(); const outputLines: string[] = []; if (output) { diff --git a/packages/coding-agent/test/tools/strip-output-notice.test.ts b/packages/coding-agent/test/tools/strip-output-notice.test.ts new file mode 100644 index 000000000..1a5a03018 --- /dev/null +++ b/packages/coding-agent/test/tools/strip-output-notice.test.ts @@ -0,0 +1,112 @@ +/** + * Round-trip contract between `appendOutputNotice` (via `formatOutputNotice`) + * and `stripOutputNotice`: anything the tool wrapper bakes into the LLM-facing + * content body, the TUI renderer must be able to peel off so the styled + * `⟨…⟩` warning line doesn't double-print next to the verbatim body text. + * + * Regression: bash/eval/ssh/browser/read all printed the same `[Showing …]` + * string twice — once from the body content, once as the styled warning line. + */ +import { describe, expect, it } from "bun:test"; +import { formatOutputNotice, type OutputMeta, stripOutputNotice } from "../../src/tools/output-meta"; + +const truncation: OutputMeta = { + truncation: { + direction: "middle", + truncatedBy: "middle", + totalLines: 8, + totalBytes: 320, + outputLines: 4, + outputBytes: 105, + headRange: { start: 1, end: 2 }, + tailRange: { start: 7, end: 8 }, + elidedLines: 4, + elidedBytes: 215, + }, +}; + +const tailTruncation: OutputMeta = { + truncation: { + direction: "tail", + truncatedBy: "bytes", + totalLines: 100, + totalBytes: 10_000, + outputLines: 40, + outputBytes: 4_000, + maxBytes: 4_000, + shownRange: { start: 61, end: 100 }, + artifactId: "abc123", + }, +}; + +const limitsOnly: OutputMeta = { + limits: { matchLimit: { reached: 50, suggestion: 100 } }, +}; + +describe("stripOutputNotice", () => { + it("removes the exact notice appended by the wrapper for middle elision", () => { + const body = "line1\nline2\n[… 4 lines elided (215B) …]\nline7\nline8"; + const notice = formatOutputNotice(truncation); + const combined = body + notice; + + // Round-trip: the wrapper appends, the renderer peels off exactly. + expect(stripOutputNotice(combined, truncation)).toBe(body); + }); + + it("removes the notice for tail truncation including artifact reference", () => { + const body = "long output…"; + const notice = formatOutputNotice(tailTruncation); + expect(notice).toContain("artifact://abc123"); + + expect(stripOutputNotice(body + notice, tailTruncation)).toBe(body); + }); + + it("removes the notice for limit-only meta (no truncation)", () => { + const body = "results…"; + const notice = formatOutputNotice(limitsOnly); + expect(notice).toContain("matches limit reached"); + + expect(stripOutputNotice(body + notice, limitsOnly)).toBe(body); + }); + + it("matches the trimEnd()'d body the renderer actually sees", () => { + // bash.ts/eval.ts call `.trimEnd()` on the body before passing it in. + // The notice itself ends with `]`, so trimEnd is a no-op on its tail; + // confirm the strip still succeeds when the renderer hands us either + // the trimmed or untrimmed form. + const body = "the output"; + const combined = `${body}${formatOutputNotice(truncation)}\n\n`; + + // renderer trims, then strips + expect(stripOutputNotice(combined.trimEnd(), truncation)).toBe(body); + // renderer strips first + expect(stripOutputNotice(combined, truncation).trimEnd()).toBe(body); + }); + + it("returns input unchanged when meta is undefined", () => { + expect(stripOutputNotice("plain text", undefined)).toBe("plain text"); + }); + + it("returns input unchanged when meta has no notice-emitting fields", () => { + // e.g. meta carries only `source` info; formatOutputNotice yields "". + const sourceOnly: OutputMeta = { source: { type: "path", value: "/tmp/x" } }; + expect(formatOutputNotice(sourceOnly)).toBe(""); + expect(stripOutputNotice("plain text", sourceOnly)).toBe("plain text"); + }); + + it("returns input unchanged when body does not actually carry the notice (streaming case)", () => { + // During streaming, `renderContext.output` is the live sink content + // before wrappedExecute has appended anything. Calling stripOutputNotice + // eagerly must not corrupt that prefix. + const streaming = "partial output so far…"; + expect(stripOutputNotice(streaming, truncation)).toBe(streaming); + }); + + it("only strips the trailing occurrence, not a coincidental earlier match", () => { + const noticeText = formatOutputNotice(truncation); + // The same notice text appearing mid-body (unlikely but possible if the + // command literally printed it) must be preserved when not at the tail. + const body = `prefix${noticeText} middle suffix`; + expect(stripOutputNotice(body, truncation)).toBe(body); + }); +});