mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
2b1113b4ba
commit
d61fc2d628
4 changed files with 312 additions and 3 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 消息,依次作为多轮执行:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue