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.
This commit is contained in:
can1357
2026-05-15 03:14:52 +02:00
parent 2ea1bd96b8
commit f0ff398607
7 changed files with 163 additions and 10 deletions
+6 -3
View File
@@ -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<TArgs>(config: ShellRendererConfig<TArgs>) {
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;
@@ -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);
+10 -2
View File
@@ -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) => {
@@ -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
// =============================================================================
+4 -1
View File
@@ -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);
+3 -2
View File
@@ -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) {
@@ -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);
});
});