feat: add read_command_output tool for retrieving truncated command output

Implements a new tool that allows the LLM to retrieve full command output
when execute_command produces output exceeding the preview threshold.

Key components:
- ReadCommandOutputTool: Reads persisted output with search/pagination
- OutputInterceptor: Intercepts and persists large command outputs to disk
- Terminal settings UI: Configuration for output interception behavior
- Type definitions for output interception settings

The tool supports:
- Reading full output beyond the truncated preview
- Search/filtering with regex patterns (like grep)
- Pagination through large outputs using offset/limit

Includes comprehensive tests for ReadCommandOutputTool and OutputInterceptor.
This commit is contained in:
Hannes Rudolph 2026-01-24 13:44:20 -07:00
parent d7fa963b13
commit 0a70e046e7
15 changed files with 346 additions and 555 deletions

View file

@ -29,9 +29,9 @@ export const DEFAULT_WRITE_DELAY_MS = 1000
* the LLM decides to retrieve more via `read_command_output`. Larger previews
* mean more immediate context but consume more of the context window.
*
* - `small`: 5KB preview - Best for long-running commands with verbose output
* - `medium`: 10KB preview - Balanced default for most use cases
* - `large`: 20KB preview - Best when commands produce critical info early
* - `small`: 2KB preview - Best for long-running commands with verbose output
* - `medium`: 4KB preview - Balanced default for most use cases
* - `large`: 8KB preview - Best when commands produce critical info early
*
* @see OutputInterceptor - Uses this setting to determine when to spill to disk
* @see PersistedCommandOutput - Contains the resulting preview and artifact reference
@ -46,14 +46,14 @@ export type TerminalOutputPreviewSize = "small" | "medium" | "large"
* to disk and made available via the `read_command_output` tool.
*/
export const TERMINAL_PREVIEW_BYTES: Record<TerminalOutputPreviewSize, number> = {
small: 5 * 1024, // 5KB
medium: 10 * 1024, // 10KB
large: 20 * 1024, // 20KB
small: 2048, // 2KB
medium: 4096, // 4KB
large: 8192, // 8KB
}
/**
* Default terminal output preview size.
* The "medium" (10KB) setting provides a good balance between immediate
* The "medium" (4KB) setting provides a good balance between immediate
* visibility and context window conservation for most use cases.
*/
export const DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE: TerminalOutputPreviewSize = "medium"
@ -176,6 +176,8 @@ export const globalSettingsSchema = z.object({
maxImageFileSize: z.number().optional(),
maxTotalImageSize: z.number().optional(),
terminalOutputLineLimit: z.number().optional(),
terminalOutputCharacterLimit: z.number().optional(),
terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(),
terminalShellIntegrationTimeout: z.number().optional(),
terminalShellIntegrationDisabled: z.boolean().optional(),

View file

@ -302,6 +302,8 @@ export type ExtensionState = Pick<
| "soundEnabled"
| "soundVolume"
| "maxConcurrentFileReads"
| "terminalOutputLineLimit"
| "terminalOutputCharacterLimit"
| "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"

11
pnpm-lock.yaml generated
View file

@ -1017,6 +1017,9 @@ importers:
'@types/glob':
specifier: ^8.1.0
version: 8.1.0
'@types/json-stream-stringify':
specifier: ^2.0.4
version: 2.0.4
'@types/lodash.debounce':
specifier: ^4.0.9
version: 4.0.9
@ -4302,6 +4305,10 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/json-stream-stringify@2.0.4':
resolution: {integrity: sha512-xSFsVnoQ8Y/7BiVF3/fEIwRx9RoGzssDKVwhy1g23wkA4GAmA3v8lsl6CxsmUD6vf4EiRd+J0ULLkMbAWRSsgQ==}
deprecated: This is a stub types definition. json-stream-stringify provides its own type definitions, so you do not need this installed.
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
@ -14316,6 +14323,10 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/json-stream-stringify@2.0.4':
dependencies:
json-stream-stringify: 3.1.6
'@types/katex@0.16.7': {}
'@types/lodash.debounce@4.0.9':

View file

@ -20,15 +20,15 @@ The tool supports two modes:
Parameters:
- artifact_id: (required) The artifact filename from the truncated output message (e.g., "cmd-1706119234567.txt")
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive. **Omit this parameter entirely if you don't need to filter - do not pass null or empty string.**
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive.
- offset: (optional) Byte offset to start reading from. Default: 0. Use for pagination.
- limit: (optional) Maximum bytes to return. Default: 40KB.
- limit: (optional) Maximum bytes to return. Default: 32KB.
Example: Reading truncated command output
{ "artifact_id": "cmd-1706119234567.txt" }
Example: Reading with pagination (after first 40KB)
{ "artifact_id": "cmd-1706119234567.txt", "offset": 40960 }
Example: Reading with pagination (after first 32KB)
{ "artifact_id": "cmd-1706119234567.txt", "offset": 32768 }
Example: Searching for errors in build output
{ "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" }
@ -38,22 +38,18 @@ Example: Finding specific test failures
const ARTIFACT_ID_DESCRIPTION = `The artifact filename from the truncated command output (e.g., "cmd-1706119234567.txt")`
const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep). Omit this parameter if not searching - do not pass null or empty string.`
const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep)`
const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)`
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 40KB)`
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)`
export default {
type: "function",
function: {
name: "read_command_output",
description: READ_COMMAND_OUTPUT_DESCRIPTION,
// Note: strict mode is intentionally disabled for this tool.
// With strict: true, OpenAI requires ALL properties to be in the 'required' array,
// which forces the LLM to always provide explicit values (even null) for optional params.
// This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can
// omit optional parameters entirely, making the tool easier to use.
strict: true,
parameters: {
type: "object",
properties: {
@ -62,19 +58,19 @@ export default {
description: ARTIFACT_ID_DESCRIPTION,
},
search: {
type: "string",
type: ["string", "null"],
description: SEARCH_DESCRIPTION,
},
offset: {
type: "number",
type: ["number", "null"],
description: OFFSET_DESCRIPTION,
},
limit: {
type: "number",
type: ["number", "null"],
description: LIMIT_DESCRIPTION,
},
},
required: ["artifact_id"],
required: ["artifact_id", "search", "offset", "limit"],
additionalProperties: false,
},
},

View file

@ -4,7 +4,12 @@ import * as vscode from "vscode"
import delay from "delay"
import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, PersistedCommandOutput } from "@roo-code/types"
import {
CommandExecutionStatus,
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE,
PersistedCommandOutput,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Task } from "../task/Task"
@ -196,6 +201,7 @@ export async function executeCommandInTerminal(
const providerState = await provider?.getState()
const terminalOutputPreviewSize =
providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE
const terminalCompressProgressBar = providerState?.terminalCompressProgressBar ?? true
interceptor = new OutputInterceptor({
executionId,
@ -203,6 +209,7 @@ export async function executeCommandInTerminal(
command,
storageDir,
previewSize: terminalOutputPreviewSize,
compressProgressBar: terminalCompressProgressBar,
})
}

View file

@ -6,8 +6,8 @@ import { getTaskDirectoryPath } from "../../utils/storage"
import { BaseTool, ToolCallbacks } from "./BaseTool"
/** Default byte limit for read operations (40KB) */
const DEFAULT_LIMIT = 40 * 1024 // 40KB default limit
/** Default byte limit for read operations (32KB) */
const DEFAULT_LIMIT = 32 * 1024 // 32KB default limit
/**
* Parameters accepted by the read_command_output tool.
@ -159,38 +159,15 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
}
let result: string
let readStart = 0
let readEnd = 0
let matchCount: number | undefined
if (search) {
// Search mode: filter lines matching the pattern
const searchResult = await this.searchInArtifact(artifactPath, search, totalSize, limit)
result = searchResult.content
matchCount = searchResult.matchCount
// For search, we're scanning the whole file
readStart = 0
readEnd = totalSize
result = await this.searchInArtifact(artifactPath, search, totalSize, limit)
} else {
// Normal read mode with offset/limit
result = await this.readArtifact(artifactPath, offset, limit, totalSize)
// Calculate actual read range
readStart = offset
readEnd = Math.min(offset + limit, totalSize)
}
// Report to UI that we read command output
await task.say(
"tool",
JSON.stringify({
tool: "readCommandOutput",
readStart,
readEnd,
totalBytes: totalSize,
...(search && { searchPattern: search, matchCount }),
}),
)
task.consecutiveMistakeCount = 0
pushToolResult(result)
} catch (error) {
@ -246,10 +223,14 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset)
const content = buffer.slice(0, bytesRead).toString("utf8")
// Calculate line numbers based on offset using chunked reading to avoid large allocations
// Calculate line numbers based on offset
let startLineNumber = 1
if (offset > 0) {
startLineNumber = await this.countNewlinesBeforeOffset(fileHandle, offset)
// Count newlines before offset to determine starting line number
const prefixBuffer = Buffer.alloc(offset)
await fileHandle.read(prefixBuffer, 0, offset, 0)
const prefix = prefixBuffer.toString("utf8")
startLineNumber = (prefix.match(/\n/g) || []).length + 1
}
const endOffset = offset + bytesRead
@ -272,14 +253,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
}
/**
* Search artifact content for lines matching a pattern using chunked streaming.
* Search artifact content for lines matching a pattern.
*
* Performs grep-like searching through the artifact file using bounded memory.
* Instead of loading the entire file into memory, this reads in fixed-size chunks
* and processes lines as they are encountered. This keeps memory usage predictable
* even for very large command outputs (e.g., 100MB+ build logs).
*
* The pattern is treated as a case-insensitive regex. If the pattern is invalid
* Performs grep-like searching through the artifact file. The pattern
* is treated as a case-insensitive regex. If the pattern is invalid
* regex syntax, it's escaped and treated as a literal string.
*
* Results are limited by the byte limit to prevent excessive output.
@ -296,8 +273,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
pattern: string,
totalSize: number,
limit: number,
): Promise<{ content: string; matchCount: number }> {
const CHUNK_SIZE = 64 * 1024 // 64KB chunks for bounded memory
): Promise<string> {
// Read the entire file for search (we need all content to search)
const content = await fs.readFile(artifactPath, "utf8")
const lines = content.split("\n")
// Create case-insensitive regex for search
let regex: RegExp
@ -308,89 +287,45 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
regex = new RegExp(this.escapeRegExp(pattern), "i")
}
const fileHandle = await fs.open(artifactPath, "r")
// Find matching lines with their line numbers
const matches: Array<{ lineNumber: number; content: string }> = []
let totalMatchBytes = 0
let lineNumber = 0
let partialLine = "" // Holds incomplete line from previous chunk
let bytesRead = 0
let hitLimit = false
try {
while (bytesRead < totalSize && !hitLimit) {
const chunkSize = Math.min(CHUNK_SIZE, totalSize - bytesRead)
const buffer = Buffer.alloc(chunkSize)
const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead)
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
const lineContent = lines[i]
const lineBytes = Buffer.byteLength(lineContent, "utf8")
if (result.bytesRead === 0) {
// Stop if we've exceeded the byte limit
if (totalMatchBytes + lineBytes > limit) {
break
}
const chunk = buffer.slice(0, result.bytesRead).toString("utf8")
bytesRead += result.bytesRead
// Combine with partial line from previous chunk
const combined = partialLine + chunk
const lines = combined.split("\n")
// Last element may be incomplete (no trailing newline), save for next iteration
partialLine = lines.pop() ?? ""
// Process complete lines
for (const line of lines) {
lineNumber++
if (regex.test(line)) {
const lineBytes = Buffer.byteLength(line, "utf8")
// Stop if we've exceeded the byte limit
if (totalMatchBytes + lineBytes > limit) {
hitLimit = true
break
}
matches.push({ lineNumber, content: line })
totalMatchBytes += lineBytes
}
}
matches.push({ lineNumber: i + 1, content: lineContent })
totalMatchBytes += lineBytes
}
// Process any remaining partial line at end of file
if (!hitLimit && partialLine.length > 0) {
lineNumber++
if (regex.test(partialLine)) {
const lineBytes = Buffer.byteLength(partialLine, "utf8")
if (totalMatchBytes + lineBytes <= limit) {
matches.push({ lineNumber, content: partialLine })
}
}
}
} finally {
await fileHandle.close()
}
const artifactId = path.basename(artifactPath)
if (matches.length === 0) {
const content = [
return [
`[Command Output: ${artifactId}] (search: "${pattern}")`,
`Total size: ${this.formatBytes(totalSize)}`,
"",
"No matches found for the search pattern.",
].join("\n")
return { content, matchCount: 0 }
}
// Format matches with line numbers
const matchedLines = matches.map((m) => `${String(m.lineNumber).padStart(5)} | ${m.content}`).join("\n")
const content = [
return [
`[Command Output: ${artifactId}] (search: "${pattern}")`,
`Total matches: ${matches.length} | Showing first ${matches.length}`,
"",
matchedLines,
].join("\n")
return { content, matchCount: matches.length }
}
/**
@ -439,45 +374,6 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
private escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
/**
* Count newlines before a given byte offset using fixed-size chunks.
*
* This avoids allocating a buffer of size `offset` which could be huge
* for large files. Instead, we read in 64KB chunks and count newlines.
*
* @param fileHandle - Open file handle for reading
* @param offset - The byte offset to count newlines up to
* @returns The line number at the given offset (1-indexed)
* @private
*/
private async countNewlinesBeforeOffset(fileHandle: fs.FileHandle, offset: number): Promise<number> {
const CHUNK_SIZE = 64 * 1024 // 64KB chunks
let newlineCount = 0
let bytesRead = 0
while (bytesRead < offset) {
const chunkSize = Math.min(CHUNK_SIZE, offset - bytesRead)
const buffer = Buffer.alloc(chunkSize)
const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead)
if (result.bytesRead === 0) {
break
}
// Count newlines in this chunk
for (let i = 0; i < result.bytesRead; i++) {
if (buffer[i] === 0x0a) {
// '\n'
newlineCount++
}
}
bytesRead += result.bytesRead
}
return newlineCount + 1 // Line numbers are 1-indexed
}
}
/** Singleton instance of the ReadCommandOutputTool */

View file

@ -159,16 +159,16 @@ describe("ReadCommandOutputTool", () => {
})
describe("Pagination (offset/limit)", () => {
it("should use default limit of 40KB", async () => {
it("should use default limit of 32KB", async () => {
const artifactId = "cmd-1706119234567.txt"
const largeContent = "x".repeat(50 * 1024) // 50KB
const fileSize = Buffer.byteLength(largeContent, "utf8")
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
// Mock read to return only up to default limit (40KB)
// Mock read to return only up to default limit (32KB)
mockFileHandle.read.mockImplementation((buf: Buffer) => {
const defaultLimit = 40 * 1024
const defaultLimit = 32 * 1024
const bytesToRead = Math.min(buf.length, defaultLimit)
buf.write(largeContent.slice(0, bytesToRead))
return Promise.resolve({ bytesRead: bytesToRead })
@ -276,31 +276,13 @@ describe("ReadCommandOutputTool", () => {
})
describe("Search filtering", () => {
// Helper to setup file handle mock for search (which now uses streaming)
const setupSearchMock = (content: string) => {
const buffer = Buffer.from(content)
const fileSize = buffer.length
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
// Mock streaming read - return entire content in one chunk (simulates small file)
mockFileHandle.read.mockImplementation(
(buf: Buffer, bufOffset: number, length: number, position: number | null) => {
const pos = position ?? 0
if (pos >= fileSize) {
return Promise.resolve({ bytesRead: 0 })
}
const bytesToRead = Math.min(length, fileSize - pos)
buffer.copy(buf, 0, pos, pos + bytesToRead)
return Promise.resolve({ bytesRead: bytesToRead })
},
)
}
it("should filter lines matching pattern", async () => {
const artifactId = "cmd-1706119234567.txt"
const content = "Line 1: error occurred\nLine 2: success\nLine 3: error found\nLine 4: complete\n"
const fileSize = Buffer.byteLength(content, "utf8")
setupSearchMock(content)
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
vi.mocked(fs.readFile).mockResolvedValue(content)
await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks)
@ -314,8 +296,10 @@ describe("ReadCommandOutputTool", () => {
it("should use case-insensitive matching", async () => {
const artifactId = "cmd-1706119234567.txt"
const content = "ERROR: Something bad\nwarning: minor issue\nERROR: Another problem\n"
const fileSize = Buffer.byteLength(content, "utf8")
setupSearchMock(content)
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
vi.mocked(fs.readFile).mockResolvedValue(content)
await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks)
@ -327,8 +311,10 @@ describe("ReadCommandOutputTool", () => {
it("should show match count and line numbers", async () => {
const artifactId = "cmd-1706119234567.txt"
const content = "Line 1\nError on line 2\nLine 3\nError on line 4\n"
const fileSize = Buffer.byteLength(content, "utf8")
setupSearchMock(content)
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
vi.mocked(fs.readFile).mockResolvedValue(content)
await tool.execute({ artifact_id: artifactId, search: "Error" }, mockTask, mockCallbacks)
@ -341,8 +327,10 @@ describe("ReadCommandOutputTool", () => {
it("should handle empty search results gracefully", async () => {
const artifactId = "cmd-1706119234567.txt"
const content = "Line 1\nLine 2\nLine 3\n"
const fileSize = Buffer.byteLength(content, "utf8")
setupSearchMock(content)
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
vi.mocked(fs.readFile).mockResolvedValue(content)
await tool.execute({ artifact_id: artifactId, search: "NOTFOUND" }, mockTask, mockCallbacks)
@ -353,8 +341,10 @@ describe("ReadCommandOutputTool", () => {
it("should handle regex patterns in search", async () => {
const artifactId = "cmd-1706119234567.txt"
const content = "test123\ntest456\nabc789\ntest000\n"
const fileSize = Buffer.byteLength(content, "utf8")
setupSearchMock(content)
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
vi.mocked(fs.readFile).mockResolvedValue(content)
await tool.execute({ artifact_id: artifactId, search: "test\\d+" }, mockTask, mockCallbacks)
@ -368,8 +358,10 @@ describe("ReadCommandOutputTool", () => {
it("should handle invalid regex patterns by treating as literal", async () => {
const artifactId = "cmd-1706119234567.txt"
const content = "Line with [brackets]\nLine without\n"
const fileSize = Buffer.byteLength(content, "utf8")
setupSearchMock(content)
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
vi.mocked(fs.readFile).mockResolvedValue(content)
// Invalid regex but valid as literal string
await tool.execute({ artifact_id: artifactId, search: "[" }, mockTask, mockCallbacks)

View file

@ -3,6 +3,8 @@ import * as path from "path"
import { TerminalOutputPreviewSize, TERMINAL_PREVIEW_BYTES, PersistedCommandOutput } from "@roo-code/types"
import { processCarriageReturns, processBackspaces } from "../misc/extract-text"
/**
* Configuration options for creating an OutputInterceptor instance.
*/
@ -17,6 +19,8 @@ export interface OutputInterceptorOptions {
storageDir: string
/** Size category for the preview buffer (small/medium/large) */
previewSize: TerminalOutputPreviewSize
/** Whether to compress progress bar output using carriage return processing */
compressProgressBar: boolean
}
/**
@ -26,14 +30,13 @@ export interface OutputInterceptorOptions {
* files, with only a preview shown to the LLM. The LLM can then use the `read_command_output`
* tool to retrieve full contents or search through the output.
*
* The interceptor uses a **head/tail buffer** strategy (inspired by Codex):
* - 50% of the preview budget is allocated to the "head" (beginning of output)
* - 50% of the preview budget is allocated to the "tail" (end of output)
* - Middle content is dropped when output exceeds the preview threshold
* The interceptor operates in two modes:
* 1. **Buffer mode**: Output is accumulated in memory until it exceeds the preview threshold
* 2. **Spill mode**: Once threshold is exceeded, output is streamed directly to disk
*
* This approach ensures the LLM sees both:
* - The beginning (command startup, environment info, early errors)
* - The end (final results, exit codes, error summaries)
* This approach prevents large command outputs (like build logs, test results, or verbose
* operations) from overwhelming the context window while still allowing the LLM to access
* the full output when needed.
*
* @example
* ```typescript
@ -43,6 +46,7 @@ export interface OutputInterceptorOptions {
* command: 'npm test',
* storageDir: '/path/to/task/command-output',
* previewSize: 'medium',
* compressProgressBar: true
* });
*
* // Write output chunks as they arrive
@ -51,38 +55,18 @@ export interface OutputInterceptorOptions {
*
* // Finalize and get the result
* const result = interceptor.finalize();
* // result.preview contains head + [omitted] + tail for display
* // result.preview contains truncated output for display
* // result.artifactPath contains path to full output if truncated
* ```
*/
export class OutputInterceptor {
/** Buffer for the head (beginning) of output */
private headBuffer: string = ""
/** Buffer for the tail (end) of output - rolling buffer that drops front when full */
private tailBuffer: string = ""
/** Number of bytes currently in the head buffer */
private headBytes: number = 0
/** Number of bytes currently in the tail buffer */
private tailBytes: number = 0
/** Number of bytes omitted from the middle */
private omittedBytes: number = 0
/**
* Pending chunks accumulated before spilling to disk.
* These contain ALL content (lossless) until we decide to spill.
* Once spilled, this array is cleared and subsequent writes go directly to disk.
*/
private pendingChunks: string[] = []
private buffer: string = ""
private writeStream: fs.WriteStream | null = null
private artifactPath: string
private totalBytes: number = 0
private spilledToDisk: boolean = false
private readonly previewBytes: number
/** Budget for the head buffer (50% of total preview) */
private readonly headBudget: number
/** Budget for the tail buffer (50% of total preview) */
private readonly tailBudget: number
private readonly compressProgressBar: boolean
/**
* Creates a new OutputInterceptor instance.
@ -91,19 +75,16 @@ export class OutputInterceptor {
*/
constructor(private readonly options: OutputInterceptorOptions) {
this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize]
this.headBudget = Math.floor(this.previewBytes / 2)
this.tailBudget = this.previewBytes - this.headBudget
this.compressProgressBar = options.compressProgressBar
this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`)
}
/**
* Write a chunk of output to the interceptor.
*
* Output is first added to the head buffer until it's full (50% of preview budget).
* Subsequent output goes to a rolling tail buffer that keeps the most recent content.
*
* If the total output exceeds the preview threshold, the interceptor spills to disk
* for full output storage while maintaining head/tail buffers for the preview.
* If the accumulated output exceeds the preview threshold, the interceptor
* automatically spills to disk and switches to streaming mode. Subsequent
* chunks are written directly to the disk file.
*
* @param chunk - The output string to write
*
@ -117,15 +98,10 @@ export class OutputInterceptor {
const chunkBytes = Buffer.byteLength(chunk, "utf8")
this.totalBytes += chunkBytes
// Always update the head/tail preview buffers
this.addToPreviewBuffers(chunk)
// Handle disk spilling for full output preservation
if (!this.spilledToDisk) {
// Accumulate ALL chunks for lossless disk storage
this.pendingChunks.push(chunk)
this.buffer += chunk
if (this.totalBytes > this.previewBytes) {
if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) {
this.spillToDisk()
}
} else {
@ -134,127 +110,6 @@ export class OutputInterceptor {
}
}
/**
* Add a chunk to the head/tail preview buffers using 50/50 split strategy.
*
* Fill head first until budget exhausted, then maintain a rolling tail buffer.
*
* @private
*/
private addToPreviewBuffers(chunk: string): void {
let remaining = chunk
let remainingBytes = Buffer.byteLength(chunk, "utf8")
// First, fill the head buffer if there's room
if (this.headBytes < this.headBudget) {
const headRoom = this.headBudget - this.headBytes
if (remainingBytes <= headRoom) {
// Entire chunk fits in head
this.headBuffer += remaining
this.headBytes += remainingBytes
return
}
// Split: part goes to head, rest goes to tail
const headPortion = this.sliceByBytes(remaining, headRoom)
this.headBuffer += headPortion
this.headBytes += headRoom
remaining = remaining.slice(headPortion.length)
remainingBytes = Buffer.byteLength(remaining, "utf8")
}
// Add remainder to tail buffer
this.addToTailBuffer(remaining, remainingBytes)
}
/**
* Add content to the rolling tail buffer, dropping old content as needed.
*
* @private
*/
private addToTailBuffer(chunk: string, chunkBytes: number): void {
if (this.tailBudget === 0) {
this.omittedBytes += chunkBytes
return
}
// If this single chunk is larger than the tail budget, keep only the last tailBudget bytes
if (chunkBytes >= this.tailBudget) {
const dropped = this.tailBytes + (chunkBytes - this.tailBudget)
this.omittedBytes += dropped
this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget)
this.tailBytes = this.tailBudget
return
}
// Append to tail
this.tailBuffer += chunk
this.tailBytes += chunkBytes
// Trim from front if over budget
this.trimTailToFit()
}
/**
* Trim the tail buffer from the front to fit within the tail budget.
*
* @private
*/
private trimTailToFit(): void {
while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) {
const excess = this.tailBytes - this.tailBudget
// Remove characters from the front until we're under budget
// We need to be careful with multi-byte characters
let removed = 0
let removeChars = 0
while (removed < excess && removeChars < this.tailBuffer.length) {
const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8")
removed += charBytes
removeChars++
}
this.omittedBytes += removed
this.tailBytes -= removed
this.tailBuffer = this.tailBuffer.slice(removeChars)
}
}
/**
* Slice a string to get approximately the first N bytes (UTF-8).
*
* @private
*/
private sliceByBytes(str: string, maxBytes: number): string {
let bytes = 0
let i = 0
while (i < str.length && bytes < maxBytes) {
const charBytes = Buffer.byteLength(str[i], "utf8")
if (bytes + charBytes > maxBytes) {
break
}
bytes += charBytes
i++
}
return str.slice(0, i)
}
/**
* Slice a string to get approximately the last N bytes (UTF-8).
*
* @private
*/
private sliceByBytesFromEnd(str: string, maxBytes: number): string {
let bytes = 0
let i = str.length - 1
while (i >= 0 && bytes < maxBytes) {
const charBytes = Buffer.byteLength(str[i], "utf8")
if (bytes + charBytes > maxBytes) {
break
}
bytes += charBytes
i--
}
return str.slice(i + 1)
}
/**
* Spill buffered content to disk and switch to streaming mode.
*
@ -272,36 +127,30 @@ export class OutputInterceptor {
}
this.writeStream = fs.createWriteStream(this.artifactPath)
// Write ALL pending chunks to disk for lossless storage.
// This ensures no content is lost, even if the preview buffers have dropped middle content.
for (const chunk of this.pendingChunks) {
this.writeStream.write(chunk)
}
// Clear pending chunks to free memory - subsequent writes go directly to disk
this.pendingChunks = []
this.writeStream.write(this.buffer)
this.spilledToDisk = true
// Keep only preview portion in memory
this.buffer = this.buffer.slice(0, this.previewBytes)
}
/**
* Finalize the interceptor and return the persisted output result.
*
* Closes any open file streams and waits for them to fully flush before returning.
* This ensures the artifact file is completely written and ready for reading.
*
* Returns a summary object containing:
* - A preview of the output (head + [omitted indicator] + tail)
* Closes any open file streams and returns a summary object containing:
* - A preview of the output (truncated to preview size)
* - The total byte count of all output
* - The path to the full output file (if truncated)
* - A flag indicating whether the output was truncated
*
* If `compressProgressBar` was enabled, the preview will have carriage returns
* and backspaces processed to show only final line states.
*
* @returns The persisted command output summary
*
* @example
* ```typescript
* const result = await interceptor.finalize();
* const result = interceptor.finalize();
* console.log(`Preview: ${result.preview}`);
* console.log(`Total bytes: ${result.totalBytes}`);
* if (result.truncated) {
@ -309,24 +158,19 @@ export class OutputInterceptor {
* }
* ```
*/
async finalize(): Promise<PersistedCommandOutput> {
// Close write stream if open and wait for it to fully flush.
// This ensures the artifact is completely written before we advertise the artifact_id.
finalize(): PersistedCommandOutput {
// Close write stream if open
if (this.writeStream) {
await new Promise<void>((resolve, reject) => {
this.writeStream!.end(() => resolve())
this.writeStream!.on("error", reject)
})
this.writeStream.end()
}
// Prepare preview: head + [omission indicator] + tail
let preview: string
if (this.omittedBytes > 0) {
const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n`
preview = this.headBuffer + omissionIndicator + this.tailBuffer
} else {
// No truncation, just combine head and tail (or head alone if tail is empty)
preview = this.headBuffer + this.tailBuffer
// Prepare preview
let preview = this.buffer.slice(0, this.previewBytes)
// Apply compression to preview only (for readability)
if (this.compressProgressBar) {
preview = processCarriageReturns(preview)
preview = processBackspaces(preview)
}
return {
@ -340,15 +184,13 @@ export class OutputInterceptor {
/**
* Get the current buffer content for UI display.
*
* Returns the combined head + tail content for real-time UI updates.
* Note: Does not include the omission indicator to avoid flickering during streaming.
* Returns the in-memory buffer which contains either all output (if not spilled)
* or just the preview portion (if spilled to disk).
*
* @returns The current buffer content as a string
*/
getBufferForUI(): string {
// For UI, return combined head + tail without omission indicator
// This provides a smoother streaming experience
return this.headBuffer + this.tailBuffer
return this.buffer
}
/**

View file

@ -32,16 +32,12 @@ describe("OutputInterceptor", () => {
beforeEach(() => {
vi.clearAllMocks()
storageDir = path.normalize("/tmp/test-storage")
storageDir = "/tmp/test-storage"
// Setup mock write stream with callback support for end()
// Setup mock write stream
mockWriteStream = {
write: vi.fn(),
end: vi.fn((callback?: () => void) => {
// Immediately call the callback to simulate stream flush completing
if (callback) callback()
}),
on: vi.fn(),
end: vi.fn(),
}
vi.mocked(fs.existsSync).mockReturnValue(true)
@ -53,13 +49,14 @@ describe("OutputInterceptor", () => {
})
describe("Buffering behavior", () => {
it("should keep small output in memory without spilling to disk", async () => {
it("should keep small output in memory without spilling to disk", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "echo test",
storageDir,
previewSize: "small", // 5KB
previewSize: "small", // 2KB
compressProgressBar: false,
})
const smallOutput = "Hello World\n"
@ -68,7 +65,7 @@ describe("OutputInterceptor", () => {
expect(interceptor.hasSpilledToDisk()).toBe(false)
expect(fs.createWriteStream).not.toHaveBeenCalled()
const result = await interceptor.finalize()
const result = interceptor.finalize()
expect(result.preview).toBe(smallOutput)
expect(result.truncated).toBe(false)
expect(result.artifactPath).toBe(null)
@ -81,45 +78,44 @@ describe("OutputInterceptor", () => {
taskId: "task-1",
command: "echo test",
storageDir,
previewSize: "small", // 5KB = 5120 bytes
previewSize: "small", // 2KB = 2048 bytes
compressProgressBar: false,
})
// Write enough data to exceed 5KB threshold
const chunk = "x".repeat(2 * 1024) // 2KB chunk
// Write enough data to exceed 2KB threshold
const chunk = "x".repeat(1024) // 1KB chunk
interceptor.write(chunk) // 1KB - should stay in memory
expect(interceptor.hasSpilledToDisk()).toBe(false)
interceptor.write(chunk) // 2KB - should stay in memory
expect(interceptor.hasSpilledToDisk()).toBe(false)
interceptor.write(chunk) // 4KB - should stay in memory
expect(interceptor.hasSpilledToDisk()).toBe(false)
interceptor.write(chunk) // 6KB - should trigger spill
interceptor.write(chunk) // 3KB - should trigger spill
expect(interceptor.hasSpilledToDisk()).toBe(true)
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt"))
expect(mockWriteStream.write).toHaveBeenCalled()
})
it("should truncate preview after spilling to disk using head/tail split", async () => {
it("should truncate preview after spilling to disk", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "echo test",
storageDir,
previewSize: "small", // 5KB
previewSize: "small", // 2KB
compressProgressBar: false,
})
// Write data that exceeds threshold
const chunk = "x".repeat(6000)
const chunk = "x".repeat(3000)
interceptor.write(chunk)
expect(interceptor.hasSpilledToDisk()).toBe(true)
const result = await interceptor.finalize()
const result = interceptor.finalize()
expect(result.truncated).toBe(true)
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
// Preview is head (1024) + omission indicator + tail (1024)
// The omission indicator adds some extra bytes
expect(result.preview).toContain("[...")
expect(result.preview).toContain("bytes omitted...]")
expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048)
})
it("should write subsequent chunks directly to disk after spilling", () => {
@ -129,10 +125,11 @@ describe("OutputInterceptor", () => {
command: "echo test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
// Trigger spill (must exceed 5KB = 5120 bytes)
const largeChunk = "x".repeat(6000)
// Trigger spill
const largeChunk = "x".repeat(3000)
interceptor.write(largeChunk)
expect(interceptor.hasSpilledToDisk()).toBe(true)
@ -148,56 +145,59 @@ describe("OutputInterceptor", () => {
})
describe("Threshold settings", () => {
it("should handle small (5KB) threshold correctly", () => {
it("should handle small (2KB) threshold correctly", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
// Write exactly 5KB
interceptor.write("x".repeat(5 * 1024))
// Write exactly 2KB
interceptor.write("x".repeat(2048))
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write more to exceed 5KB
// Write more to exceed 2KB
interceptor.write("x")
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
it("should handle medium (10KB) threshold correctly", () => {
it("should handle medium (4KB) threshold correctly", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "medium",
compressProgressBar: false,
})
// Write exactly 10KB
interceptor.write("x".repeat(10 * 1024))
// Write exactly 4KB
interceptor.write("x".repeat(4096))
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write more to exceed 10KB
// Write more to exceed 4KB
interceptor.write("x")
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
it("should handle large (20KB) threshold correctly", () => {
it("should handle large (8KB) threshold correctly", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "large",
compressProgressBar: false,
})
// Write exactly 20KB
interceptor.write("x".repeat(20 * 1024))
// Write exactly 8KB
interceptor.write("x".repeat(8192))
expect(interceptor.hasSpilledToDisk()).toBe(false)
// Write more to exceed 20KB
// Write more to exceed 8KB
interceptor.write("x")
expect(interceptor.hasSpilledToDisk()).toBe(true)
})
@ -213,10 +213,11 @@ describe("OutputInterceptor", () => {
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
// Trigger spill (must exceed 5KB = 5120 bytes)
interceptor.write("x".repeat(6000))
// Trigger spill
interceptor.write("x".repeat(3000))
expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true })
})
@ -229,31 +230,30 @@ describe("OutputInterceptor", () => {
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
// Trigger spill (must exceed 5KB = 5120 bytes)
interceptor.write("x".repeat(6000))
// Trigger spill
interceptor.write("x".repeat(3000))
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`))
})
it("should write head and tail buffers to artifact when spilling", () => {
it("should write full output to artifact, not truncated", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB = 5120 bytes, so head=2560, tail=2560
previewSize: "small",
compressProgressBar: false,
})
const fullOutput = "x".repeat(10000)
const fullOutput = "x".repeat(5000)
interceptor.write(fullOutput)
// The write stream should receive the head buffer content first
// (spillToDisk writes head + tail that existed at spill time)
expect(mockWriteStream.write).toHaveBeenCalled()
// Verify that we're writing to disk
expect(interceptor.hasSpilledToDisk()).toBe(true)
// The write stream should receive the full buffer content
expect(mockWriteStream.write).toHaveBeenCalledWith(fullOutput)
})
it("should get artifact path from getArtifactPath() method", () => {
@ -264,6 +264,7 @@ describe("OutputInterceptor", () => {
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
const expectedPath = path.join(storageDir, `cmd-${executionId}.txt`)
@ -272,19 +273,20 @@ describe("OutputInterceptor", () => {
})
describe("finalize() method", () => {
it("should return preview output for small commands", async () => {
it("should return preview output for small commands", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "echo hello",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
const output = "Hello World\n"
interceptor.write(output)
const result = await interceptor.finalize()
const result = interceptor.finalize()
expect(result.preview).toBe(output)
expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8"))
@ -292,61 +294,61 @@ describe("OutputInterceptor", () => {
expect(result.truncated).toBe(false)
})
it("should return PersistedCommandOutput for large commands with head/tail preview", async () => {
it("should return PersistedCommandOutput for large commands", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
previewSize: "small",
compressProgressBar: false,
})
const largeOutput = "x".repeat(10000)
const largeOutput = "x".repeat(5000)
interceptor.write(largeOutput)
const result = await interceptor.finalize()
const result = interceptor.finalize()
expect(result.truncated).toBe(true)
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
expect(result.totalBytes).toBe(Buffer.byteLength(largeOutput, "utf8"))
// Preview should contain head + omission indicator + tail
expect(result.preview).toContain("[...")
expect(result.preview).toContain("bytes omitted...]")
expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048)
})
it("should close write stream when finalizing", async () => {
it("should close write stream when finalizing", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
// Trigger spill (must exceed 5KB = 5120 bytes)
interceptor.write("x".repeat(6000))
await interceptor.finalize()
// Trigger spill
interceptor.write("x".repeat(3000))
interceptor.finalize()
expect(mockWriteStream.end).toHaveBeenCalled()
})
it("should include correct metadata (artifactId, size, truncated flag)", async () => {
it("should include correct metadata (artifactId, size, truncated flag)", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
// Must exceed 5KB = 5120 bytes to trigger truncation
const output = "x".repeat(6000)
const output = "x".repeat(5000)
interceptor.write(output)
const result = await interceptor.finalize()
const result = interceptor.finalize()
expect(result).toHaveProperty("preview")
expect(result).toHaveProperty("totalBytes", 6000)
expect(result).toHaveProperty("totalBytes", 5000)
expect(result).toHaveProperty("artifactPath")
expect(result).toHaveProperty("truncated", true)
expect(result.artifactPath).toMatch(/cmd-12345\.txt$/)
@ -401,6 +403,46 @@ describe("OutputInterceptor", () => {
})
})
describe("Progress bar compression", () => {
it("should apply compression when compressProgressBar is true", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: true,
})
// Output with carriage returns (simulating progress bar)
const output = "Progress: 10%\rProgress: 50%\rProgress: 100%\n"
interceptor.write(output)
const result = interceptor.finalize()
// Preview should be compressed (carriage returns processed)
// The processCarriageReturns function should keep only the last line before \r
expect(result.preview).not.toBe(output)
})
it("should not apply compression when compressProgressBar is false", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
const output = "Line 1\nLine 2\n"
interceptor.write(output)
const result = interceptor.finalize()
expect(result.preview).toBe(output)
})
})
describe("getBufferForUI() method", () => {
it("should return current buffer for UI updates", () => {
const interceptor = new OutputInterceptor({
@ -409,6 +451,7 @@ describe("OutputInterceptor", () => {
command: "test",
storageDir,
previewSize: "small",
compressProgressBar: false,
})
const output = "Hello World"
@ -417,116 +460,22 @@ describe("OutputInterceptor", () => {
expect(interceptor.getBufferForUI()).toBe(output)
})
it("should return head + tail buffer after spilling to disk", () => {
it("should return truncated buffer after spilling to disk", () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
previewSize: "small",
compressProgressBar: false,
})
// Trigger spill
const largeOutput = "x".repeat(10000)
const largeOutput = "x".repeat(5000)
interceptor.write(largeOutput)
const buffer = interceptor.getBufferForUI()
// Buffer for UI is head + tail (no omission indicator for smooth streaming)
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(5120)
})
})
describe("Head/Tail split behavior", () => {
it("should preserve first 50% and last 50% of output", async () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
})
// Create identifiable head and tail content
const headContent = "HEAD".repeat(750) // 3000 bytes
const middleContent = "M".repeat(6000) // 6000 bytes (will be omitted)
const tailContent = "TAIL".repeat(750) // 3000 bytes
interceptor.write(headContent)
interceptor.write(middleContent)
interceptor.write(tailContent)
const result = await interceptor.finalize()
// Should start with HEAD content (first 2560 bytes of head budget)
expect(result.preview.startsWith("HEAD")).toBe(true)
// Should end with TAIL content (last 2560 bytes)
expect(result.preview.endsWith("TAIL")).toBe(true)
// Should have omission indicator
expect(result.preview).toContain("[...")
expect(result.preview).toContain("bytes omitted...]")
})
it("should not add omission indicator when output fits in budget", async () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB
})
const smallOutput = "Hello World\n"
interceptor.write(smallOutput)
const result = await interceptor.finalize()
// No omission indicator for small output
expect(result.preview).toBe(smallOutput)
expect(result.preview).not.toContain("[...")
})
it("should handle output that exactly fills head budget", async () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB = 5120, head=2560
})
// Write exactly 2560 bytes (head budget)
const exactHeadContent = "x".repeat(2560)
interceptor.write(exactHeadContent)
const result = await interceptor.finalize()
// Should fit entirely in head, no truncation
expect(result.preview).toBe(exactHeadContent)
expect(result.truncated).toBe(false)
})
it("should split single large chunk across head and tail", async () => {
const interceptor = new OutputInterceptor({
executionId: "12345",
taskId: "task-1",
command: "test",
storageDir,
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
})
// Write a single chunk larger than preview budget
// First 2560 chars go to head, last 2560 chars go to tail
const content = "A".repeat(2560) + "B".repeat(4000) + "C".repeat(2560)
interceptor.write(content)
const result = await interceptor.finalize()
// Head should have A's
expect(result.preview.startsWith("A")).toBe(true)
// Tail should have C's
expect(result.preview.endsWith("C")).toBe(true)
// Should have omission indicator
expect(result.preview).toContain("[...")
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(2048)
})
})
})

View file

@ -0,0 +1,57 @@
/**
* Terminal Output Handling Module
*
* This module provides utilities for capturing, persisting, and retrieving
* command output from terminal executions.
*
* ## Overview
*
* When the LLM executes commands via `execute_command`, the output can be
* very large (build logs, test output, etc.). To prevent context window
* overflow while still allowing access to full output, this module
* implements a "persisted output" pattern:
*
* 1. **OutputInterceptor**: Buffers command output during execution. If
* output exceeds a configurable threshold, it "spills" to disk and
* keeps only a preview in memory.
*
* 2. **Artifact Storage**: Full outputs are stored as text files in the
* task's `command-output/` directory with names like `cmd-{timestamp}.txt`.
*
* 3. **ReadCommandOutputTool**: Allows the LLM to retrieve the full output
* later via the `read_command_output` tool, with support for search
* and pagination.
*
* ## Data Flow
*
* ```
* execute_command
*
*
* OutputInterceptor.write() Buffer accumulates
*
* (threshold exceeded)
* OutputInterceptor.spillToDisk() Artifact file created
*
*
* OutputInterceptor.finalize() Returns PersistedCommandOutput
*
*
* LLM receives preview + artifact_id
*
* (if needs full output)
* read_command_output(artifact_id) Full content/search results
* ```
*
* ## Configuration
*
* Preview size is controlled by `terminalOutputPreviewSize` setting:
* - `small`: 2KB preview
* - `medium`: 4KB preview (default)
* - `large`: 8KB preview
*
* @module terminal
*/
export { OutputInterceptor } from "./OutputInterceptor"
export type { OutputInterceptorOptions } from "./OutputInterceptor"

View file

@ -541,6 +541,7 @@
"@types/diff": "^5.2.1",
"@types/diff-match-patch": "^1.0.36",
"@types/glob": "^8.1.0",
"@types/json-stream-stringify": "^2.0.4",
"@types/lodash.debounce": "^4.0.9",
"@types/mocha": "^10.0.10",
"@types/node": "20.x",

View file

@ -179,6 +179,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
ttsSpeed,
soundVolume,
telemetrySetting,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
terminalOutputPreviewSize,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled, // Added from upstream
@ -397,6 +399,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
terminalCompressProgressBar,
terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium",
mcpEnabled,
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),

View file

@ -124,6 +124,31 @@ export const TerminalSettings = ({
{t("settings:terminal.outputPreviewSize.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="terminal-compress-progress-bar"
section="terminal"
label={t("settings:terminal.compressProgressBar.label")}>
<VSCodeCheckbox
checked={terminalCompressProgressBar ?? true}
onChange={(e: any) =>
setCachedStateField("terminalCompressProgressBar", e.target.checked)
}
data-testid="terminal-compress-progress-bar-checkbox">
<span className="font-medium">{t("settings:terminal.compressProgressBar.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
<Trans i18nKey="settings:terminal.compressProgressBar.description">
<VSCodeLink
href={buildDocLink(
"features/shell-integration#compress-progress-bar-output",
"settings_terminal_compress_progress_bar",
)}
style={{ display: "inline" }}>
{" "}
</VSCodeLink>
</Trans>
</div>
</SearchableSetting>
</div>
</div>

View file

@ -96,6 +96,10 @@ export interface ExtensionStateContextType extends ExtensionState {
setWriteDelayMs: (value: number) => void
screenshotQuality?: number
setScreenshotQuality: (value: number) => void
terminalOutputLineLimit?: number
setTerminalOutputLineLimit: (value: number) => void
terminalOutputCharacterLimit?: number
setTerminalOutputCharacterLimit: (value: number) => void
terminalOutputPreviewSize?: "small" | "medium" | "large"
setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void
mcpEnabled: boolean
@ -537,6 +541,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })),
setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
setTerminalOutputLineLimit: (value) =>
setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setTerminalOutputCharacterLimit: (value) =>
setState((prevState) => ({ ...prevState, terminalOutputCharacterLimit: value })),
setTerminalOutputPreviewSize: (value) =>
setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })),
setTerminalShellIntegrationTimeout: (value) =>

View file

@ -737,9 +737,9 @@
"label": "Command output preview size",
"description": "Controls how much command output Roo sees directly. Full output is always saved and accessible when needed.",
"options": {
"small": "Small (5KB)",
"medium": "Medium (10KB)",
"large": "Large (20KB)"
"small": "Small (2KB)",
"medium": "Medium (4KB)",
"large": "Large (8KB)"
}
},
"shellIntegrationTimeout": {