diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1294ab849e9..193766fb797 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -30,6 +30,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" +import { formatSize } from "@/util/format" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -1608,6 +1609,14 @@ function Bash(props: ToolProps) { return [...lines().slice(0, 10), "…"].join("\n") }) + const filterInfo = createMemo(() => { + if (!props.metadata.filtered) return undefined + const total = formatSize(props.metadata.totalBytes ?? 0) + const omitted = formatSize(props.metadata.omittedBytes ?? 0) + const matches = props.metadata.matchCount ?? 0 + return `Filtered: ${matches} match${matches === 1 ? "" : "es"} from ${total} (${omitted} omitted)` + }) + const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined @@ -1644,6 +1653,9 @@ function Bash(props: ToolProps) { $ {props.input.command} {limited()} + + {filterInfo()} + {expanded() ? "Click to collapse" : "Click to expand"} diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 704d3572bbb..2dbc6b5a37d 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -3,6 +3,7 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { Installation } from "../../installation" import { Global } from "../../global" +import { formatSize } from "../../util/format" import { $ } from "bun" import fs from "fs/promises" import path from "path" @@ -341,13 +342,6 @@ async function getDirectorySize(dir: string): Promise { return total } -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` -} - function shortenPath(p: string): string { const home = os.homedir() if (p.startsWith(home)) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index de62788200b..ff2cf8ff1b7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -29,7 +29,7 @@ import { ListTool } from "../tool/ls" import { FileTime } from "../file/time" import { Flag } from "../flag/flag" import { ulid } from "ulid" -import { spawn } from "child_process" + import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" @@ -44,7 +44,8 @@ import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" -import { Truncate } from "@/tool/truncation" +import { Truncate, StreamingOutput } from "@/tool/truncation" +import { spawn } from "child_process" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1475,39 +1476,31 @@ NOTE: At any point in time through this workflow you should feel free to ask the const matchingInvocation = invocations[shellName] ?? invocations[""] const args = matchingInvocation?.args + const streaming = new StreamingOutput() + const proc = spawn(shell, args, { cwd: Instance.directory, detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, TERM: "dumb", }, + stdio: ["ignore", "pipe", "pipe"], }) - let output = "" - - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() + const append = (chunk: Buffer) => { + const preview = streaming.append(chunk) if (part.state.status === "running") { part.state.metadata = { - output: output, + output: preview, description: "", } Session.updatePart(part) } - }) + } - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) let aborted = false let exited = false @@ -1526,33 +1519,72 @@ NOTE: At any point in time through this workflow you should feel free to ask the abort.addEventListener("abort", abortHandler, { once: true }) - await new Promise((resolve) => { - proc.on("close", () => { - exited = true + await new Promise((resolve, reject) => { + const cleanup = () => { abort.removeEventListener("abort", abortHandler) + } + + proc.once("exit", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + + proc.once("close", () => { + exited = true + cleanup() resolve() }) }) + streaming.close() + if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") + streaming.appendMetadata("\n\n" + ["", "User aborted the command", ""].join("\n")) } + msg.time.completed = Date.now() await Session.updateMessage(msg) + if (part.state.status === "running") { - part.state = { - status: "completed", - time: { - ...part.state.time, - end: Date.now(), - }, - input: part.state.input, - title: "", - metadata: { + if (streaming.truncated) { + part.state = { + status: "completed", + time: { + ...part.state.time, + end: Date.now(), + }, + input: part.state.input, + title: "", + metadata: { + output: `[output streamed to file: ${streaming.totalBytes} bytes]`, + description: "", + outputPath: streaming.outputPath, + }, + output: streaming.finalize(), + } + } else { + const output = streaming.inMemoryOutput + part.state = { + status: "completed", + time: { + ...part.state.time, + end: Date.now(), + }, + input: part.state.input, + title: "", + metadata: { + output, + description: "", + }, output, - description: "", - }, - output, + } } await Session.updatePart(part) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index bf7c524941f..a331ac9762a 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -9,19 +9,31 @@ import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" import { $ } from "bun" -import { Filesystem } from "@/util/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncation" +import { Truncate, StreamingOutput } from "./truncation" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) +export interface BashMetadata { + output: string + exit: number | null + description: string + truncated?: boolean + outputPath?: string + filtered?: boolean + filterPattern?: string + matchCount?: number + totalBytes?: number + omittedBytes?: number +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -68,6 +80,12 @@ export const BashTool = Tool.define("bash", async () => { `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, ) .optional(), + output_filter: z + .string() + .describe( + `Optional regex pattern to filter output. When set, full output streams to a file while lines matching the pattern are returned inline. Useful for build commands where you only care about warnings/errors. Example: "^(warning|error|WARN|ERROR):.*" to capture compiler diagnostics. The regex is matched against each line.`, + ) + .optional(), description: z .string() .describe( @@ -80,6 +98,16 @@ export const BashTool = Tool.define("bash", async () => { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = params.timeout ?? DEFAULT_TIMEOUT + + // Parse output_filter regex if provided + let filter: RegExp | undefined + if (params.output_filter) { + try { + filter = new RegExp(params.output_filter) + } catch (e) { + throw new Error(`Invalid output_filter regex: ${params.output_filter}. ${e}`) + } + } const tree = await parser().then((p) => p.parse(params.command)) if (!tree) { throw new Error("Failed to parse command") @@ -154,6 +182,8 @@ export const BashTool = Tool.define("bash", async () => { }) } + const streaming = new StreamingOutput({ filter }) + const proc = spawn(params.command, { shell, cwd, @@ -164,8 +194,6 @@ export const BashTool = Tool.define("bash", async () => { detached: process.platform !== "win32", }) - let output = "" - // Initialize metadata with empty output ctx.metadata({ metadata: { @@ -175,11 +203,12 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - output += chunk.toString() + const preview = streaming.append(chunk) + const display = + preview.length > MAX_METADATA_LENGTH ? preview.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : preview ctx.metadata({ metadata: { - // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, + output: display, description: params.description, }, }) @@ -228,29 +257,75 @@ export const BashTool = Tool.define("bash", async () => { cleanup() reject(error) }) + + proc.once("close", () => { + exited = true + cleanup() + resolve() + }) }) - const resultMetadata: string[] = [] + streaming.close() + const resultMetadata: string[] = [] if (timedOut) { resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } - if (aborted) { resultMetadata.push("User aborted the command") } - if (resultMetadata.length > 0) { - output += "\n\n\n" + resultMetadata.join("\n") + "\n" + streaming.appendMetadata("\n\n\n" + resultMetadata.join("\n") + "\n") + } + + // If using filter, return filtered lines + if (streaming.hasFilter) { + const output = streaming.truncated + ? `${streaming.filteredOutput}\n${streaming.finalize(params.output_filter)}` + : streaming.finalize(params.output_filter) + + return { + title: params.description, + metadata: { + output: streaming.filteredOutput || `[no matches for filter: ${params.output_filter}]`, + exit: proc.exitCode, + description: params.description, + truncated: streaming.truncated, + outputPath: streaming.outputPath, + filtered: true, + filterPattern: params.output_filter, + matchCount: streaming.matchCount, + totalBytes: streaming.totalBytes, + omittedBytes: streaming.omittedBytes, + } as BashMetadata, + output, + } + } + + // If we streamed to a file (threshold exceeded), return truncated result + if (streaming.truncated) { + return { + title: params.description, + metadata: { + output: `[output streamed to file: ${streaming.totalBytes} bytes]`, + exit: proc.exitCode, + description: params.description, + truncated: true, + outputPath: streaming.outputPath, + totalBytes: streaming.totalBytes, + } as BashMetadata, + output: streaming.finalize(), + } } + const output = streaming.inMemoryOutput return { title: params.description, metadata: { output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, exit: proc.exitCode, description: params.description, - }, + } as BashMetadata, output, } }, diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 9fbc9fcf37e..b2f7f8cb658 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -25,6 +25,7 @@ Usage notes: - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly. + - For build commands (make, cargo build, npm run build, tsc, etc.) that produce lots of output where you only care about warnings/errors, use the `output_filter` parameter with a regex like "^(warning|error|WARN|ERROR):". This streams full output to a file while returning only matching lines inline, saving you from having to grep the output afterward. - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 84e799c1310..27918c7bb87 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -1,10 +1,193 @@ import fs from "fs/promises" +import fsSync from "fs" import path from "path" import { Global } from "../global" import { Identifier } from "../id/id" import { PermissionNext } from "../permission/next" import type { Agent } from "../agent/agent" import { Scheduler } from "../scheduler" +import { Log } from "../util/log" + +const log = Log.create({ service: "truncation" }) + +export interface StreamingOutputOptions { + threshold?: number + /** Optional regex to filter output lines. Matching lines are collected separately. */ + filter?: RegExp +} + +/** + * Streaming output accumulator that spills to disk when threshold is exceeded. + * Avoids O(n²) memory growth from string concatenation. + * + * Optionally supports line filtering - when a filter regex is provided, matching + * lines are collected separately while full output still streams to file. + */ +export class StreamingOutput { + private output = "" + private outputBytes = 0 + private streamFile: { fd: number; path: string } | undefined + private streamedBytes = 0 + private threshold: number + private filter?: RegExp + private filtered = "" + private filteredBytes = 0 + private filteredCount = 0 + private lineBuffer = "" + + constructor(options: StreamingOutputOptions = {}) { + this.threshold = options.threshold ?? Truncate.MAX_BYTES + this.filter = options.filter + } + + /** Append a chunk of output. Returns the current preview string. */ + append(chunk: Buffer): string { + const text = chunk.toString() + this.outputBytes += chunk.length + + // Spill to file when threshold exceeded + if (!this.streamFile && this.outputBytes > this.threshold) { + this.streamFile = this.createStreamFile() + } + + if (this.streamFile) { + fsSync.writeSync(this.streamFile.fd, text) + this.streamedBytes += Buffer.byteLength(text, "utf-8") + } else { + this.output += text + } + + // Process filter if active + if (this.filter) { + this.lineBuffer += text + const lines = this.lineBuffer.split("\n") + this.lineBuffer = lines.pop() || "" + for (const line of lines) { + if (this.filter.test(line)) { + const entry = line + "\n" + this.filtered += entry + this.filteredBytes += Buffer.byteLength(entry, "utf-8") + this.filteredCount++ + } + } + } + + return this.preview() + } + + /** Get current preview - either full output, streaming indicator, or filter status */ + preview(): string { + if (this.filter) { + if (this.filtered) return this.filtered + return `[filtering: ${this.outputBytes} bytes, ${this.matchCount} matches...]\n` + } + if (this.streamFile) { + return `[streaming to file: ${this.streamedBytes} bytes written...]\n` + } + return this.output + } + + /** Whether output was streamed to file */ + get truncated(): boolean { + return this.streamFile !== undefined + } + + /** Total bytes written */ + get totalBytes(): number { + return this.streamFile ? this.streamedBytes : this.outputBytes + } + + /** Path to output file (if streaming) */ + get outputPath(): string | undefined { + return this.streamFile?.path + } + + /** Get the in-memory output (only valid if not truncated) */ + get inMemoryOutput(): string { + return this.output + } + + /** Get filtered output (only when filter is active) */ + get filteredOutput(): string { + return this.filtered + } + + /** Number of lines matching the filter */ + get matchCount(): number { + return this.filteredCount + } + + /** Bytes omitted by filtering */ + get omittedBytes(): number { + return this.totalBytes - this.filteredBytes + } + + /** Whether a filter is active */ + get hasFilter(): boolean { + return this.filter !== undefined + } + + /** Close the stream file if open. Call this after command completes. */ + close(): void { + // Process any remaining content in line buffer + if (this.filter && this.lineBuffer) { + if (this.filter.test(this.lineBuffer)) { + const entry = this.lineBuffer + "\n" + this.filtered += entry + this.filteredBytes += Buffer.byteLength(entry, "utf-8") + this.filteredCount++ + } + } + if (this.streamFile) { + fsSync.closeSync(this.streamFile.fd) + } + } + + /** Append metadata to output (either in memory or to file) */ + appendMetadata(text: string): void { + if (this.streamFile) { + fsSync.appendFileSync(this.streamFile.path, text) + } else { + this.output += text + } + } + + /** Get final output string (for non-truncated) or hint message (for truncated) */ + finalize(filterPattern?: string): string { + if (this.filter) { + if (this.streamFile) { + return `Filtered ${this.matchCount} matching lines from ${this.totalBytes} bytes of output.\nFull output saved to: ${this.streamFile.path}\nUse Grep to search or Read with offset/limit to view specific sections.\nNote: This file will be deleted after a few more commands. Copy it if you need to preserve it.` + } + return this.filtered || `[no matches for filter: ${filterPattern}]` + } + if (this.streamFile) { + return `The command output was ${this.streamedBytes} bytes and was truncated (inline limit: ${this.threshold} bytes).\nFull output saved to: ${this.streamFile.path}\nUse Grep to search the full content or Read with offset/limit to view specific sections.\nNote: This file will be deleted after a few more commands. Copy it if you need to preserve it.` + } + return this.output + } + + private createStreamFile(): { fd: number; path: string } | undefined { + let fd: number = -1 + try { + const dir = Truncate.DIR + fsSync.mkdirSync(dir, { recursive: true }) + Truncate.cleanup().catch(() => {}) + const filepath = path.join(dir, Identifier.ascending("tool")) + fd = fsSync.openSync(filepath, "w") + // Write existing buffered output to file + if (this.output) { + fsSync.writeSync(fd, this.output) + this.streamedBytes += Buffer.byteLength(this.output, "utf-8") + } + this.output = "" // Clear memory buffer + return { fd, path: filepath } + } catch (e) { + if (fd >= 0) fsSync.closeSync(fd) + log.warn("failed to create stream file, continuing in memory", { error: e }) + return undefined + } + } +} export namespace Truncate { export const MAX_LINES = 2000 diff --git a/packages/opencode/src/util/format.ts b/packages/opencode/src/util/format.ts index 4ae62eac450..c105fcdb738 100644 --- a/packages/opencode/src/util/format.ts +++ b/packages/opencode/src/util/format.ts @@ -1,3 +1,10 @@ +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + export function formatDuration(secs: number) { if (secs <= 0) return "" if (secs < 60) return `${secs}s` diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 750ff8193e9..1fd6c774542 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -268,7 +268,8 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + // Streaming truncation uses different message format + expect(result.output).toContain("Full output saved to:") }, }) }) @@ -317,4 +318,199 @@ describe("tool.bash truncation", () => { }, }) }) + + test("streams to file during execution for very large output", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + // Generate 100KB of data - well over the 50KB threshold + const byteCount = Truncate.MAX_BYTES * 2 + const result = await bash.execute( + { + command: `head -c ${byteCount} /dev/zero | tr '\\0' 'x'`, + description: "Generate large streaming output", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(true) + const filepath = (result.metadata as any).outputPath + expect(filepath).toBeTruthy() + + // Verify the full output was saved + const saved = await Bun.file(filepath).text() + expect(saved.length).toBe(byteCount) + expect(saved[0]).toBe("x") + expect(saved[byteCount - 1]).toBe("x") + }, + }) + }) + + test("preserves exit code when streaming to file", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const byteCount = Truncate.MAX_BYTES * 2 + const result = await bash.execute( + { + command: `head -c ${byteCount} /dev/zero | tr '\\0' 'x'; exit 42`, + description: "Generate large output with non-zero exit", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(true) + expect((result.metadata as any).exit).toBe(42) + }, + }) + }) + + test("streams stderr to file", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const byteCount = Truncate.MAX_BYTES * 2 + const result = await bash.execute( + { + command: `head -c ${byteCount} /dev/zero | tr '\\0' 'e' >&2`, + description: "Generate large stderr output", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(true) + const filepath = (result.metadata as any).outputPath + expect(filepath).toBeTruthy() + + const saved = await Bun.file(filepath).text() + expect(saved.length).toBe(byteCount) + expect(saved[0]).toBe("e") + }, + }) + }) + + test("output message contains file path when streaming", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const byteCount = Truncate.MAX_BYTES * 2 + const result = await bash.execute( + { + command: `head -c ${byteCount} /dev/zero | tr '\\0' 'x'`, + description: "Check output message format", + }, + ctx, + ) + const filepath = (result.metadata as any).outputPath + expect(result.output).toContain("Full output saved to:") + expect(result.output).toContain(filepath) + }, + }) + }) + + test("output_filter captures matching lines in memory for small output", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + // Small build output with warnings/errors - stays in memory + const result = await bash.execute( + { + command: `echo "compiling..."; echo "warning: unused variable"; echo "done"; echo "error: type mismatch"`, + output_filter: "^(warning|error):", + description: "Build with filter (small)", + }, + ctx, + ) + + // Should NOT be truncated (small output stays in memory) + expect((result.metadata as any).truncated).toBe(false) + expect((result.metadata as any).filtered).toBe(true) + expect((result.metadata as any).matchCount).toBe(2) + + // The output should contain only the filtered lines + expect(result.output).toContain("warning: unused variable") + expect(result.output).toContain("error: type mismatch") + expect(result.output).not.toContain("compiling...") + expect(result.output).not.toContain("done") + }, + }) + }) + + test("output_filter streams to file when output exceeds threshold", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + // Generate large output that exceeds threshold + const byteCount = Truncate.MAX_BYTES * 2 + const result = await bash.execute( + { + command: `head -c ${byteCount} /dev/zero | tr '\\0' 'x'; echo ""; echo "warning: this is a warning"`, + output_filter: "^warning:", + description: "Build with filter (large)", + }, + ctx, + ) + + // Should be truncated (large output spills to file) + expect((result.metadata as any).truncated).toBe(true) + expect((result.metadata as any).filtered).toBe(true) + const filepath = (result.metadata as any).outputPath + expect(filepath).toBeTruthy() + + // The inline output should contain only the filtered line + expect(result.output).toContain("warning: this is a warning") + expect(result.output).toContain("Filtered 1 matching line") + + // The full output file should contain everything + const saved = await Bun.file(filepath).text() + expect(saved.length).toBeGreaterThan(byteCount) + expect(saved).toContain("warning: this is a warning") + }, + }) + }) + + test("output_filter with no matches returns empty filtered output", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { + command: `echo "all good"; echo "no problems here"`, + output_filter: "^(warning|error):", + description: "Build with no matches", + }, + ctx, + ) + + // Small output stays in memory, no truncation + expect((result.metadata as any).truncated).toBe(false) + expect((result.metadata as any).filtered).toBe(true) + expect((result.metadata as any).matchCount).toBe(0) + expect(result.output).toContain("[no matches for filter:") + }, + }) + }) + + test("invalid output_filter regex throws error", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: `echo test`, + output_filter: "[invalid(regex", + description: "Invalid regex test", + }, + ctx, + ), + ).rejects.toThrow("Invalid output_filter regex") + }, + }) + }) })