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.
This commit is contained in:
can1357
2026-05-13 11:19:11 +02:00
parent 0e8f32c5a5
commit 28b9ce7a0c
13 changed files with 766 additions and 54 deletions
+5
View File
@@ -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://<id>`) 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://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
## [15.0.0] - 2026-05-13
### Breaking Changes
@@ -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,
@@ -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);
@@ -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<PythonResult> {
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);
@@ -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,
@@ -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://<id>) 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 `<head>\n<marker>\n<tail>` 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<OutputSummary> {
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,
};
}
@@ -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())];
@@ -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<BashInteractiveResult> {
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<BashInteractiveResult>(
(tui, uiTheme, _keybindings, done) => {
const session = new PtySession();
+3 -1
View File
@@ -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<typeof evalSchema> {
outputSink = new OutputSink({
artifactPath,
artifactId,
headBytes: resolveOutputSinkHeadBytes(session.settings),
maxColumns: resolveOutputMaxColumns(session.settings),
onChunk: chunk => {
appendTail(chunk);
pushUpdate();
+175 -36
View File
@@ -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 = <P extends "tools.artifactSpillThreshold" | "tools.artifactTailBytes" | "tools.artifactTailLines">(
path: P,
) => s?.get(path) ?? getDefault(path);
type Path =
| "tools.artifactSpillThreshold"
| "tools.artifactTailBytes"
| "tools.artifactTailLines"
| "tools.artifactHeadBytes";
const get = <P extends Path>(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 };
+35 -4
View File
@@ -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<typeof readSchema, ReadToolDetails> {
let content: Array<TextContent | ImageContent> | 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<typeof readSchema, ReadToolDetails> {
.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<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
if (truncationInfo) {
resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
}
if (columnTruncated > 0) {
resultBuilder.limits({ columnMax: columnTruncated });
}
return resultBuilder.done();
}
@@ -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);
});
});
+27
View File
@@ -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 = {