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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user