From 1ca5fd38b4833ac3fe6213a55e5abfd426228266 Mon Sep 17 00:00:00 2001 From: Jack Liang <130693642+Jackmeson1@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:43:20 -0700 Subject: [PATCH] fix(cli)!: keep a selected JSON null in --filter-output output (#2333) createCliResultOutput rendered an explicit null selected by --filter-output as the empty document {}, the same output it uses when the selection has no value at all. On the streamed JSON/JSONL path, a consumer could not distinguish a selected null from a missing field. Write a selected null as null in streamed JSON and JSONL. This also preserves an unfiltered top-level null. The {} fallback stays for undefined, functions and symbols. The --full-output envelope already carried data: null for a selected null. The --token-count path still hands results that fit the formatted-string limit back to Incur and prints 0 for a selected null. Other formats and Incur-owned result paths are unchanged. Consumers must accept JSON null for a present null and distinguish it from a missing selection. Use --full-output if an object envelope is required. Keeping the old shared {} representation would continue hiding this distinction. Claude-Session: https://claude.ai/code/session_019TTGAv6fe859ebbCLLfLHP Co-authored-by: Claude Opus 5.5 --- src/cli/cliResultOutput.ts | 5 +++-- tests/boundary/cli/jsonOutput.test.ts | 32 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index 67e745b0b0..5efde8b336 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -216,8 +216,9 @@ export const createCliResultOutput = ( : filtered === undefined ? { ok: true, meta } : { ok: true, data: filtered, meta } - : filtered === undefined || - filtered === null || + : // A selected null is a value; only a selection with no JSON value + // falls back to the empty document. + filtered === undefined || typeof filtered === "function" || typeof filtered === "symbol" ? {} diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 5e8e86e7f8..0d1ee5a598 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -561,3 +561,35 @@ describe("CLI result output surface", () => { }); }, 60_000); }); + +describe("streamed CLI filtered selections", () => { + it.each([ + ["raw_result", "json", "null\n"], + ["raw_result", "jsonl", "null\n"], + ["nested.value", "json", '{"nested":{"value":null}}\n'], + ["missing", "json", "{}\n"], + ["count", "json", "0\n"], + ["enabled", "json", "false\n"], + ] as const)( + "keeps the selected value of --filter-output %s in %s", + async (filter, format, expected) => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput( + ["--format", format, "--filter-output", filter], + destination, + ); + if (output === undefined) throw new Error("Missing JSON output surface"); + await output.write( + { raw_result: null, nested: { value: null }, count: 0, enabled: false }, + { command: "fixture", duration: "1ms", format }, + ); + expect(Buffer.concat(chunks).toString("utf8")).toBe(expected); + }, + ); +});