From ebcaa405d6d2b9b13296e1fdc1f2ee723d7a4772 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 29 Jun 2026 19:36:21 +0800 Subject: [PATCH] fix(cli): preserve custom tool-progress payloads and honor final-only for goal summaries - `tool_progress` JSON now carries `custom_kind`/`custom_data`, so stream-json clients keep structured `kind: "custom"` payloads (e.g. the MCP OAuth authorization URL) instead of an opaque `{"kind":"custom"}`. - The headless `/goal` summary is suppressed on stdout under `--final-message-only`, matching the single-final-assistant-message contract (the goal status is still conveyed by the exit code). --- apps/kimi-code/src/cli/run-prompt.ts | 11 +++++++- apps/kimi-code/test/cli/goal-prompt.test.ts | 21 ++++++++++++++ apps/kimi-code/test/cli/run-prompt.test.ts | 31 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index b587daff0..fcd9384f8 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -287,8 +287,13 @@ async function runHeadlessGoal( } finally { unsubscribeGoalEvents(); const snapshot = completedSnapshot ?? (await session.getGoal()).goal; + // The goal status is conveyed by the exit code; `--final-message-only` keeps + // stdout to the single final assistant message, so the JSON summary is + // suppressed in that mode (the text summary stays on stderr). if (outputFormat === 'stream-json') { - stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); + if (!finalOnly) { + stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); + } } else { stderr.write(`${formatGoalSummaryText(snapshot)}\n`); } @@ -1284,6 +1289,10 @@ function toolProgressMessage(event: Extract): }; if (event.update.text !== undefined) message['text'] = event.update.text; if (event.update.percent !== undefined) message['percent'] = event.update.percent; + // `kind: 'custom'` updates (e.g. the MCP OAuth authorization URL) carry their + // payload here; keep it so stream-json clients get the structured signal. + if (event.update.customKind !== undefined) message['custom_kind'] = event.update.customKind; + if (event.update.customData !== undefined) message['custom_data'] = event.update.customData; return message; } diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 4cbf33365..ffe72406b 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -189,6 +189,27 @@ describe('runPrompt headless goal mode', () => { expect(stdout.text()).toContain('"status":"complete"'); }); + it('suppresses the JSON goal summary in final-message-only mode', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'shipped' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json', finalMessageOnly: true }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('{"role":"assistant","content":"shipped"}\n'); + expect(stdout.text()).not.toContain('goal.summary'); + }); + it('sets a distinct exit code for a non-complete final status', async () => { mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'blocked' }) } as never); const stdout = writer(); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index ff002e084..0bdd6dafd 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1523,6 +1523,37 @@ describe('runPrompt', () => { expect(stderr.text()).toBe(''); }); + it('preserves custom tool.progress payloads (e.g. MCP OAuth URL) in stream-json', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 75, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'tool.progress', + turnId: 75, + toolCallId: 'tc_1', + update: { + kind: 'custom', + customKind: 'mcp.oauth.authorization_url', + customData: { serverName: 'fs', authorizationUrl: 'https://auth.example/x' }, + }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 75, reason: 'completed' })); + } + }); + const stdout = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + }); + + expect(stdout.text()).toContain( + '{"type":"tool_progress","tool_call_id":"tc_1","kind":"custom","custom_kind":"mcp.oauth.authorization_url","custom_data":{"serverName":"fs","authorizationUrl":"https://auth.example/x"}}', + ); + }); + it('keeps tool.progress on stderr (not JSON) in text mode', async () => { mocks.session.prompt.mockImplementationOnce(async () => { for (const handler of mocks.eventHandlers) {