From d61fc2d6288fa57b14aa19f4ab854a474e646aa9 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 29 Jun 2026 16:24:00 +0800 Subject: [PATCH] feat(cli): emit TUI-level activity events in print stream-json Surface the activity layer the TUI renders on stdout as JSON so the stream-json output is both complete and entirely JSON: - live tool output as `{"type":"tool_progress",...}` (moved off stderr in stream-json mode; text mode keeps the stderr chunk) - subagent lifecycle as `{"type":"subagent","event":...}` - `{"type":"warning"|"skill_activated"|"mcp_server_status"|"compaction"| "goal_update"|"agent_status"|"tool_list_updated",...}` Activity events flush the pending assistant message first to preserve ordering, and are emitted only in stream-json mode. Adds prompt-runner tests and updates the kimi-command reference docs. --- apps/kimi-code/src/cli/run-prompt.ts | 145 +++++++++++++++++- apps/kimi-code/test/cli/run-prompt.test.ts | 166 +++++++++++++++++++++ docs/en/reference/kimi-command.md | 2 +- docs/zh/reference/kimi-command.md | 2 +- 4 files changed, 312 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index b3f8c14d5..ce82b07c7 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -508,6 +508,15 @@ function runPromptTurn( outputWriter.writeNotification(notification); return; } + // TUI-level activity events (subagent lifecycle, warnings, skill/mcp/ + // compaction/goal/agent-status updates) are likewise not turn-scoped; emit + // them as their own JSON lines so a stream-json consumer sees what the TUI + // would render. (`tool.progress` is turn-scoped and handled below.) + const activity = toActivityMessage(event); + if (activity !== undefined) { + outputWriter.writeActivity(activity); + return; + } if (event.type === 'error') { if (event.agentId !== PROMPT_MAIN_AGENT_ID) { return; @@ -559,7 +568,11 @@ function runPromptTurn( outputWriter.writeToolResult(event.toolCallId, event.output); return; case 'tool.progress': - if (event.update.text !== undefined && event.update.text.length > 0) { + // stream-json: a JSON line on stdout (keeping the live tool output in + // the JSON stream); text: the raw chunk on stderr as before. + if (outputFormat === 'stream-json') { + outputWriter.writeActivity(toolProgressMessage(event)); + } else if (event.update.text !== undefined && event.update.text.length > 0) { stderr.write( event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`, ); @@ -615,6 +628,8 @@ interface PromptTurnWriter { ): void; writeToolResult(toolCallId: string, output: unknown): void; writeNotification(message: PromptJsonNotificationMessage): void; + /** Emits a TUI-level activity event (tool progress, subagent, warning, …) as its own line. */ + writeActivity(message: Record): void; flushAssistant(): void; discardAssistant(): void; finish(): void; @@ -667,6 +682,8 @@ class PromptTranscriptWriter implements PromptTurnWriter { writeNotification(): void {} + writeActivity(): void {} + flushAssistant(): void {} discardAssistant(): void {} @@ -787,6 +804,13 @@ class PromptJsonWriter implements PromptTurnWriter { this.writeJsonLine(message); } + writeActivity(message: Record): void { + // Same ordering guarantee as notifications: declare the activity after the + // assistant content it follows. + this.flushAssistant(); + this.stdout.write(`${JSON.stringify(message)}\n`); + } + writeToolCall(toolCallId: string, name: string, args: unknown): void { const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); if (existing !== undefined) { @@ -914,6 +938,8 @@ class PromptFinalJsonWriter implements PromptTurnWriter { writeNotification(): void {} + writeActivity(): void {} + flushAssistant(): void { // A new step supersedes the previous one; keep only the latest step's text. this.assistantText = ''; @@ -952,6 +978,8 @@ class PromptFinalTextWriter implements PromptTurnWriter { writeNotification(): void {} + writeActivity(): void {} + flushAssistant(): void { this.assistantText = ''; } @@ -1238,6 +1266,121 @@ function toNotificationMessage(event: Event): PromptJsonNotificationMessage | un } } +/** Builds the JSON line for a turn-scoped `tool.progress` event (stream-json). */ +function toolProgressMessage(event: Extract): Record { + const message: Record = { + type: 'tool_progress', + tool_call_id: event.toolCallId, + kind: event.update.kind, + }; + if (event.update.text !== undefined) message['text'] = event.update.text; + if (event.update.percent !== undefined) message['percent'] = event.update.percent; + return message; +} + +/** + * Maps a TUI-level activity event (subagent lifecycle, warnings, skill / MCP / + * compaction / goal / agent-status / tool-list updates) to a JSON line, or + * `undefined` for events that are not activity events. `tool.progress` is + * turn-scoped and handled separately. + */ +function toActivityMessage(event: Event): Record | undefined { + switch (event.type) { + case 'subagent.spawned': { + const message: Record = { + type: 'subagent', + event: event.type, + subagent_id: event.subagentId, + name: event.subagentName, + run_in_background: event.runInBackground, + }; + if (event.description !== undefined) message['description'] = event.description; + return message; + } + case 'subagent.started': + return { type: 'subagent', event: event.type, subagent_id: event.subagentId }; + case 'subagent.suspended': + return { + type: 'subagent', + event: event.type, + subagent_id: event.subagentId, + reason: event.reason, + }; + case 'subagent.completed': + return { + type: 'subagent', + event: event.type, + subagent_id: event.subagentId, + result_summary: event.resultSummary, + }; + case 'subagent.failed': + return { + type: 'subagent', + event: event.type, + subagent_id: event.subagentId, + error: event.error, + }; + case 'warning': { + const message: Record = { type: 'warning', message: event.message }; + if (event.code !== undefined) message['code'] = event.code; + return message; + } + case 'skill.activated': { + const message: Record = { + type: 'skill_activated', + skill_name: event.skillName, + trigger: event.trigger, + }; + if (event.skillArgs !== undefined) message['skill_args'] = event.skillArgs; + return message; + } + case 'mcp.server.status': { + const message: Record = { + type: 'mcp_server_status', + name: event.server.name, + status: event.server.status, + transport: event.server.transport, + tool_count: event.server.toolCount, + }; + if (event.server.error !== undefined) message['error'] = event.server.error; + return message; + } + case 'compaction.started': + return { type: 'compaction', event: event.type, trigger: event.trigger }; + case 'compaction.blocked': + case 'compaction.cancelled': + return { type: 'compaction', event: event.type }; + case 'compaction.completed': + return { + type: 'compaction', + event: event.type, + tokens_before: event.result.tokensBefore, + tokens_after: event.result.tokensAfter, + compacted_count: event.result.compactedCount, + }; + case 'goal.updated': { + const message: Record = { type: 'goal_update' }; + if (event.snapshot !== null) { + message['goal_id'] = event.snapshot.goalId; + message['status'] = event.snapshot.status; + } + if (event.change !== undefined) message['change'] = event.change.kind; + return message; + } + case 'agent.status.updated': { + const message: Record = { type: 'agent_status' }; + if (event.model !== undefined) message['model'] = event.model; + if (event.contextTokens !== undefined) message['context_tokens'] = event.contextTokens; + if (event.contextUsage !== undefined) message['context_usage'] = event.contextUsage; + return message; + } + case 'tool.list.updated': + return { type: 'tool_list_updated', reason: event.reason, server_name: event.serverName }; + default: + return undefined; + } +} + /** Default `--input-format` line source: a reader over the given stdin stream. */ function defaultStdinLines(input: NodeJS.ReadableStream): AsyncIterable { return createInterface({ input, crlfDelay: Infinity }); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index cec907ca7..667784094 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1410,6 +1410,172 @@ describe('runPrompt', () => { expect(stderr.text()).toContain('background task'); }); + it('emits tool.progress as a JSON line on stdout in stream-json mode (activity)', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 70, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'tool.call.started', + turnId: 70, + toolCallId: 'tc_1', + name: 'Bash', + args: { command: 'build' }, + }), + ); + handler( + mocks.mainEvent({ + type: 'tool.progress', + turnId: 70, + toolCallId: 'tc_1', + update: { kind: 'stdout', text: 'compiling...' }, + }), + ); + handler( + mocks.mainEvent({ type: 'tool.result', turnId: 70, toolCallId: 'tc_1', output: 'done' }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 70, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toContain( + '{"type":"tool_progress","tool_call_id":"tc_1","kind":"stdout","text":"compiling..."}', + ); + expect(stderr.text()).toBe(''); + }); + + it('keeps tool.progress on stderr (not JSON) in text mode', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 70, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'tool.progress', + turnId: 70, + toolCallId: 'tc_1', + update: { kind: 'stdout', text: 'building' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 70, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stderr.text()).toContain('building'); + expect(stdout.text()).not.toContain('tool_progress'); + }); + + it('emits subagent lifecycle events as JSON lines in stream-json mode (activity)', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 71, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'subagent.spawned', + subagentId: 'a1', + subagentName: 'researcher', + parentToolCallId: 'tc_1', + runInBackground: false, + }), + ); + handler( + mocks.mainEvent({ + type: 'subagent.completed', + subagentId: 'a1', + resultSummary: 'found it', + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 71, reason: 'completed' })); + } + }); + const stdout = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + }); + + expect(stdout.text()).toContain( + '{"type":"subagent","event":"subagent.spawned","subagent_id":"a1","name":"researcher","run_in_background":false}', + ); + expect(stdout.text()).toContain( + '{"type":"subagent","event":"subagent.completed","subagent_id":"a1","result_summary":"found it"}', + ); + }); + + it('emits warning, skill and tool-list activity events as JSON lines', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 72, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'warning', message: 'low disk', code: 'disk.low' })); + handler( + mocks.mainEvent({ + type: 'skill.activated', + activationId: 's1', + skillName: 'deploy', + trigger: 'model-tool', + }), + ); + handler( + mocks.mainEvent({ + type: 'tool.list.updated', + reason: 'mcp.connected', + serverName: 'fs', + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 72, reason: 'completed' })); + } + }); + const stdout = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr: writer(), + }); + + expect(stdout.text()).toContain('{"type":"warning","message":"low disk","code":"disk.low"}'); + expect(stdout.text()).toContain( + '{"type":"skill_activated","skill_name":"deploy","trigger":"model-tool"}', + ); + expect(stdout.text()).toContain( + '{"type":"tool_list_updated","reason":"mcp.connected","server_name":"fs"}', + ); + }); + + it('does not emit activity events in text mode', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 73, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'subagent.spawned', + subagentId: 'a1', + subagentName: 'researcher', + parentToolCallId: 'tc_1', + runInBackground: false, + }), + ); + handler(mocks.mainEvent({ type: 'warning', message: 'low disk' })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 73, delta: 'ok' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 73, reason: 'completed' })); + } + }); + const stdout = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr: writer() }); + + expect(stdout.text()).toBe('• ok\n\n'); + expect(stdout.text()).not.toContain('subagent'); + expect(stdout.text()).not.toContain('warning'); + }); + it('approval fallback approves if an unexpected approval request reaches SDK', async () => { await runPrompt(opts(), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 510ebbc65..b837072fe 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -121,7 +121,7 @@ When you need to parse output programmatically, use the `stream-json` format — kimi -p "List changed files" --output-format stream-json ``` -In `stream-json` mode, regular replies produce an Assistant message; when the model calls a tool, an Assistant message with `tool_calls` is emitted first, followed by the corresponding Tool message, then subsequent Assistant messages. Thinking content is written as its own line — an Assistant message tagged with `"type":"thinking"` — emitted before the answer it produced. Background-task and cron notifications are emitted as `{"type":"notification", ...}` lines. Tool progress and "resuming session" notices are still written to stderr. +In `stream-json` mode, regular replies produce an Assistant message; when the model calls a tool, an Assistant message with `tool_calls` is emitted first, followed by the corresponding Tool message, then subsequent Assistant messages. Thinking content is written as its own line — an Assistant message tagged with `"type":"thinking"` — emitted before the answer it produced. Beyond the conversation, the same TUI-level activity is emitted on stdout so the stream stays both complete and entirely JSON: live tool output as `{"type":"tool_progress", ...}`, background-task/cron events as `{"type":"notification", ...}`, plus `{"type":"subagent", ...}`, `{"type":"warning", ...}`, `{"type":"skill_activated", ...}`, `{"type":"mcp_server_status", ...}`, `{"type":"compaction", ...}`, `{"type":"goal_update", ...}`, `{"type":"agent_status", ...}` and `{"type":"tool_list_updated", ...}`. (In `text` mode, tool progress is written to stderr as before and these activity events are not emitted.) The "resuming session" notice is still written to stderr in `text` mode. To drive Kimi Code from another program, pair `stream-json` output with `--input-format stream-json` to feed one JSON user message per line over stdin and run each as a turn: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 01bf3e3c5..e6a5a17cc 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -121,7 +121,7 @@ kimi -m kimi-code/kimi-for-coding -p "Explain the latest diff" kimi -p "List changed files" --output-format stream-json ``` -`stream-json` 模式下,普通回复输出 Assistant 消息;模型调用工具时,先输出带 `tool_calls` 的 Assistant 消息,再输出对应的 Tool 消息,最后继续输出后续 Assistant 消息。thinking 内容会单独成行——一条带 `"type":"thinking"` 标记的 Assistant 消息,排在它所产生的回复之前。后台任务与 cron 通知会输出为 `{"type":"notification", ...}` 行。工具进度和恢复会话提示仍写到 stderr。 +`stream-json` 模式下,普通回复输出 Assistant 消息;模型调用工具时,先输出带 `tool_calls` 的 Assistant 消息,再输出对应的 Tool 消息,最后继续输出后续 Assistant 消息。thinking 内容会单独成行——一条带 `"type":"thinking"` 标记的 Assistant 消息,排在它所产生的回复之前。除对话外,TUI 级的活动信息同样输出到 stdout,使输出流既完整又全是 JSON:实时工具输出为 `{"type":"tool_progress", ...}`、后台任务/cron 为 `{"type":"notification", ...}`,以及 `{"type":"subagent", ...}`、`{"type":"warning", ...}`、`{"type":"skill_activated", ...}`、`{"type":"mcp_server_status", ...}`、`{"type":"compaction", ...}`、`{"type":"goal_update", ...}`、`{"type":"agent_status", ...}` 与 `{"type":"tool_list_updated", ...}`。(`text` 模式下工具进度仍写到 stderr,且不输出这些活动事件。)恢复会话提示在 `text` 模式下仍写到 stderr。 需要让其它程序驱动 Kimi Code 时,把 `stream-json` 输出与 `--input-format stream-json` 搭配使用:从 stdin 每行喂一个 JSON user 消息,依次作为多轮执行: