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.
This commit is contained in:
Kaiyi 2026-06-29 16:24:00 +08:00
parent 2b1113b4ba
commit d61fc2d628
4 changed files with 312 additions and 3 deletions

View file

@ -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<string, unknown>): 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<string, unknown>): 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<Event, { type: 'tool.progress' }>): Record<string, unknown> {
const message: Record<string, unknown> = {
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<string, unknown> | undefined {
switch (event.type) {
case 'subagent.spawned': {
const message: Record<string, unknown> = {
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<string, unknown> = { type: 'warning', message: event.message };
if (event.code !== undefined) message['code'] = event.code;
return message;
}
case 'skill.activated': {
const message: Record<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { 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<string, unknown> = { 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<string> {
return createInterface({ input, crlfDelay: Infinity });

View file

@ -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) },

View file

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

View file

@ -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 消息,依次作为多轮执行: