From 28b9ce7a0c9f14ec7d10b8707624ef61d2f84fbf Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 11:19:11 +0200 Subject: [PATCH] feat(coding-agent): added middle-elision caps to OutputSink truncation - Added `tools.artifactHeadBytes` and `tools.outputMaxColumns` settings with defaults in `SETTINGS_SCHEMA`. - Expanded `OutputSink` with `headBytes`/`maxColumns` and middle truncate logic with elision markers and tracking. - Updated output-meta to resolve sink settings, emit truncation metrics, and use `truncateMiddle` for spills. - Integrated head and column limits into JS/Python/Bash/SSH/read output flows, with `:raw` skipping read truncation. - Documented new output middle-elision and column-cap behavior in `CHANGELOG.md`. - Added truncation tests for `OutputSink`, `truncateMiddle`, and read-tool line handling. --- packages/coding-agent/CHANGELOG.md | 5 + .../src/config/settings-schema.ts | 40 +++ packages/coding-agent/src/eval/js/executor.ts | 3 + packages/coding-agent/src/eval/py/executor.ts | 5 + .../coding-agent/src/exec/bash-executor.ts | 3 + .../src/session/streaming-output.ts | 320 +++++++++++++++++- packages/coding-agent/src/ssh/ssh-executor.ts | 5 + .../src/tools/bash-interactive.ts | 10 +- packages/coding-agent/src/tools/eval.ts | 4 +- .../coding-agent/src/tools/output-meta.ts | 211 ++++++++++-- packages/coding-agent/src/tools/read.ts | 39 ++- .../test/streaming-output.test.ts | 148 ++++++++ packages/coding-agent/test/tools.test.ts | 27 ++ 13 files changed, 766 insertions(+), 54 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6cd261729..1b6fee6af 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`. +- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable. + ## [15.0.0] - 2026-05-13 ### Breaking Changes diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index adc024b73..5fd335806 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -460,6 +460,46 @@ export const SETTINGS_SCHEMA = { ], }, }, + "tools.artifactHeadBytes": { + type: "number", + default: 20, + ui: { + tab: "tools", + label: "Artifact head size (KB)", + description: + "Amount of head content kept inline alongside the tail when output spills to artifact (middle elision). 0 disables — keep tail only.", + options: [ + { value: "0", label: "0 KB", description: "Disabled; tail-only truncation" }, + { value: "1", label: "1 KB", description: "~250 tokens" }, + { value: "2.5", label: "2.5 KB", description: "~625 tokens" }, + { value: "5", label: "5 KB", description: "~1.25K tokens" }, + { value: "10", label: "10 KB", description: "~2.5K tokens" }, + { value: "20", label: "20 KB", description: "Default; ~5K tokens" }, + { value: "50", label: "50 KB", description: "~12.5K tokens" }, + { value: "100", label: "100 KB", description: "~25K tokens" }, + { value: "200", label: "200 KB", description: "~50K tokens" }, + ], + }, + }, + "tools.outputMaxColumns": { + type: "number", + default: 768, + ui: { + tab: "tools", + label: "Output column cap", + description: + "Per-line byte cap for streaming tool outputs (bash, ssh, python, js eval) and `read`. Lines wider than this are ellipsis-truncated; remaining bytes up to the next newline are dropped. 0 disables.", + options: [ + { value: "0", label: "Off", description: "No per-line cap" }, + { value: "256", label: "256", description: "Tight" }, + { value: "512", label: "512" }, + { value: "768", label: "768", description: "Default" }, + { value: "1024", label: "1024" }, + { value: "2048", label: "2048" }, + { value: "4096", label: "4096", description: "Loose" }, + ], + }, + }, "tools.artifactTailLines": { type: "number", default: 500, diff --git a/packages/coding-agent/src/eval/js/executor.ts b/packages/coding-agent/src/eval/js/executor.ts index 76fb6fc02..4ea5d97e3 100644 --- a/packages/coding-agent/src/eval/js/executor.ts +++ b/packages/coding-agent/src/eval/js/executor.ts @@ -1,5 +1,6 @@ import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output"; import type { ToolSession } from "../../tools"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta"; import { executeInVmContext, type JsDisplayOutput } from "./context-manager"; export interface JsExecutorOptions { @@ -49,6 +50,8 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi artifactPath: options.artifactPath, artifactId: options.artifactId, spillThreshold: DEFAULT_MAX_BYTES, + headBytes: resolveOutputSinkHeadBytes(options.session.settings), + maxColumns: resolveOutputMaxColumns(options.session.settings), onChunk: chunk => options.onChunk?.(chunk), }); const timeoutMs = getExecutionTimeoutMs(options); diff --git a/packages/coding-agent/src/eval/py/executor.ts b/packages/coding-agent/src/eval/py/executor.ts index e6c11b9ca..2011349b1 100644 --- a/packages/coding-agent/src/eval/py/executor.ts +++ b/packages/coding-agent/src/eval/py/executor.ts @@ -1,6 +1,8 @@ import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { Settings } from "../../config/settings"; import { OutputSink } from "../../session/streaming-output"; import type { ToolSession } from "../../tools"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta"; import type { JsStatusEvent } from "../js/shared/types"; import type { KernelDisplayOutput } from "./display"; import { @@ -815,10 +817,13 @@ async function executeWithKernel( code: string, options: PythonExecutorOptions | undefined, ): Promise { + const settings = await Settings.init(); const sink = new OutputSink({ onChunk: options?.onChunk, artifactPath: options?.artifactPath, artifactId: options?.artifactId, + headBytes: resolveOutputSinkHeadBytes(settings), + maxColumns: resolveOutputMaxColumns(settings), }); const displayOutputs: KernelDisplayOutput[] = []; const deadlineMs = getExecutionDeadlineMs(options); diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 90edeb928..b1709a38d 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -7,6 +7,7 @@ import * as fs from "node:fs/promises"; import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives"; import { Settings, type ShellMinimizerSettings } from "../config/settings"; import { OutputSink } from "../session/streaming-output"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; import { NON_INTERACTIVE_ENV } from "./non-interactive-env"; @@ -94,6 +95,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions onChunk: options?.onChunk, artifactPath: options?.artifactPath, artifactId: options?.artifactId, + headBytes: resolveOutputSinkHeadBytes(settings), + maxColumns: resolveOutputMaxColumns(settings), // Throttle the streaming preview callback to avoid saturating the // event loop when commands produce massive output (e.g. seq 1 50M). chunkThrottleMs: options?.onChunk ? 50 : 0, diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index 6e1e92b93..2bca34476 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -12,6 +12,7 @@ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line const NL = "\n"; +const ELLIPSIS = "…"; // ============================================================================= // Interfaces @@ -24,6 +25,14 @@ export interface OutputSummary { totalBytes: number; outputLines: number; outputBytes: number; + /** Bytes elided from the middle when head-retain mode is active. */ + elidedBytes?: number; + /** Lines elided from the middle when head-retain mode is active. */ + elidedLines?: number; + /** Bytes dropped by the per-line column cap (sum across all lines). */ + columnDroppedBytes?: number; + /** Number of distinct lines that hit the per-line column cap. */ + columnTruncatedLines?: number; /** Artifact ID for internal URL access (artifact://) when truncated */ artifactId?: string; } @@ -31,7 +40,21 @@ export interface OutputSummary { export interface OutputSinkOptions { artifactPath?: string; artifactId?: string; + /** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */ spillThreshold?: number; + /** + * When > 0, the sink keeps the first `headBytes` of output in addition to + * the rolling tail window. Output between the two windows is elided + * (middle elision). Default 0 = tail-only behavior. + */ + headBytes?: number; + /** + * Per-line byte cap. When > 0, lines wider than `maxColumns` bytes are + * truncated with an ellipsis at write time; remaining bytes up to the next + * `\n` are dropped. Cap state persists across chunks so split-mid-line + * writes still respect the budget. Default 0 = no per-line cap. + */ + maxColumns?: number; onChunk?: (chunk: string) => void; /** Minimum ms between onChunk calls. 0 = every chunk (default). */ chunkThrottleMs?: number; @@ -40,11 +63,15 @@ export interface OutputSinkOptions { export interface TruncationResult { content: string; truncated?: boolean; - truncatedBy?: "lines" | "bytes"; + truncatedBy?: "lines" | "bytes" | "middle"; totalLines: number; totalBytes: number; outputLines?: number; outputBytes?: number; + /** Bytes elided from the middle (truncateMiddle only). */ + elidedBytes?: number; + /** Lines elided from the middle (truncateMiddle only). */ + elidedLines?: number; lastLinePartial?: boolean; firstLineExceedsLimit?: boolean; } @@ -54,6 +81,16 @@ export interface TruncationOptions { maxLines?: number; /** Maximum number of bytes (default: 50KB) */ maxBytes?: number; + /** + * For `truncateMiddle`: bytes reserved for the head window. The tail + * window receives `maxBytes - maxHeadBytes`. Default `floor(maxBytes/2)`. + */ + maxHeadBytes?: number; + /** + * For `truncateMiddle`: lines reserved for the head window. The tail + * window receives `maxLines - maxHeadLines`. Default `floor(maxLines/2)`. + */ + maxHeadLines?: number; } /** Result from byte-level truncation helpers. */ @@ -425,6 +462,90 @@ export function truncateTail(content: string, options: TruncationOptions = {}): }; } +// ============================================================================= +// Middle elision (keep head + tail, drop middle) +// ============================================================================= + +/** + * Format the inline marker substituted for the elided middle region. + * Returned without surrounding newlines so callers can position it freely. + */ +export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string { + const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`; + return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`; +} + +/** + * Truncate content keeping a head window and a tail window, eliding the middle. + * + * The combined output is `\n\n` when truncation is needed. + * `maxHeadBytes` defaults to `floor(maxBytes / 2)`; the tail receives the + * remainder. Falls back to `truncateTail` / `truncateHead` if either side's + * budget is empty or the content already fits. + */ +export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2); + const tailBytes = Math.max(0, maxBytes - headBytes); + const headLines = options.maxHeadLines ?? Math.max(1, Math.floor(maxLines / 2)); + const tailLines = Math.max(0, maxLines - headLines); + + const totalBytes = Buffer.byteLength(content, "utf-8"); + const totalLines = countNewlines(content) + 1; + + if (totalBytes <= maxBytes && totalLines <= maxLines) { + return noTruncResult(content, totalLines, totalBytes); + } + + // Degenerate budgets → fall back to one-sided truncation. + if (headBytes <= 0 || headLines <= 0) { + return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines }); + } + if (tailBytes <= 0 || tailLines <= 0) { + return truncateHead(content, { maxBytes: headBytes, maxLines: headLines }); + } + + const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines }); + const tail = truncateTail(content, { maxBytes: tailBytes, maxLines: tailLines }); + + const headLinesKept = head.outputLines ?? 0; + const tailLinesKept = tail.outputLines ?? 0; + const headBytesKept = head.outputBytes ?? Buffer.byteLength(head.content, "utf-8"); + const tailBytesKept = tail.outputBytes ?? Buffer.byteLength(tail.content, "utf-8"); + + // Head unusable (first line exceeds budget) → tail-only. + if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail; + // Tail unusable → head-only. + if (tailLinesKept === 0) return head; + // Windows overlap → no meaningful elision; return content untruncated. + if (headLinesKept + tailLinesKept >= totalLines) { + return noTruncResult(content, totalLines, totalBytes); + } + + const elidedLines = totalLines - headLinesKept - tailLinesKept; + // `totalBytes - headBytesKept - tailBytesKept` includes newline separators + // between the kept windows and the elided region; close enough for a notice. + const elidedBytes = Math.max(0, totalBytes - headBytesKept - tailBytesKept); + const marker = formatMiddleElisionMarker(elidedLines, elidedBytes); + const composed = `${head.content}\n${marker}\n${tail.content}`; + const markerBytes = Buffer.byteLength(marker, "utf-8"); + + return { + content: composed, + truncated: true, + truncatedBy: "middle", + totalLines, + totalBytes, + outputLines: headLinesKept + tailLinesKept + 1, + outputBytes: headBytesKept + tailBytesKept + markerBytes + 2, + elidedLines, + elidedBytes, + lastLinePartial: tail.lastLinePartial, + firstLineExceedsLimit: false, + }; +} + // ============================================================================= // TailBuffer — ring-style tail buffer with lazy joining // ============================================================================= @@ -520,12 +641,21 @@ export class TailBuffer { export class OutputSink { #buffer = ""; #bufferBytes = 0; + #head = ""; + #headBytes = 0; + #headLines = 0; // newline count inside #head #totalLines = 0; // newline count #totalBytes = 0; #sawData = false; #truncated = false; #lastChunkTime = 0; + // Per-line column cap streaming state (persists across `push` calls so a + // long line split across chunks still trips the same trigger). + #currentLineBytes = 0; + #columnEllipsisAdded = false; + #columnDroppedBytes = 0; + #columnTruncatedLines = 0; #file?: { path: string; artifactId?: string; @@ -539,20 +669,26 @@ export class OutputSink { readonly #artifactPath?: string; readonly #artifactId?: string; readonly #spillThreshold: number; + readonly #headLimit: number; readonly #onChunk?: (chunk: string) => void; readonly #chunkThrottleMs: number; + readonly #maxColumns: number; constructor(options?: OutputSinkOptions) { const { artifactPath, artifactId, spillThreshold = DEFAULT_MAX_BYTES, + headBytes = 0, + maxColumns = 0, onChunk, chunkThrottleMs = 0, } = options ?? {}; this.#artifactPath = artifactPath; this.#artifactId = artifactId; this.#spillThreshold = spillThreshold; + this.#headLimit = Math.max(0, headBytes); + this.#maxColumns = Math.max(0, maxColumns); this.#onChunk = onChunk; this.#chunkThrottleMs = chunkThrottleMs; } @@ -565,6 +701,8 @@ export class OutputSink { chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText); // Throttled onChunk: only call the callback when enough time has passed. + // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind + // what reached the sink — the column cap is for the persisted LLM view. if (this.#onChunk) { const now = Date.now(); if (now - this.#lastChunkTime >= this.#chunkThrottleMs) { @@ -573,22 +711,124 @@ export class OutputSink { } } - const dataBytes = Buffer.byteLength(chunk, "utf-8"); - this.#totalBytes += dataBytes; + const rawBytes = Buffer.byteLength(chunk, "utf-8"); + this.#totalBytes += rawBytes; if (chunk.length > 0) { this.#sawData = true; this.#totalLines += countNewlines(chunk); } - const threshold = this.#spillThreshold; - const willOverflow = this.#bufferBytes + dataBytes > threshold; + // Per-line column cap. State persists across chunks so a mid-line split + // still respects the budget. Operates on the sanitized chunk; the cap is + // applied before head/tail accounting but after artifact mirroring decides. + const capped = this.#maxColumns > 0 ? this.#applyColumnCap(chunk) : chunk; + const cappedBytes = capped === chunk ? rawBytes : Buffer.byteLength(capped, "utf-8"); + const cappedThisChunk = cappedBytes < rawBytes; + if (cappedThisChunk) this.#truncated = true; - // Write to artifact file if configured and past the threshold - if (this.#artifactPath && (this.#file != null || willOverflow)) { + // Mirror RAW chunk to the artifact file so the on-disk record is the full + // uncapped stream. Mirror triggers on: in-memory overflow OR this chunk's + // column cap dropped bytes (otherwise we'd lose data) OR file already open. + if (this.#artifactPath && (this.#file != null || cappedThisChunk || this.#willOverflow(cappedBytes))) { this.#writeToFile(chunk); } + if (cappedBytes === 0) return; + + // Head retention: drain the (capped) chunk into #head until the budget is + // exhausted, then forward any leftover to the tail buffer. + let tailChunk = capped; + let tailBytes = cappedBytes; + if (this.#headLimit > 0 && this.#headBytes < this.#headLimit) { + const room = this.#headLimit - this.#headBytes; + if (cappedBytes <= room) { + this.#head += capped; + this.#headBytes += cappedBytes; + this.#headLines += countNewlines(capped); + return; + } + // Split: head takes a UTF-8-safe prefix; remainder flows to tail. + const headSlice = truncateHeadBytes(capped, room); + if (headSlice.bytes > 0) { + this.#head += headSlice.text; + this.#headBytes += headSlice.bytes; + this.#headLines += countNewlines(headSlice.text); + tailChunk = capped.substring(headSlice.text.length); + tailBytes = cappedBytes - headSlice.bytes; + } + } + + this.#pushTail(tailChunk, tailBytes); + } + + /** + * Apply the per-line byte cap to `chunk`, dropping bytes that would push the + * current line beyond `#maxColumns`. Emits a single `…` once a line trips the + * cap; subsequent bytes are skipped until the next `\n`. State persists + * across calls so a long line split across chunks still produces one marker. + */ + #applyColumnCap(chunk: string): string { + if (chunk.length === 0) return chunk; + const max = this.#maxColumns; + const parts: string[] = []; + let cursor = 0; + while (cursor < chunk.length) { + const nlIdx = chunk.indexOf(NL, cursor); + const segEnd = nlIdx === -1 ? chunk.length : nlIdx; + if (segEnd > cursor) { + const segment = chunk.substring(cursor, segEnd); + if (this.#columnEllipsisAdded) { + // Past the cap; drop until newline. + this.#columnDroppedBytes += Buffer.byteLength(segment, "utf-8"); + } else { + const segBytes = Buffer.byteLength(segment, "utf-8"); + const remaining = max - this.#currentLineBytes; + if (segBytes <= remaining) { + parts.push(segment); + this.#currentLineBytes += segBytes; + } else { + // First overflow on this line: keep what fits, append ellipsis, + // arm the skip-until-newline flag. + const ellipsisBytes = 3; // "…" in UTF-8 + const headRoom = Math.max(0, remaining - ellipsisBytes); + let kept = ""; + let keptBytes = 0; + if (headRoom > 0) { + const sliced = truncateHeadBytes(segment, headRoom); + kept = sliced.text; + keptBytes = sliced.bytes; + parts.push(kept); + } + parts.push(ELLIPSIS); + this.#columnDroppedBytes += segBytes - keptBytes; + this.#columnTruncatedLines++; + this.#currentLineBytes += keptBytes + ellipsisBytes; + this.#columnEllipsisAdded = true; + } + } + } + if (nlIdx === -1) break; + parts.push(NL); + this.#currentLineBytes = 0; + this.#columnEllipsisAdded = false; + cursor = nlIdx + 1; + } + return parts.join(""); + } + + #willOverflow(dataBytes: number): boolean { + // Triggers file mirroring as soon as the next chunk would push us over + // the tail budget (head retention does not change spill-to-artifact). + return this.#bufferBytes + dataBytes > this.#spillThreshold; + } + + #pushTail(chunk: string, dataBytes: number): void { + if (dataBytes === 0) return; + + const threshold = this.#spillThreshold; + const willOverflow = this.#bufferBytes + dataBytes > threshold; + if (!willOverflow) { this.#buffer += chunk; this.#bufferBytes += dataBytes; @@ -612,8 +852,6 @@ export class OutputSink { this.#buffer = text; this.#bufferBytes = bytes; } - - if (this.#file) this.#truncated = true; } /** @@ -685,26 +923,84 @@ export class OutputSink { * streaming counters (totalLines/totalBytes reflect the raw chunks that * already reached the sink). Used when an upstream minimizer rewrites the * captured output after the raw bytes have already been streamed. + * + * Clears any retained head window — the minimized text is authoritative. */ replace(text: string): void { this.#buffer = text; this.#bufferBytes = Buffer.byteLength(text, "utf-8"); + this.#head = ""; + this.#headBytes = 0; + this.#headLines = 0; + this.#currentLineBytes = 0; + this.#columnEllipsisAdded = false; + this.#columnDroppedBytes = 0; + this.#columnTruncatedLines = 0; } async dump(notice?: string): Promise { const noticeLine = notice ? `[${notice}]\n` : ""; - const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0; const totalLines = this.#sawData ? this.#totalLines + 1 : 0; if (this.#file) await this.#file.sink.end(); + // Compose the visible output. With head retention, splice head + marker + // + tail when content was elided. Otherwise return the rolling buffer. + const headBytes = this.#headBytes; + const tailBuf = this.#buffer; + const tailBytes = this.#bufferBytes; + const headLines = this.#headLines + (headBytes > 0 && !this.#head.endsWith("\n") ? 1 : 0); + const tailLines = tailBuf.length > 0 ? countNewlines(tailBuf) + 1 : 0; + + // Bytes that survived the column cap. Middle elision operates on these, + // so column-dropped bytes don't inflate the "elided from middle" count. + const effectiveTotalBytes = Math.max(0, this.#totalBytes - this.#columnDroppedBytes); + + let body: string; + let outputBytes: number; + let outputLines: number; + let elidedBytes: number | undefined; + let elidedLines: number | undefined; + + if (headBytes > 0 && effectiveTotalBytes > headBytes + tailBytes) { + // Middle was elided. Emit head + marker + tail. + elidedBytes = Math.max(0, effectiveTotalBytes - headBytes - tailBytes); + elidedLines = Math.max(0, totalLines - headLines - tailLines); + const marker = formatMiddleElisionMarker(elidedLines, elidedBytes); + const markerBytes = Buffer.byteLength(marker, "utf-8"); + const headSep = this.#head.endsWith("\n") ? "" : "\n"; + const tailSep = tailBuf.startsWith("\n") ? "" : "\n"; + body = `${this.#head}${headSep}${marker}${tailSep}${tailBuf}`; + outputBytes = + headBytes + + markerBytes + + tailBytes + + Buffer.byteLength(headSep, "utf-8") + + Buffer.byteLength(tailSep, "utf-8"); + outputLines = headLines + 1 + tailLines; + this.#truncated = true; + } else if (headBytes > 0) { + // Head + tail combine into the full buffered output (no overlap or elision). + body = `${this.#head}${tailBuf}`; + outputBytes = headBytes + tailBytes; + outputLines = body.length > 0 ? countNewlines(body) + 1 : 0; + } else { + body = tailBuf; + outputBytes = tailBytes; + outputLines = tailLines; + } + return { - output: `${noticeLine}${this.#buffer}`, + output: `${noticeLine}${body}`, truncated: this.#truncated, totalLines, totalBytes: this.#totalBytes, outputLines, - outputBytes: this.#bufferBytes, + outputBytes, + elidedBytes, + elidedLines, + columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined, + columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined, artifactId: this.#file?.artifactId, }; } diff --git a/packages/coding-agent/src/ssh/ssh-executor.ts b/packages/coding-agent/src/ssh/ssh-executor.ts index a2ad4f82f..3dd6f5b6d 100644 --- a/packages/coding-agent/src/ssh/ssh-executor.ts +++ b/packages/coding-agent/src/ssh/ssh-executor.ts @@ -1,5 +1,7 @@ import { logger, ptree } from "@oh-my-pi/pi-utils"; +import { Settings } from "../config/settings"; import { OutputSink } from "../session/streaming-output"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta"; import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager"; import { hasSshfs, mountRemote } from "./sshfs-mount"; @@ -83,10 +85,13 @@ export async function executeSSH( stderr: "full", }); + const settings = await Settings.init(); const sink = new OutputSink({ onChunk: options?.onChunk, artifactPath: options?.artifactPath, artifactId: options?.artifactId, + headBytes: resolveOutputSinkHeadBytes(settings), + maxColumns: resolveOutputMaxColumns(settings), }); const streams = [child.stdout.pipeTo(sink.createInput())]; diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index 99f9b3500..e030bcf43 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -12,10 +12,12 @@ import { } from "@oh-my-pi/pi-tui"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import xterm from "@xterm/headless"; +import { Settings } from "../config/settings"; import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env"; import type { Theme } from "../modes/theme/theme"; import { OutputSink, type OutputSummary } from "../session/streaming-output"; import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta"; import { formatStatusIcon, replaceTabs } from "./render-utils"; export interface BashInteractiveResult extends OutputSummary { @@ -294,7 +296,13 @@ export async function runInteractiveBashPty( artifactId?: string; }, ): Promise { - const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId }); + const settings = await Settings.init(); + const sink = new OutputSink({ + artifactPath: options.artifactPath, + artifactId: options.artifactId, + headBytes: resolveOutputSinkHeadBytes(settings), + maxColumns: resolveOutputMaxColumns(settings), + }); const result = await ui.custom( (tui, uiTheme, _keybindings, done) => { const session = new PtySession(); diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index a3cc47aa9..f8ed5c0ea 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -16,7 +16,7 @@ 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 } from "./output-meta"; +import { formatStyledTruncationWarning, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta"; import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -358,6 +358,8 @@ export class EvalTool implements AgentTool { outputSink = new OutputSink({ artifactPath, artifactId, + headBytes: resolveOutputSinkHeadBytes(session.settings), + maxColumns: resolveOutputMaxColumns(session.settings), onChunk: chunk => { appendTail(chunk); pushUpdate(); diff --git a/packages/coding-agent/src/tools/output-meta.ts b/packages/coding-agent/src/tools/output-meta.ts index abf62bd98..04dca5dff 100644 --- a/packages/coding-agent/src/tools/output-meta.ts +++ b/packages/coding-agent/src/tools/output-meta.ts @@ -15,7 +15,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { getDefault, type Settings } from "../config/settings"; import { formatGroupedDiagnosticMessages } from "../lsp/utils"; import type { Theme } from "../modes/theme/theme"; -import { type OutputSummary, type TruncationResult, truncateTail } from "../session/streaming-output"; +import { type OutputSummary, type TruncationResult, truncateMiddle, truncateTail } from "../session/streaming-output"; import { formatBytes, wrapBrackets } from "./render-utils"; import { renderError } from "./tool-errors"; @@ -23,15 +23,22 @@ import { renderError } from "./tool-errors"; * Truncation metadata for the output notice. */ export interface TruncationMeta { - direction: "head" | "tail"; - truncatedBy: "lines" | "bytes"; + direction: "head" | "tail" | "middle"; + truncatedBy: "lines" | "bytes" | "middle"; totalLines: number; totalBytes: number; outputLines: number; outputBytes: number; maxBytes?: number; - /** Line range shown (1-indexed, inclusive) */ + /** Line range shown (1-indexed, inclusive). Omitted for middle elision. */ shownRange?: { start: number; end: number }; + /** Head/tail line ranges shown when direction === "middle". */ + headRange?: { start: number; end: number }; + tailRange?: { start: number; end: number }; + /** Bytes elided from the middle. */ + elidedBytes?: number; + /** Lines elided from the middle. */ + elidedLines?: number; /** Artifact ID if full output was saved */ artifactId?: string; /** Next offset for pagination (head truncation only) */ @@ -79,20 +86,20 @@ export interface OutputMeta { // ============================================================================= export interface TruncationOptions { - direction: "head" | "tail"; + direction: "head" | "tail" | "middle"; startLine?: number; totalFileLines?: number; artifactId?: string; } export interface TruncationSummaryOptions { - direction: "head" | "tail"; + direction: "head" | "tail" | "middle"; startLine?: number; totalFileLines?: number; } export interface TruncationTextOptions { - direction: "head" | "tail"; + direction: "head" | "tail" | "middle"; totalLines?: number; totalBytes?: number; maxBytes?: number; @@ -120,7 +127,40 @@ export class OutputMetaBuilder { const { direction, startLine = 1, totalFileLines, artifactId } = options; const outputLines = result.outputLines ?? result.totalLines; const outputBytes = result.outputBytes ?? result.totalBytes; - const truncatedBy: "lines" | "bytes" = result.truncatedBy === "lines" ? "lines" : "bytes"; + const isMiddle = direction === "middle" || result.truncatedBy === "middle"; + const truncatedBy: "lines" | "bytes" | "middle" = isMiddle + ? "middle" + : result.truncatedBy === "lines" + ? "lines" + : "bytes"; + + const effectiveTotalLines = totalFileLines ?? result.totalLines; + + if (isMiddle) { + const elidedLines = result.elidedLines ?? Math.max(0, effectiveTotalLines - outputLines); + const elidedBytes = result.elidedBytes ?? Math.max(0, result.totalBytes - outputBytes); + // Reconstruct head/tail line ranges. The kept output spans the first + // `headLines` lines and the last `tailLines` lines of the source; lines + // in the middle (count == elidedLines) are dropped. + const keptLines = Math.max(0, outputLines - 1); // -1 for marker line + const headLines = Math.ceil(keptLines / 2); + const tailLines = keptLines - headLines; + this.#meta.truncation = { + direction: "middle", + truncatedBy: "middle", + totalLines: effectiveTotalLines, + totalBytes: result.totalBytes, + outputLines, + outputBytes, + headRange: headLines > 0 ? { start: 1, end: headLines } : undefined, + tailRange: + tailLines > 0 ? { start: effectiveTotalLines - tailLines + 1, end: effectiveTotalLines } : undefined, + elidedLines, + elidedBytes, + artifactId, + }; + return this; + } let shownStart: number; let shownEnd: number; @@ -136,7 +176,7 @@ export class OutputMetaBuilder { this.#meta.truncation = { direction, truncatedBy, - totalLines: totalFileLines ?? result.totalLines, + totalLines: effectiveTotalLines, totalBytes: result.totalBytes, outputLines, outputBytes, @@ -154,6 +194,29 @@ export class OutputMetaBuilder { const { direction, startLine = 1, totalFileLines } = options; const totalLines = totalFileLines ?? summary.totalLines; + + // Middle elision: the sink retained head + tail with an elision marker. + if (summary.elidedBytes != null && summary.elidedBytes > 0) { + const elidedLines = summary.elidedLines ?? Math.max(0, totalLines - summary.outputLines); + const keptLines = Math.max(0, summary.outputLines - 1); // -1 for marker line + const headLines = Math.ceil(keptLines / 2); + const tailLines = keptLines - headLines; + this.#meta.truncation = { + direction: "middle", + truncatedBy: "middle", + totalLines, + totalBytes: summary.totalBytes, + outputLines: summary.outputLines, + outputBytes: summary.outputBytes, + headRange: headLines > 0 ? { start: 1, end: headLines } : undefined, + tailRange: tailLines > 0 ? { start: totalLines - tailLines + 1, end: totalLines } : undefined, + elidedBytes: summary.elidedBytes, + elidedLines, + artifactId: summary.artifactId, + }; + return this; + } + const truncatedBy: "lines" | "bytes" = summary.outputBytes < summary.totalBytes ? "bytes" @@ -322,9 +385,28 @@ export function formatFullOutputReference(artifactId: string): string { } export function formatTruncationMetaNotice(truncation: TruncationMeta): string { - const range = truncation.shownRange; let notice: string; + if (truncation.direction === "middle") { + const head = truncation.headRange; + const tail = truncation.tailRange; + const totalLines = truncation.totalLines; + const elidedBytes = truncation.elidedBytes ?? Math.max(0, truncation.totalBytes - truncation.outputBytes); + const elidedLines = truncation.elidedLines ?? Math.max(0, totalLines - truncation.outputLines); + const headPart = head ? `lines ${head.start}-${head.end}` : ""; + const tailPart = tail ? `${tail.start}-${tail.end}` : ""; + if (headPart && tailPart) { + notice = `Showing ${headPart} and ${tailPart} of ${totalLines}; ${elidedLines.toLocaleString()} middle line${elidedLines === 1 ? "" : "s"} (${formatBytes(elidedBytes)}) elided`; + } else { + notice = `Showing ${truncation.outputLines} of ${totalLines} lines; middle elided`; + } + if (truncation.artifactId != null) { + notice += `. ${formatFullOutputReference(truncation.artifactId)}`; + } + return notice; + } + + const range = truncation.shownRange; if (range && range.end >= range.start) { notice = `Showing lines ${range.start}-${range.end} of ${truncation.totalLines}`; } else { @@ -442,21 +524,44 @@ const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute"); /** Resolved artifact spill config sourced from the session settings (or schema defaults). */ function getSpillConfig(s: Settings | undefined) { - const get =

( - path: P, - ) => s?.get(path) ?? getDefault(path); + type Path = + | "tools.artifactSpillThreshold" + | "tools.artifactTailBytes" + | "tools.artifactTailLines" + | "tools.artifactHeadBytes"; + const get =

(path: P) => s?.get(path) ?? getDefault(path); return { threshold: get("tools.artifactSpillThreshold") * 1024, tailBytes: get("tools.artifactTailBytes") * 1024, tailLines: get("tools.artifactTailLines"), + headBytes: get("tools.artifactHeadBytes") * 1024, }; } /** - * If the tool result text exceeds RESULT_ARTIFACT_THRESHOLD, save the full - * output as a session artifact and replace the content with a tail-truncated - * version plus an artifact reference. Skips when the tool already saved its - * own artifact (e.g. bash/python via OutputSink). + * Resolve the OutputSink `headBytes` budget from session settings. + * Exposed so streaming executors (bash/python/ssh/eval) can opt into + * middle elision with the same per-user configuration. + */ +export function resolveOutputSinkHeadBytes(s: Settings | undefined): number { + return getSpillConfig(s).headBytes; +} + +/** + * Resolve the per-line column cap from session settings. Shared by streaming + * executors (bash/python/ssh/eval via OutputSink) and the `read` tool's + * line-buffer post-processing, so one setting controls both surfaces. + */ +export function resolveOutputMaxColumns(s: Settings | undefined): number { + return s?.get("tools.outputMaxColumns") ?? getDefault("tools.outputMaxColumns"); +} + +/** + * If the tool result text exceeds the spill threshold, save the full output + * as a session artifact and replace the content with a head+tail (middle + * elision) view plus an artifact reference. When `tools.artifactHeadBytes` + * is 0, falls back to tail-only truncation. Skips when the tool already + * saved its own artifact (e.g. bash/python via OutputSink). */ async function spillLargeResultToArtifact( result: AgentToolResult, @@ -466,7 +571,7 @@ async function spillLargeResultToArtifact( const sessionManager = context?.sessionManager; if (!sessionManager) return result; if (toolName === "read") return result; - const { threshold, tailBytes, tailLines } = getSpillConfig(context?.settings); + const { threshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings); // Skip if tool already saved an artifact const existingMeta: OutputMeta | undefined = result.details?.meta; @@ -489,13 +594,21 @@ async function spillLargeResultToArtifact( const artifactId = await sessionManager.saveArtifact(fullText, toolName); if (!artifactId) return result; - // Truncate to tail - const truncated = truncateTail(fullText, { - maxBytes: tailBytes, - maxLines: tailLines, - }); + // Truncate: middle elision when a head budget is configured, otherwise tail-only. + const useMiddle = headBytes > 0; + const truncated = useMiddle + ? truncateMiddle(fullText, { + maxBytes: headBytes + tailBytes, + maxLines: tailLines * 2, + maxHeadBytes: headBytes, + maxHeadLines: tailLines, + }) + : truncateTail(fullText, { + maxBytes: tailBytes, + maxLines: tailLines, + }); - // Replace text blocks with single tail-truncated block, keep images + // Replace text blocks with single truncated block, keep images const newContent: (TextContent | ImageContent)[] = []; for (const block of result.content) { if (block.type !== "text") { @@ -507,18 +620,44 @@ async function spillLargeResultToArtifact( // Build truncation meta const outputLines = truncated.outputLines ?? truncated.totalLines; const outputBytes = truncated.outputBytes ?? truncated.totalBytes; - const shownStart = truncated.totalLines - outputLines + 1; - const truncationMeta: TruncationMeta = { - direction: "tail", - truncatedBy: truncated.truncatedBy ?? "bytes", - totalLines: truncated.totalLines, - totalBytes: truncated.totalBytes, - outputLines, - outputBytes, - maxBytes: tailBytes, - shownRange: { start: shownStart, end: truncated.totalLines }, - artifactId, - }; + let truncationMeta: TruncationMeta; + if (truncated.truncatedBy === "middle") { + const elidedLines = truncated.elidedLines ?? Math.max(0, truncated.totalLines - outputLines); + const elidedBytes = truncated.elidedBytes ?? Math.max(0, truncated.totalBytes - outputBytes); + const keptLines = Math.max(0, outputLines - 1); // -1 for marker line + const headLines = Math.ceil(keptLines / 2); + const tailLineCount = keptLines - headLines; + truncationMeta = { + direction: "middle", + truncatedBy: "middle", + totalLines: truncated.totalLines, + totalBytes: truncated.totalBytes, + outputLines, + outputBytes, + maxBytes: headBytes + tailBytes, + headRange: headLines > 0 ? { start: 1, end: headLines } : undefined, + tailRange: + tailLineCount > 0 + ? { start: truncated.totalLines - tailLineCount + 1, end: truncated.totalLines } + : undefined, + elidedLines, + elidedBytes, + artifactId, + }; + } else { + const shownStart = truncated.totalLines - outputLines + 1; + truncationMeta = { + direction: "tail", + truncatedBy: truncated.truncatedBy ?? "bytes", + totalLines: truncated.totalLines, + totalBytes: truncated.totalBytes, + outputLines, + outputBytes, + maxBytes: tailBytes, + shownRange: { start: shownStart, end: truncated.totalLines }, + artifactId, + }; + } const newMeta: OutputMeta = { ...(existingMeta ?? {}), truncation: truncationMeta }; const newDetails = { ...(result.details ?? {}), meta: newMeta }; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index f1b981d8f..45b496cf0 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -25,6 +25,7 @@ import { type TruncationResult, truncateHead, truncateHeadBytes, + truncateLine, } from "../session/streaming-output"; import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; @@ -54,7 +55,12 @@ import { renderReadUrlResult, } from "./fetch"; import { applyListLimit } from "./list-limit"; -import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; +import { + formatFullOutputReference, + formatStyledTruncationWarning, + type OutputMeta, + resolveOutputMaxColumns, +} from "./output-meta"; import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils"; import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils"; import { @@ -81,6 +87,12 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx" const MAX_SUMMARY_BYTES = 2 * 1024 * 1024; const MAX_SUMMARY_LINES = 20_000; +/** + * Per-line column cap for file reads. Lines wider than the value of + * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file + * on disk is unchanged. Shared with the streaming sink path so one setting + * covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly. + */ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]); // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep; @@ -1309,6 +1321,7 @@ export class ReadTool implements AgentTool { let content: Array | undefined; let details: ReadToolDetails = {}; let sourcePath: string | undefined; + let columnTruncated = 0; let truncationInfo: | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } } | undefined; @@ -1498,6 +1511,22 @@ export class ReadTool implements AgentTool { .done(); } + // Per-line column cap. Skipped in raw mode so `:raw` always returns + // verbatim bytes for paste-back-into-tool workflows. Total byte/line + // counts in `truncation` keep reflecting the source, not the trimmed + // view — column truncation surfaces separately via `.limits()`. + const rawSelector = isRawSelector(parsed); + const maxColumns = resolveOutputMaxColumns(this.session.settings); + if (!rawSelector && maxColumns > 0) { + for (let i = 0; i < collectedLines.length; i++) { + const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns); + if (wasTruncated) { + collectedLines[i] = text; + columnTruncated = maxColumns; + } + } + } + const selectedContent = collectedLines.join("\n"); const userLimitedLines = collectedLines.length; @@ -1522,9 +1551,8 @@ export class ReadTool implements AgentTool { getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines); } - const isRawMode = isRawSelector(parsed); - const shouldAddHashLines = !isRawMode && displayMode.hashLines; - const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers; + const shouldAddHashLines = !rawSelector && displayMode.hashLines; + const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers; let capturedDisplayContent: { text: string; startLine: number } | undefined; const formatText = (text: string, startNum: number): string => { capturedDisplayContent = { text, startLine: startNum }; @@ -1636,6 +1664,9 @@ export class ReadTool implements AgentTool { if (truncationInfo) { resultBuilder.truncation(truncationInfo.result, truncationInfo.options); } + if (columnTruncated > 0) { + resultBuilder.limits({ columnMax: columnTruncated }); + } return resultBuilder.done(); } diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 675deb2ac..134fe9a5c 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -4,12 +4,14 @@ import * as os from "node:os"; import * as path from "node:path"; import { formatHeadTruncationNotice, + formatMiddleElisionMarker, formatTailTruncationNotice, OutputSink, TailBuffer, truncateHead, truncateHeadBytes, truncateLine, + truncateMiddle, truncateTail, truncateTailBytes, } from "../src/session/streaming-output"; @@ -335,3 +337,149 @@ describe("truncation notice formatting", () => { ).toBe("\n\n[Showing lines 100-100 of 500. Use :101 to continue]"); }); }); + +describe("truncateMiddle", () => { + test("returns content unchanged when within budget", () => { + const result = truncateMiddle("a\nb\nc", { maxBytes: 100, maxLines: 10 }); + expect(result.truncated).toBeFalsy(); + expect(result.content).toBe("a\nb\nc"); + }); + + test("keeps head and tail with marker for byte-overflow content", () => { + const lines = Array.from({ length: 12 }, (_, i) => `line-${i + 1}`).join("\n"); + const result = truncateMiddle(lines, { + maxBytes: 24, // 12 bytes head + 12 bytes tail + maxLines: 12, + maxHeadBytes: 12, + maxHeadLines: 3, + }); + expect(result.truncated).toBe(true); + expect(result.truncatedBy).toBe("middle"); + // Must contain first line and last line, plus the elision marker. + expect(result.content.startsWith("line-1\n")).toBe(true); + expect(result.content.endsWith("line-12")).toBe(true); + expect(result.content).toContain("elided"); + expect(result.content).not.toContain("line-7"); // a middle line + expect(result.elidedLines).toBeGreaterThan(0); + expect(result.elidedBytes).toBeGreaterThan(0); + }); + + test("falls back to tail-only when head budget cannot accept the first line", () => { + const giantFirstLine = `${"x".repeat(200)}\nshort-2\nshort-3`; + const result = truncateMiddle(giantFirstLine, { + maxBytes: 40, + maxLines: 10, + maxHeadBytes: 8, // first line is 200 bytes — exceeds head budget + maxHeadLines: 1, + }); + expect(result.truncated).toBe(true); + // Should not contain the elision marker; it's a regular tail truncation. + expect(result.content).not.toContain("elided"); + }); + + test("formatMiddleElisionMarker pluralises and formats bytes", () => { + expect(formatMiddleElisionMarker(1, 100)).toBe("[… 1 line elided (100B) …]"); + expect(formatMiddleElisionMarker(123, 4096)).toBe("[… 123 lines elided (4.0KB) …]"); + }); +}); + +describe("OutputSink head-retain mode", () => { + test("middle elision splices head, marker, and tail", async () => { + const sink = new OutputSink({ spillThreshold: 6, headBytes: 6 }); + // Total 36 bytes: head ~6, tail ~6, middle ~24 elided. + const lines = Array.from({ length: 12 }, (_, i) => `L${i}`).join("\n"); + await sink.push(lines); + + const dumped = await sink.dump(); + expect(dumped.truncated).toBe(true); + expect(dumped.elidedBytes ?? 0).toBeGreaterThan(0); + expect(dumped.elidedLines ?? 0).toBeGreaterThan(0); + expect(dumped.output.startsWith("L0\n")).toBe(true); + expect(dumped.output.endsWith("L11")).toBe(true); + expect(dumped.output).toContain("elided"); + expect(dumped.totalBytes).toBe(byteLength(lines)); + }); + + test("disabled (headBytes=0) preserves tail-only behavior", async () => { + const sink = new OutputSink({ spillThreshold: 5, headBytes: 0 }); + await sink.push("abc"); + await sink.push("def"); + + const dumped = await sink.dump(); + expect(dumped.truncated).toBe(true); + expect(dumped.output).toBe("bcdef"); + expect(dumped.elidedBytes).toBeUndefined(); + }); + + test("head fills cleanly across chunks without elision when total fits", async () => { + const sink = new OutputSink({ spillThreshold: 50, headBytes: 4 }); + await sink.push("abcdefgh"); + const dumped = await sink.dump(); + expect(dumped.output).toBe("abcdefgh"); + expect(dumped.truncated).toBe(false); + expect(dumped.elidedBytes).toBeUndefined(); + }); +}); + +describe("OutputSink maxColumns (per-line cap)", () => { + test("truncates a single overlong line with an ellipsis and drops the rest", async () => { + const sink = new OutputSink({ maxColumns: 8, spillThreshold: 1000 }); + await sink.push(`short\n${"x".repeat(50)}\nfooter`); + + const dumped = await sink.dump(); + expect(dumped.truncated).toBe(true); + expect(dumped.output).toContain("short\n"); + expect(dumped.output).toContain("\nfooter"); + expect(dumped.output).toContain("…"); + // The wide line shouldn't appear verbatim. + expect(dumped.output).not.toContain("x".repeat(50)); + expect(dumped.columnTruncatedLines).toBe(1); + expect(dumped.columnDroppedBytes ?? 0).toBeGreaterThan(0); + // totalBytes still reflects the raw stream, not the post-cap view. + expect(dumped.totalBytes).toBe(byteLength(`short\n${"x".repeat(50)}\nfooter`)); + }); + + test("persists per-line state across chunk boundaries", async () => { + const sink = new OutputSink({ maxColumns: 4, spillThreshold: 1000 }); + await sink.push("ab"); // 2 bytes into the current line + await sink.push("cd"); // 4 bytes total — still within cap + await sink.push("efgh"); // tips over → ellipsis once, then drop rest + await sink.push("ijkl\n"); + await sink.push("next"); + + const dumped = await sink.dump(); + const lines = dumped.output.split("\n"); + expect(lines[0]).toMatch(/^(abcd)?…$|^abcd…$/); + expect(lines[1]).toBe("next"); + expect(dumped.columnTruncatedLines).toBe(1); + }); + + test("disabled by default — maxColumns: 0 is a passthrough", async () => { + const sink = new OutputSink({ spillThreshold: 4000 }); + const wide = "y".repeat(2000); + await sink.push(wide); + const dumped = await sink.dump(); + expect(dumped.output).toBe(wide); + expect(dumped.columnTruncatedLines).toBeUndefined(); + expect(dumped.columnDroppedBytes).toBeUndefined(); + }); + + test("middle elision math subtracts column-dropped bytes", async () => { + // Head + tail buffers are tiny; the wide middle line gets column-capped, + // so its dropped bytes shouldn't be double-counted as "elided from middle". + const sink = new OutputSink({ + maxColumns: 4, + spillThreshold: 6, + headBytes: 6, + }); + const wideMiddle = "M".repeat(200); + const input = `head\n${wideMiddle}\ntail`; + await sink.push(input); + const dumped = await sink.dump(); + const elided = dumped.elidedBytes ?? 0; + const dropped = dumped.columnDroppedBytes ?? 0; + expect(dropped).toBeGreaterThan(0); + // elided + dropped + kept ≤ totalBytes (with a small slack for the marker/newlines). + expect(elided + dropped).toBeLessThan(dumped.totalBytes); + }); +}); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index d2aa64057..01e3739b5 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -280,6 +280,33 @@ describe("Coding Agent Tools", () => { expect(result.details?.truncation).toBeUndefined(); }); + it("truncates lines wider than the read column cap, leaving narrow lines untouched", async () => { + const wideLine = "x".repeat(1500); + const testFile = path.join(testDir, "wide.txt"); + fs.writeFileSync(testFile, `header\n${wideLine}\nfooter`); + + const result = await readTool.execute("test-call-column-truncate", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("header"); + expect(output).toContain("footer"); + expect(output).not.toContain(wideLine); // verbatim wide line is gone + expect(output).toContain("…"); // ellipsis marker + expect(output).toContain("Some lines truncated to 768 chars"); + }); + + it("returns wide lines verbatim with the :raw selector", async () => { + const wideLine = "y".repeat(1500); + const testFile = path.join(testDir, "wide-raw.txt"); + fs.writeFileSync(testFile, `head\n${wideLine}\ntail`); + + const result = await readTool.execute("test-call-column-raw", { path: `${testFile}:raw` }); + const output = getTextOutput(result); + + expect(output).toContain(wideLine); + expect(output).not.toContain("Some lines truncated to 768 chars"); + }); + it("should read ipynb files as editable cell text", async () => { const notebookPath = path.join(testDir, "notebook.ipynb"); const notebook = {