From 3bea6f9fa2f800f2081c7dece8fd17a406afbc05 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 29 Jun 2026 16:44:50 +0800 Subject: [PATCH] fix(cli): require input source for --quiet and drop empty final-only JSON on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `--quiet` with neither `--prompt` nor `--input-format` now fails validation ("Quiet mode requires --prompt or --input-format.") instead of entering print mode and crashing on an undefined prompt. - `PromptFinalJsonWriter.finish()` skips emitting when there is no final text, matching the text writer — a failed `--final-message-only` turn no longer prints a spurious `{"role":"assistant","content":""}` before its error line. Adds a changeset for the print-mode stream-json work. --- .changeset/print-mode-stream-json.md | 5 ++++ apps/kimi-code/src/cli/options.ts | 5 ++++ apps/kimi-code/src/cli/run-prompt.ts | 7 +++++- apps/kimi-code/test/cli/options.test.ts | 20 ++++++++++++---- apps/kimi-code/test/cli/run-prompt.test.ts | 28 ++++++++++++++++++++++ 5 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 .changeset/print-mode-stream-json.md diff --git a/.changeset/print-mode-stream-json.md b/.changeset/print-mode-stream-json.md new file mode 100644 index 000000000..717f12fb4 --- /dev/null +++ b/.changeset/print-mode-stream-json.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Expand `kimi -p` print mode to cover (and slightly exceed) kimi-cli's non-interactive `stream-json` capabilities: emit model thinking as its own JSONL line; add `--input-format text|stream-json` (multi-turn prompts over stdin), `--final-message-only` and the `--quiet` shorthand; surface background-task/cron events as `notification` lines; report turn failures as `{"type":"error",...}` JSON with retryable provider errors mapped to exit code 75; wait for background tasks before exit (gated by `background.keepAliveOnExit`); and emit the TUI activity layer (tool progress, subagent lifecycle, warnings, skill/MCP/compaction/goal/agent-status/tool-list updates) on stdout so the stream-json output stays both complete and entirely JSON. diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 89822ce45..fa62d7260 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -53,6 +53,11 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.quiet === true && opts.outputFormat !== undefined && opts.outputFormat !== 'text') { throw new OptionConflictError('Quiet mode implies --output-format text.'); } + // Prompt mode needs an input source. `--prompt` and `--input-format` provide + // one; `--quiet` on its own does not, so it must be paired with either. + if (promptMode && !hasPrompt && opts.inputFormat === undefined) { + throw new OptionConflictError('Quiet mode requires --prompt or --input-format.'); + } if (hasPrompt && opts.inputFormat !== undefined) { throw new OptionConflictError( 'Cannot combine --prompt with --input-format; the prompt is read from stdin.', diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index ce82b07c7..bfb3c0b90 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -950,7 +950,12 @@ class PromptFinalJsonWriter implements PromptTurnWriter { } finish(): void { - this.stdout.write(`${JSON.stringify({ role: 'assistant', content: this.assistantText })}\n`); + // Only emit when there is a final message: a failed turn settles through + // finish() before its error line, so an empty emit would read as a spurious + // successful empty answer. Mirrors PromptFinalTextWriter. + if (this.assistantText.length > 0) { + this.stdout.write(`${JSON.stringify({ role: 'assistant', content: this.assistantText })}\n`); + } } } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index c5ce73738..b792e3823 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -362,19 +362,31 @@ describe('CLI options parsing', () => { expect(parse([]).quiet).toBe(false); }); - it('enters print mode on its own', () => { - const opts = parse(['--quiet']); + it('enters print mode when paired with --prompt', () => { + const opts = parse(['--quiet', '-p', 'hi']); expect(opts.quiet).toBe(true); expect(validateOptions(opts).uiMode).toBe('print'); }); + it('enters print mode when paired with --input-format', () => { + const opts = parse(['--quiet', '--input-format', 'text']); + expect(opts.quiet).toBe(true); + expect(validateOptions(opts).uiMode).toBe('print'); + }); + + it('rejects --quiet on its own (no input source)', () => { + const opts = parse(['--quiet']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Quiet mode requires --prompt or --input-format.'); + }); + it('is allowed alongside an explicit --output-format text', () => { - const opts = parse(['--quiet', '--output-format', 'text']); + const opts = parse(['--quiet', '-p', 'hi', '--output-format', 'text']); expect(() => validateOptions(opts)).not.toThrow(); }); it('rejects --quiet with --output-format stream-json', () => { - const opts = parse(['--quiet', '--output-format', 'stream-json']); + const opts = parse(['--quiet', '-p', 'hi', '--output-format', 'stream-json']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); expect(() => validateOptions(opts)).toThrow('Quiet mode implies --output-format text.'); }); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 667784094..494742edc 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -811,6 +811,34 @@ describe('runPrompt', () => { expect(stderr.text()).toBe(''); }); + it('does not emit an empty final assistant message when a final-only turn fails', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 41, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 41, + reason: 'failed', + error: { code: 'provider.api_error', message: 'boom', retryable: false }, + }), + ); + } + }); + const stdout = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json', finalMessageOnly: true }), '1.2.3-test', { + stdout, + stderr: writer(), + }); + + // Only the error line — no spurious `{"role":"assistant","content":""}` first. + expect(stdout.text()).toBe( + '{"type":"error","code":"provider.api_error","message":"boom","retryable":false}\n', + ); + expect(process.exitCode).toBe(1); + }); + it('emits only the final text in text final-message-only mode and skips the resume hint (output B)', async () => { const stdout = writer(); const stderr = writer();