fix(cli): require input source for --quiet and drop empty final-only JSON on failure

- `--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.
This commit is contained in:
Kaiyi 2026-06-29 16:44:50 +08:00
parent d61fc2d628
commit 3bea6f9fa2
5 changed files with 60 additions and 5 deletions

View file

@ -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.

View file

@ -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.',

View file

@ -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`);
}
}
}

View file

@ -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.');
});

View file

@ -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();