mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(cli): suppress tool-progress stderr under final-only, add leak guard
- text-mode `tool.progress` output is suppressed when `--final-message-only` / `--quiet` is set, matching the minimal-output contract (it bypassed the final-only writer and wrote every chunk to stderr). - add kitchen-sink final-only invariant tests (stream-json and quiet) that feed every in-turn event kind and assert stdout is exactly the final message with a silent stderr — one guard that catches any direct-write leak at once.
This commit is contained in:
parent
fef49beb9d
commit
ff95ddd82d
2 changed files with 59 additions and 2 deletions
|
|
@ -575,10 +575,15 @@ function runPromptTurn(
|
|||
return;
|
||||
case 'tool.progress':
|
||||
// stream-json: a JSON line on stdout (keeping the live tool output in
|
||||
// the JSON stream); text: the raw chunk on stderr as before.
|
||||
// the JSON stream); text: the raw chunk on stderr — suppressed under
|
||||
// `--final-message-only`/`--quiet`, which promise minimal output.
|
||||
if (outputFormat === 'stream-json') {
|
||||
outputWriter.writeActivity(toolProgressMessage(event));
|
||||
} else if (event.update.text !== undefined && event.update.text.length > 0) {
|
||||
} else if (
|
||||
!finalOnly &&
|
||||
event.update.text !== undefined &&
|
||||
event.update.text.length > 0
|
||||
) {
|
||||
stderr.write(
|
||||
event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -839,6 +839,58 @@ describe('runPrompt', () => {
|
|||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Final-only is a subtractive contract enforced by the writer, but several
|
||||
// paths write to stdout/stderr directly (resume hint, goal summary, drain,
|
||||
// tool progress). These kitchen-sink tests feed every in-turn event kind and
|
||||
// assert the output is EXACTLY the final message — one guard that catches any
|
||||
// such leak at once, rather than discovering them one path at a time.
|
||||
const emitKitchenSink = (turnId: number) => {
|
||||
for (const handler of mocks.eventHandlers) {
|
||||
const e = (event: Record<string, unknown>) => handler(mocks.mainEvent(event));
|
||||
e({ type: 'turn.started', turnId, origin: { kind: 'user' } });
|
||||
e({ type: 'thinking.delta', turnId, delta: 'secret reasoning' });
|
||||
e({ type: 'assistant.delta', turnId, delta: 'first step' });
|
||||
e({ type: 'tool.call.started', turnId, toolCallId: 'tc_1', name: 'Bash', args: { command: 'ls' } });
|
||||
e({ type: 'tool.progress', turnId, toolCallId: 'tc_1', update: { kind: 'stdout', text: 'live output' } });
|
||||
e({ type: 'tool.result', turnId, toolCallId: 'tc_1', output: 'res' });
|
||||
e({ type: 'hook.result', turnId, hookEvent: 'UserPromptSubmit', content: '{}' });
|
||||
e({
|
||||
type: 'background.task.terminated',
|
||||
info: { taskId: 'b1', kind: 'process', status: 'completed', description: 'build', startedAt: 0, endedAt: 1 },
|
||||
});
|
||||
e({ type: 'subagent.spawned', subagentId: 'a1', subagentName: 'researcher', runInBackground: false });
|
||||
e({ type: 'warning', message: 'heads up' });
|
||||
e({ type: 'turn.step.started', turnId });
|
||||
e({ type: 'assistant.delta', turnId, delta: 'the final answer' });
|
||||
e({ type: 'turn.ended', turnId, reason: 'completed' });
|
||||
}
|
||||
};
|
||||
|
||||
it('final-only stream-json emits exactly the final message for a kitchen-sink turn', async () => {
|
||||
mocks.session.prompt.mockImplementationOnce(async () => emitKitchenSink(90));
|
||||
const stdout = writer();
|
||||
const stderr = writer();
|
||||
|
||||
await runPrompt(opts({ outputFormat: 'stream-json', finalMessageOnly: true }), '1.2.3-test', {
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
|
||||
expect(stdout.text()).toBe('{"role":"assistant","content":"the final answer"}\n');
|
||||
expect(stderr.text()).toBe('');
|
||||
});
|
||||
|
||||
it('final-only/quiet text emits exactly the final text with a silent stderr', async () => {
|
||||
mocks.session.prompt.mockImplementationOnce(async () => emitKitchenSink(91));
|
||||
const stdout = writer();
|
||||
const stderr = writer();
|
||||
|
||||
await runPrompt(opts({ quiet: true }), '1.2.3-test', { stdout, stderr });
|
||||
|
||||
expect(stdout.text()).toBe('the final answer\n');
|
||||
expect(stderr.text()).toBe('');
|
||||
});
|
||||
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue