From 60f392f13a46ac3e2c8c28e6ca72d529ca8c74b8 Mon Sep 17 00:00:00 2001 From: hayatosc Date: Sat, 14 Mar 2026 11:05:51 +0900 Subject: [PATCH] feat(output): add suppress-reads flag --- README.md | 9 ++ docs/CLI.md | 7 + skills/acpx/SKILL.md | 4 +- src/cli-core.ts | 28 +++- src/cli/flags.ts | 4 + src/output-json-formatter.ts | 187 ++++++++++++++++++++++++- src/output.ts | 23 +++- src/read-output-suppression.ts | 35 +++++ src/types.ts | 1 + test/cli.test.ts | 2 + test/integration.test.ts | 50 +++++++ test/mock-agent.ts | 46 +++++++ test/output.test.ts | 197 +++++++++++++++++++++++++++ test/read-output-suppression.test.ts | 23 ++++ 14 files changed, 602 insertions(+), 14 deletions(-) create mode 100644 src/read-output-suppression.ts create mode 100644 test/read-output-suppression.test.ts diff --git a/README.md b/README.md index 03c301a..03c23cc 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ acpx --format text codex 'summarize your findings' acpx --format json codex exec 'review changed files' acpx --format json --json-strict codex exec 'machine-safe JSON only' acpx --format quiet codex 'final recommendation only' +acpx --suppress-reads codex exec 'show tool activity without dumping file bodies' acpx --timeout 90 codex 'investigate intermittent test timeout' acpx --ttl 30 codex 'keep queue owner alive for quick follow-ups' @@ -257,8 +258,16 @@ acpx --format json --json-strict codex exec 'review this PR' # quiet: final assistant text only acpx --format quiet codex 'give me a 3-line summary' + +# suppress read payloads while keeping the selected output format +acpx --suppress-reads codex exec 'inspect the repo and report tool usage' ``` +- `text`: human-readable stream with assistant text and tool updates +- `json`: raw ACP NDJSON stream for automation +- `quiet`: final assistant text only +- `--suppress-reads`: replace raw read-file contents with `[read output suppressed]` in `text` and `json` output + JSON events include a stable envelope for correlation: ```json diff --git a/docs/CLI.md b/docs/CLI.md index ef2bce7..3871288 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -75,6 +75,7 @@ All global options: | `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. | | `--deny-all` | Deny all permissions | Permission mode `deny-all`. | | `--format ` | Output format | `text` (default), `json`, `quiet`. | +| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. | | `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. | | `--non-interactive-permissions ` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. | | `--timeout ` | Max wait time for agent response | Must be positive. Decimal seconds allowed. | @@ -424,6 +425,12 @@ When a prompt is already in flight for a session, `acpx` uses a per-session queu - `json`: one raw ACP JSON-RPC message per line - `quiet`: concatenated assistant text only +When `--suppress-reads` is enabled: + +- `text`: read-like tool outputs render as `[read output suppressed]` +- `json`: ACP `fs/read_text_file` responses and read-like tool-call outputs replace raw file contents with `[read output suppressed]` +- `quiet`: unchanged, because quiet mode only prints assistant text + ACP message examples: ```json diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index bed8a70..bb33121 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -29,7 +29,7 @@ Core capabilities: - Local agent process checks via `status` - Stable ACP client methods for filesystem and terminal requests - Stable ACP `authenticate` handshake via env/config credentials -- Structured streaming output (`text`, `json`, `quiet`) +- Structured streaming output (`text`, `json`, `quiet`) with optional `--suppress-reads` - Built-in agent registry plus raw `--agent` escape hatch ## Install @@ -192,6 +192,7 @@ Behavior: - `--approve-reads`: auto-approve reads/searches, prompt for writes (default mode) - `--deny-all`: deny all permission requests - `--format `: output format (`text`, `json`, `quiet`) +- `--suppress-reads`: suppress raw read-file contents while preserving the selected format - `--timeout `: max wait time (positive number) - `--ttl `: queue owner idle TTL before shutdown (default `300`, `0` disables TTL) - `--verbose`: verbose ACP/debug logs to stderr @@ -266,6 +267,7 @@ Use `--format `: - `text` (default): human-readable stream with updates/tool status and done line - `json`: NDJSON event stream (good for automation) - `quiet`: final assistant text only +- `--suppress-reads`: replace raw read-file contents with `[read output suppressed]` in `text` and `json` output Example automation: diff --git a/src/cli-core.ts b/src/cli-core.ts index 244cba4..ecd8325 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -177,6 +177,17 @@ function resolveCompatibleConfigId( export { parseAllowedTools, parseMaxTurns, parseTtlSeconds }; export { formatPromptSessionBannerLine } from "./cli/output-render.js"; +function resolveRequestedOutputPolicy(globalFlags: { + format: OutputFormat; + jsonStrict?: boolean; + suppressReads?: boolean; +}): OutputPolicy { + return { + ...resolveOutputPolicy(globalFlags.format, globalFlags.jsonStrict === true), + suppressReads: globalFlags.suppressReads === true, + }; +} + type SessionModule = typeof import("./session.js"); type OutputModule = typeof import("./output.js"); type OutputRenderModule = typeof import("./cli/output-render.js"); @@ -254,7 +265,7 @@ async function handlePrompt( config: ResolvedAcpxConfig, ): Promise { const globalFlags = resolveGlobalFlags(command, config); - const outputPolicy = resolveOutputPolicy(globalFlags.format, globalFlags.jsonStrict === true); + const outputPolicy = resolveRequestedOutputPolicy(globalFlags); const permissionMode = resolvePermissionMode(globalFlags, config.defaultPermissions); const prompt = await readPrompt(promptParts, flags.file, globalFlags.cwd); const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); @@ -273,6 +284,7 @@ async function handlePrompt( jsonContext: { sessionId: record.acpxRecordId, }, + suppressReads: outputPolicy.suppressReads, }); await printPromptSessionBanner(record, agent.cwd, outputPolicy.format, outputPolicy.jsonStrict); @@ -317,7 +329,7 @@ async function handleExec( ): Promise { if (config.disableExec) { const globalFlags = resolveGlobalFlags(command, config); - const outputPolicy = resolveOutputPolicy(globalFlags.format, globalFlags.jsonStrict === true); + const outputPolicy = resolveRequestedOutputPolicy(globalFlags); if (outputPolicy.format === "json") { process.stdout.write( `${JSON.stringify({ @@ -341,14 +353,16 @@ async function handleExec( } const globalFlags = resolveGlobalFlags(command, config); - const outputPolicy = resolveOutputPolicy(globalFlags.format, globalFlags.jsonStrict === true); + const outputPolicy = resolveRequestedOutputPolicy(globalFlags); const permissionMode = resolvePermissionMode(globalFlags, config.defaultPermissions); const prompt = await readPrompt(promptParts, flags.file, globalFlags.cwd); const [{ createOutputFormatter }, { runOnce }] = await Promise.all([ loadOutputModule(), loadSessionModule(), ]); - const outputFormatter = createOutputFormatter(outputPolicy.format); + const outputFormatter = createOutputFormatter(outputPolicy.format, { + suppressReads: outputPolicy.suppressReads, + }); const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); const result = await runOnce({ @@ -1471,6 +1485,7 @@ async function emitJsonErrorEvent(error: NormalizedOutputError): Promise { jsonContext: { sessionId: "unknown", }, + suppressReads: false, }); formatter.onError(error); formatter.flush(); @@ -1538,7 +1553,10 @@ export async function main(argv: string[] = process.argv): Promise { const config = await loadResolvedConfig(detectInitialCwd(argv.slice(2))); const requestedJsonStrict = detectJsonStrict(argv.slice(2)); const requestedOutputFormat = detectRequestedOutputFormat(argv.slice(2), config.format); - const requestedOutputPolicy = resolveOutputPolicy(requestedOutputFormat, requestedJsonStrict); + const requestedOutputPolicy = { + ...resolveOutputPolicy(requestedOutputFormat, requestedJsonStrict), + suppressReads: argv.slice(2).some((token) => token === "--suppress-reads"), + }; const builtInAgents = listBuiltInAgents(config.agents); const program = new Command(); diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 6c7771b..18e1263 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -30,6 +30,7 @@ export type GlobalFlags = PermissionFlags & { authPolicy?: AuthPolicy; nonInteractivePermissions: NonInteractivePermissionPolicy; jsonStrict?: boolean; + suppressReads?: boolean; timeout?: number; ttl: number; verbose?: boolean; @@ -193,6 +194,7 @@ export function addGlobalFlags(command: Command): Command { parseNonInteractivePermissionPolicy, ) .option("--format ", "Output format: text, json, quiet", parseOutputFormat) + .option("--suppress-reads", "Suppress raw read-file contents in output") .option("--model ", "Agent model id") .option( "--allowed-tools ", @@ -278,6 +280,7 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig) authPolicy: opts.authPolicy ?? config.authPolicy, nonInteractivePermissions: opts.nonInteractivePermissions ?? config.nonInteractivePermissions, jsonStrict, + suppressReads: opts.suppressReads === true, timeout: opts.timeout ?? config.timeoutMs, ttl: opts.ttl ?? config.ttlMs ?? DEFAULT_QUEUE_OWNER_TTL_MS, verbose, @@ -295,6 +298,7 @@ export function resolveOutputPolicy(format: OutputFormat, jsonStrict: boolean): return { format, jsonStrict, + suppressReads: false, suppressNonJsonStderr: jsonStrict, queueErrorAlreadyEmitted: format !== "quiet", suppressSdkConsoleErrors: jsonStrict, diff --git a/src/output-json-formatter.ts b/src/output-json-formatter.ts index d8e31f5..06d7432 100644 --- a/src/output-json-formatter.ts +++ b/src/output-json-formatter.ts @@ -1,4 +1,5 @@ import { buildJsonRpcErrorResponse } from "./jsonrpc-error.js"; +import { isReadLikeTool, SUPPRESSED_READ_OUTPUT } from "./read-output-suppression.js"; import type { OutputErrorAcpPayload, OutputErrorCode, @@ -11,14 +12,102 @@ type WritableLike = { write(chunk: string): void; }; +type JsonRpcRequestMessage = { + jsonrpc?: unknown; + id?: unknown; + method?: unknown; +}; + +type JsonRpcResponseMessage = { + jsonrpc?: unknown; + id?: unknown; + result?: unknown; +}; + const DEFAULT_JSON_SESSION_ID = "unknown"; +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function jsonRpcIdKey(value: unknown): string | undefined { + if (typeof value === "string") { + return `s:${value}`; + } + if (typeof value === "number" && Number.isFinite(value)) { + return `n:${value}`; + } + return undefined; +} + +function sanitizeReadResult(result: unknown): unknown { + const record = asRecord(result); + if (!record || typeof record.content !== "string") { + return result; + } + return { + ...record, + content: SUPPRESSED_READ_OUTPUT, + }; +} + +function sanitizeToolContent(content: unknown): unknown { + if (!Array.isArray(content)) { + return content; + } + + return [ + { + type: "content", + content: { + type: "text", + text: SUPPRESSED_READ_OUTPUT, + }, + }, + ]; +} + +function sanitizeToolMessage(message: unknown): unknown { + const root = asRecord(message); + const params = asRecord(root?.params); + const update = asRecord(params?.update); + if (!root || !params || !update) { + return message; + } + + return { + ...root, + params: { + ...params, + update: { + ...update, + rawOutput: + Object.prototype.hasOwnProperty.call(update, "rawOutput") && + update.rawOutput !== undefined + ? { content: SUPPRESSED_READ_OUTPUT } + : update.rawOutput, + content: + Object.prototype.hasOwnProperty.call(update, "content") && update.content !== undefined + ? sanitizeToolContent(update.content) + : update.content, + }, + }, + }; +} + class JsonOutputFormatter implements OutputFormatter { private readonly stdout: WritableLike; + private readonly suppressReads: boolean; private sessionId: string; + private readonly requestMethodById = new Map(); + private readonly toolStateById = new Map(); - constructor(stdout: WritableLike, context?: OutputFormatterContext) { + constructor(stdout: WritableLike, suppressReads: boolean, context?: OutputFormatterContext) { this.stdout = stdout; + this.suppressReads = suppressReads; this.sessionId = context?.sessionId?.trim() || DEFAULT_JSON_SESSION_ID; } @@ -27,7 +116,98 @@ class JsonOutputFormatter implements OutputFormatter { } onAcpMessage(message: unknown): void { - this.stdout.write(`${JSON.stringify(message)}\n`); + this.stdout.write(`${JSON.stringify(this.sanitizeMessage(message))}\n`); + } + + private sanitizeMessage(message: unknown): unknown { + if (!this.suppressReads) { + return message; + } + + const sanitizedResponse = this.sanitizeReadResponse(message); + if (sanitizedResponse !== message) { + return sanitizedResponse; + } + + const sanitizedToolMessage = this.sanitizeReadToolMessage(message); + if (sanitizedToolMessage !== message) { + return sanitizedToolMessage; + } + + this.trackRequestMethod(message); + return message; + } + + private trackRequestMethod(message: unknown): void { + const candidate = message as JsonRpcRequestMessage; + if (typeof candidate.method !== "string") { + return; + } + const idKey = jsonRpcIdKey(candidate.id); + if (!idKey) { + return; + } + this.requestMethodById.set(idKey, candidate.method); + } + + private sanitizeReadResponse(message: unknown): unknown { + const candidate = message as JsonRpcResponseMessage; + const idKey = jsonRpcIdKey(candidate.id); + if (!idKey || !Object.hasOwn(candidate, "result")) { + return message; + } + + const method = this.requestMethodById.get(idKey); + this.requestMethodById.delete(idKey); + if (method !== "fs/read_text_file") { + return message; + } + + const root = asRecord(message); + if (!root) { + return message; + } + + return { + ...root, + result: sanitizeReadResult(candidate.result), + }; + } + + private sanitizeReadToolMessage(message: unknown): unknown { + const root = asRecord(message); + if (root?.method !== "session/update") { + return message; + } + + const params = asRecord(root.params); + const update = asRecord(params?.update); + if (!params || !update) { + return message; + } + + const sessionUpdate = update.sessionUpdate; + if (sessionUpdate !== "tool_call" && sessionUpdate !== "tool_call_update") { + return message; + } + + const toolCallId = typeof update.toolCallId === "string" ? update.toolCallId : undefined; + if (!toolCallId) { + return message; + } + + const previous = this.toolStateById.get(toolCallId) ?? {}; + const current = { + title: typeof update.title === "string" ? update.title : previous.title, + kind: typeof update.kind === "string" || update.kind === null ? update.kind : previous.kind, + }; + this.toolStateById.set(toolCallId, current); + + if (!isReadLikeTool(current)) { + return message; + } + + return sanitizeToolMessage(message); } onError(params: { @@ -62,7 +242,8 @@ class JsonOutputFormatter implements OutputFormatter { export function createJsonOutputFormatter( stdout: WritableLike, + suppressReads = false, context?: OutputFormatterContext, ): OutputFormatter { - return new JsonOutputFormatter(stdout, context); + return new JsonOutputFormatter(stdout, suppressReads, context); } diff --git a/src/output.ts b/src/output.ts index b6d362c..6db8e50 100644 --- a/src/output.ts +++ b/src/output.ts @@ -10,6 +10,7 @@ import type { } from "@agentclientprotocol/sdk"; import { parseJsonRpcErrorMessage, parsePromptStopReason } from "./acp-jsonrpc.js"; import { createJsonOutputFormatter } from "./output-json-formatter.js"; +import { isReadLikeTool, SUPPRESSED_READ_OUTPUT } from "./read-output-suppression.js"; import type { AcpJsonRpcMessage, ClientOperation, @@ -29,6 +30,7 @@ type WritableLike = { type OutputFormatterOptions = { stdout?: WritableLike; jsonContext?: OutputFormatterContext; + suppressReads?: boolean; }; type NormalizedToolStatus = ToolCallStatus | "unknown"; @@ -465,6 +467,14 @@ function summarizeToolOutput( return fragments.join("\n\n"); } +function renderToolOutput(state: ToolRenderState, suppressReads: boolean): string | undefined { + if (suppressReads && isReadLikeTool(state)) { + return SUPPRESSED_READ_OUTPUT; + } + + return summarizeToolOutput(state.rawOutput, state.content); +} + function limitOutputBlock(value: string): string { const normalized = value.replace(/\r\n/g, "\n").trim(); if (!normalized) { @@ -490,15 +500,17 @@ function limitOutputBlock(value: string): string { class TextOutputFormatter implements OutputFormatter { private readonly stdout: WritableLike; private readonly useColor: boolean; + private readonly suppressReads: boolean; private readonly toolStates = new Map(); private thoughtBuffer = ""; private wroteAny = false; private atLineStart = true; private section: FormatterSection | null = null; - constructor(stdout: WritableLike) { + constructor(stdout: WritableLike, suppressReads: boolean) { this.stdout = stdout; this.useColor = Boolean(stdout.isTTY); + this.suppressReads = suppressReads; } setContext(_context: OutputFormatterContext): void { @@ -734,7 +746,7 @@ class TextOutputFormatter implements OutputFormatter { kind: state.kind, input: summarizeToolInput(state.rawInput), files: formatLocations(state.locations), - output: summarizeToolOutput(state.rawOutput, state.content), + output: renderToolOutput(state, this.suppressReads), }; return safeJson(signaturePayload, 0) ?? JSON.stringify(signaturePayload); @@ -783,7 +795,7 @@ class TextOutputFormatter implements OutputFormatter { this.writeLine(` files: ${files}`); } - const output = summarizeToolOutput(state.rawOutput, state.content); + const output = renderToolOutput(state, this.suppressReads); if (output) { this.writeLine(" output:"); this.writeLine(indentBlock(limitOutputBlock(output), " ")); @@ -884,12 +896,13 @@ export function createOutputFormatter( options: OutputFormatterOptions = {}, ): OutputFormatter { const stdout = options.stdout ?? process.stdout; + const suppressReads = options.suppressReads === true; switch (format) { case "text": - return new TextOutputFormatter(stdout); + return new TextOutputFormatter(stdout, suppressReads); case "json": - return createJsonOutputFormatter(stdout, options.jsonContext); + return createJsonOutputFormatter(stdout, suppressReads, options.jsonContext); case "quiet": return new QuietOutputFormatter(stdout); default: { diff --git a/src/read-output-suppression.ts b/src/read-output-suppression.ts new file mode 100644 index 0000000..4b54bf4 --- /dev/null +++ b/src/read-output-suppression.ts @@ -0,0 +1,35 @@ +export const SUPPRESSED_READ_OUTPUT = "[read output suppressed]"; + +export type ReadLikeToolDescriptor = { + title?: string; + kind?: string | null; +}; + +function inferToolKindFromTitle(title: string | undefined): string | undefined { + const normalized = title?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + const head = normalized.split(":", 1)[0]?.trim(); + if (!head) { + return undefined; + } + + if ( + head.includes("read") || + head.includes("cat") || + head.includes("open") || + head.includes("view") + ) { + return "read"; + } + + return undefined; +} + +export function isReadLikeTool(tool: ReadLikeToolDescriptor): boolean { + return ( + tool.kind?.trim().toLowerCase() === "read" || inferToolKindFromTitle(tool.title) === "read" + ); +} diff --git a/src/types.ts b/src/types.ts index 415a18d..82bd622 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,7 @@ export type OutputFormatterContext = { export type OutputPolicy = { format: OutputFormat; jsonStrict: boolean; + suppressReads: boolean; suppressNonJsonStderr: boolean; queueErrorAlreadyEmitted: boolean; suppressSdkConsoleErrors: boolean; diff --git a/test/cli.test.ts b/test/cli.test.ts index 5b0d721..e852692 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -214,6 +214,8 @@ test("global passthrough flags are present in help output", async () => { assert.match(result.stdout, /--model /); assert.match(result.stdout, /--allowed-tools /); assert.match(result.stdout, /--max-turns /); + assert.match(result.stdout, /text, json, quiet/); + assert.match(result.stdout, /--suppress-reads/); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index ad7c1b0..87c2dd6 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -926,6 +926,56 @@ test("integration: fs/read_text_file through mock agent", async () => { }); }); +test("integration: --suppress-reads hides read file body in text format", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const readPath = path.join(cwd, "acpx-test-read-tools.txt"); + await fs.writeFile(readPath, "mock read content", "utf8"); + + try { + const result = await runCli( + [...baseAgentArgs(cwd), "--suppress-reads", "exec", `read-tool ${readPath}`], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /\[tool\] Read/); + assert.match(result.stdout, /\[read output suppressed\]/); + assert.doesNotMatch(result.stdout, /mock read content/); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: --suppress-reads hides read file body in json format", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + const readPath = path.join(cwd, "acpx-test-read-json.txt"); + await fs.writeFile(readPath, "mock read content", "utf8"); + + try { + const result = await runCli( + [...baseAgentArgs(cwd), "--format", "json", "--suppress-reads", "exec", `read ${readPath}`], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + const payloads = parseJsonRpcOutputLines(result.stdout); + const readResponse = payloads.find((payload) => { + if (!("result" in payload)) { + return false; + } + return typeof (payload.result as { content?: unknown } | undefined)?.content === "string"; + }); + assert.equal( + (readResponse?.result as { content?: string } | undefined)?.content, + "[read output suppressed]", + ); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: fs/write_text_file through mock agent", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); diff --git a/test/mock-agent.ts b/test/mock-agent.ts index 2f10598..24eca13 100644 --- a/test/mock-agent.ts +++ b/test/mock-agent.ts @@ -654,6 +654,52 @@ class MockAgent implements Agent { return readResult.content; } + if (text.startsWith("read-tool ")) { + const filePath = text.slice("read-tool ".length).trim(); + if (!filePath) { + throw new Error("Usage: read-tool "); + } + + const toolCallId = randomUUID(); + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Read", + kind: "read", + status: "in_progress", + rawInput: { + filePath, + }, + }, + }); + + const readResult = await this.connection.readTextFile({ + sessionId, + path: filePath, + }); + + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + title: "Read", + kind: "read", + status: "completed", + rawInput: { + filePath, + }, + rawOutput: { + content: readResult.content, + }, + }, + }); + + return `read complete: ${filePath}`; + } + if (text.startsWith("write ")) { const rest = text.slice("write ".length).trim(); const firstSpace = rest.search(/\s/); diff --git a/test/output.test.ts b/test/output.test.ts index e108cf8..4a08dfa 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -171,6 +171,203 @@ test("json formatter emits ACP JSON-RPC error response from onError", () => { assert.equal(parsed.error?.data?.sessionId, "session-err"); }); +test("text formatter suppresses read output when requested", () => { + const writer = new CaptureWriter(); + const formatter = createOutputFormatter("text", { stdout: writer, suppressReads: true }); + + formatter.onAcpMessage(messageChunk("assistant text still visible") as never); + formatter.onAcpMessage(thoughtChunk("thought still visible") as never); + formatter.onAcpMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-read-1", + title: "Read", + status: "in_progress", + rawInput: { filePath: "/tmp/demo.txt" }, + }, + }, + } as never); + formatter.onAcpMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-read-1", + title: "Read", + kind: "read", + status: "completed", + rawInput: { filePath: "/tmp/demo.txt" }, + rawOutput: { content: "secret file body" }, + }, + }, + } as never); + formatter.onAcpMessage(doneResult("end_turn") as never); + + const output = writer.toString(); + assert.match(output, /assistant text still visible/); + assert.match(output, /\[thinking\] thought still visible/); + assert.match(output, /\[tool\] Read/); + assert.match(output, /\/tmp\/demo.txt/); + assert.match(output, /\[read output suppressed\]/); + assert.doesNotMatch(output, /secret file body/); +}); + +test("json formatter suppresses read output when requested", () => { + const writer = new CaptureWriter(); + const formatter = createOutputFormatter("json", { + stdout: writer, + suppressReads: true, + jsonContext: { + sessionId: "session-json", + }, + }); + + formatter.onAcpMessage({ + jsonrpc: "2.0", + id: "req-read-1", + method: "fs/read_text_file", + params: { + sessionId: "session-json", + path: "/tmp/demo.txt", + }, + } as never); + formatter.onAcpMessage({ + jsonrpc: "2.0", + id: "req-read-1", + result: { + content: "secret file body", + }, + } as never); + + const lines = writer + .toString() + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); + + assert.equal(lines.length, 2); + assert.equal( + (lines[1]?.result as { content?: string } | undefined)?.content, + "[read output suppressed]", + ); +}); + +test("json formatter suppresses read-like tool updates inferred from title", () => { + const writer = new CaptureWriter(); + const formatter = createOutputFormatter("json", { + stdout: writer, + suppressReads: true, + jsonContext: { + sessionId: "session-json", + }, + }); + + formatter.onAcpMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-json", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-read-2", + title: "Open file", + status: "completed", + rawOutput: { content: "secret file body" }, + content: [ + { + type: "content", + content: { + type: "text", + text: "secret file body", + }, + }, + ], + }, + }, + } as never); + + const line = JSON.parse(writer.toString().trim()) as { + params?: { + update?: { + rawOutput?: { content?: string }; + content?: Array<{ content?: { text?: string } }>; + }; + }; + }; + + assert.equal(line.params?.update?.rawOutput?.content, "[read output suppressed]"); + assert.equal(line.params?.update?.content?.[0]?.content?.text, "[read output suppressed]"); +}); + +test("json formatter leaves non-read tool updates unchanged with suppression enabled", () => { + const writer = new CaptureWriter(); + const formatter = createOutputFormatter("json", { + stdout: writer, + suppressReads: true, + jsonContext: { + sessionId: "session-json", + }, + }); + + formatter.onAcpMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-json", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-write-1", + title: "Write", + kind: "edit", + status: "completed", + rawOutput: { content: "wrote file" }, + }, + }, + } as never); + + const line = JSON.parse(writer.toString().trim()) as { + params?: { + update?: { + rawOutput?: { content?: string }; + }; + }; + }; + + assert.equal(line.params?.update?.rawOutput?.content, "wrote file"); +}); + +test("quiet formatter ignores suppress-reads and still outputs assistant text only", () => { + const writer = new CaptureWriter(); + const formatter = createOutputFormatter("quiet", { stdout: writer, suppressReads: true }); + + formatter.onAcpMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-read-1", + title: "Read", + kind: "read", + status: "completed", + rawOutput: { content: "secret file body" }, + }, + }, + } as never); + formatter.onAcpMessage(messageChunk("Hello world") as never); + formatter.onAcpMessage(doneResult("end_turn") as never); + + assert.equal(writer.toString(), "Hello world\n"); +}); + test("quiet formatter outputs only agent text and flushes on prompt result", () => { const writer = new CaptureWriter(); const formatter = createOutputFormatter("quiet", { stdout: writer }); diff --git a/test/read-output-suppression.test.ts b/test/read-output-suppression.test.ts new file mode 100644 index 0000000..6e1df25 --- /dev/null +++ b/test/read-output-suppression.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isReadLikeTool, SUPPRESSED_READ_OUTPUT } from "../src/read-output-suppression.js"; + +test("SUPPRESSED_READ_OUTPUT is stable", () => { + assert.equal(SUPPRESSED_READ_OUTPUT, "[read output suppressed]"); +}); + +test("isReadLikeTool matches explicit read kind", () => { + assert.equal(isReadLikeTool({ kind: "read", title: "Anything" }), true); +}); + +test("isReadLikeTool infers read-like titles", () => { + assert.equal(isReadLikeTool({ title: "Read: /tmp/file.txt" }), true); + assert.equal(isReadLikeTool({ title: "Open file" }), true); + assert.equal(isReadLikeTool({ title: "View buffer" }), true); +}); + +test("isReadLikeTool rejects unrelated titles and blanks", () => { + assert.equal(isReadLikeTool({ title: "Write file" }), false); + assert.equal(isReadLikeTool({ title: " " }), false); + assert.equal(isReadLikeTool({}), false); +});