test(cli): guarantee print stream-json covers every protocol event

Add a `Record<Event['type'], StreamJsonRoute>` coverage table that classifies
every protocol event by how print mode's stream-json output treats it. Because
it is keyed by the full event union, adding a new event type fails `typecheck`
until it is explicitly classified, so print mode can never silently drop a new
event. A companion test keeps the notification/activity/tool_progress rows in
sync with the mappers in run-prompt.ts (now exported for testing).
This commit is contained in:
Kaiyi 2026-06-29 19:54:41 +08:00
parent 98f4cfd83d
commit fef49beb9d
2 changed files with 188 additions and 3 deletions

View file

@ -1257,7 +1257,7 @@ function parsePromptBooleanEnv(value: string | undefined): boolean | undefined {
* Maps session-level notification events (background tasks, cron) to a JSONL
* notification line, or `undefined` for events that are not notifications.
*/
function toNotificationMessage(event: Event): PromptJsonNotificationMessage | undefined {
export function toNotificationMessage(event: Event): PromptJsonNotificationMessage | undefined {
switch (event.type) {
case 'background.task.started':
case 'background.task.terminated':
@ -1281,7 +1281,7 @@ 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> {
export function toolProgressMessage(event: Extract<Event, { type: 'tool.progress' }>): Record<string, unknown> {
const message: Record<string, unknown> = {
type: 'tool_progress',
tool_call_id: event.toolCallId,
@ -1302,7 +1302,7 @@ function toolProgressMessage(event: Extract<Event, { type: 'tool.progress' }>):
* `undefined` for events that are not activity events. `tool.progress` is
* turn-scoped and handled separately.
*/
function toActivityMessage(event: Event): Record<string, unknown> | undefined {
export function toActivityMessage(event: Event): Record<string, unknown> | undefined {
switch (event.type) {
case 'subagent.spawned': {
const message: Record<string, unknown> = {

View file

@ -0,0 +1,185 @@
import type { Event } from '@moonshot-ai/kimi-code-sdk';
import { describe, expect, it } from 'vitest';
import {
toActivityMessage,
toNotificationMessage,
toolProgressMessage,
} from '#/cli/run-prompt';
/**
* Print-mode stream-json event coverage.
*
* `STREAM_JSON_ROUTE` classifies EVERY protocol event into how `kimi -p
* --output-format stream-json` treats it. Because the table is typed as
* `Record<Event['type'], StreamJsonRoute>`, adding a new event type to the
* protocol makes this file fail `pnpm typecheck` until the new event is
* explicitly classified so print mode can never silently drop a new event.
*
* The runtime test then checks that the `notification` / `activity` /
* `tool_progress` rows actually match the mappers in `run-prompt.ts`, so a row
* marked "emitted" can't drift away from the code that emits it.
*/
type StreamJsonRoute =
| 'assistant' // assistant.delta / hook.result -> assistant message content
| 'thinking' // thinking.delta -> {"role":"assistant","type":"thinking"}
| 'tool_call' // tool.call.started/delta -> assistant tool_calls
| 'tool_result' // tool.result -> {"role":"tool"}
| 'tool_progress' // tool.progress -> {"type":"tool_progress"}
| 'notification' // background.task.* / cron.fired -> {"type":"notification"}
| 'activity' // subagent / warning / skill / mcp / compaction / goal / agent-status / tool-list
| 'error' // error / failed turn.ended -> {"type":"error"}
| 'turn_control' // turn lifecycle: drives flush/finish, not a data line of its own
| 'ignored'; // intentionally not surfaced (interactive-only or daemon/session-level)
const STREAM_JSON_ROUTE: Record<Event['type'], StreamJsonRoute> = {
// Conversation
'assistant.delta': 'assistant',
'hook.result': 'assistant',
'thinking.delta': 'thinking',
'tool.call.started': 'tool_call',
'tool.call.delta': 'tool_call',
'tool.result': 'tool_result',
'tool.progress': 'tool_progress',
error: 'error',
// Notifications
'background.task.started': 'notification',
'background.task.terminated': 'notification',
'cron.fired': 'notification',
// Activity layer
warning: 'activity',
'agent.status.updated': 'activity',
'goal.updated': 'activity',
'skill.activated': 'activity',
'tool.list.updated': 'activity',
'mcp.server.status': 'activity',
'subagent.spawned': 'activity',
'subagent.started': 'activity',
'subagent.suspended': 'activity',
'subagent.completed': 'activity',
'subagent.failed': 'activity',
'compaction.started': 'activity',
'compaction.blocked': 'activity',
'compaction.cancelled': 'activity',
'compaction.completed': 'activity',
// Turn lifecycle (control, not a data line)
'turn.started': 'turn_control',
'turn.ended': 'turn_control',
'turn.step.started': 'turn_control',
'turn.step.interrupted': 'turn_control',
'turn.step.retrying': 'turn_control',
// Intentionally not surfaced in print mode
'turn.step.completed': 'ignored',
'session.meta.updated': 'ignored',
'shell.output': 'ignored', // interactive `!` shell only
'shell.started': 'ignored', // interactive `!` shell only
'prompt.submitted': 'ignored',
'event.config.changed': 'ignored',
'event.session.created': 'ignored',
'event.session.status_changed': 'ignored',
'event.workspace.created': 'ignored',
'event.workspace.updated': 'ignored',
'event.workspace.deleted': 'ignored',
};
const ev = (value: unknown): Event => value as Event;
const bgInfo = (status: string) => ({
taskId: 'b1',
kind: 'process',
status,
description: 'build',
startedAt: 0,
endedAt: status === 'running' ? null : 1,
});
/** Sample events for the rows handled by the standalone session-level mappers. */
const SAMPLES: Partial<Record<Event['type'], Event>> = {
'background.task.started': ev({ type: 'background.task.started', info: bgInfo('running') }),
'background.task.terminated': ev({
type: 'background.task.terminated',
info: bgInfo('completed'),
}),
'cron.fired': ev({ type: 'cron.fired', prompt: 'tick' }),
warning: ev({ type: 'warning', message: 'heads up' }),
'agent.status.updated': ev({ type: 'agent.status.updated', model: 'k2' }),
'goal.updated': ev({ type: 'goal.updated', snapshot: null }),
'skill.activated': ev({ type: 'skill.activated', skillName: 'deploy', trigger: 'model-tool' }),
'tool.list.updated': ev({ type: 'tool.list.updated', reason: 'mcp.connected', serverName: 'fs' }),
'mcp.server.status': ev({
type: 'mcp.server.status',
server: { name: 'fs', status: 'connected', transport: 'stdio', toolCount: 0 },
}),
'subagent.spawned': ev({
type: 'subagent.spawned',
subagentId: 'a1',
subagentName: 'researcher',
runInBackground: false,
}),
'subagent.started': ev({ type: 'subagent.started', subagentId: 'a1' }),
'subagent.suspended': ev({ type: 'subagent.suspended', subagentId: 'a1', reason: 'paused' }),
'subagent.completed': ev({ type: 'subagent.completed', subagentId: 'a1', resultSummary: 'ok' }),
'subagent.failed': ev({ type: 'subagent.failed', subagentId: 'a1', error: 'boom' }),
'compaction.started': ev({ type: 'compaction.started', trigger: 'auto' }),
'compaction.blocked': ev({ type: 'compaction.blocked' }),
'compaction.cancelled': ev({ type: 'compaction.cancelled' }),
'compaction.completed': ev({
type: 'compaction.completed',
result: { summary: '', compactedCount: 2, tokensBefore: 10, tokensAfter: 4 },
}),
'tool.progress': ev({
type: 'tool.progress',
toolCallId: 'tc_1',
update: { kind: 'stdout', text: 'x' },
}),
};
describe('print-mode stream-json event coverage', () => {
it('classifies every protocol event type (compile-time exhaustiveness)', () => {
// The Record above is the real guarantee (typecheck fails on an unclassified
// new event); this asserts every value is a known route as a sanity check.
const routes = new Set<StreamJsonRoute>([
'assistant',
'thinking',
'tool_call',
'tool_result',
'tool_progress',
'notification',
'activity',
'error',
'turn_control',
'ignored',
]);
for (const route of Object.values(STREAM_JSON_ROUTE)) {
expect(routes.has(route)).toBe(true);
}
});
it('keeps the notification / activity / tool_progress rows in sync with the mappers', () => {
const mismatches: string[] = [];
for (const type of Object.keys(STREAM_JSON_ROUTE) as Event['type'][]) {
const route = STREAM_JSON_ROUTE[type];
const sample = SAMPLES[type] ?? ev({ type });
const isNotification = toNotificationMessage(sample) !== undefined;
const isActivity = toActivityMessage(sample) !== undefined;
if (isNotification !== (route === 'notification')) {
mismatches.push(`${type}: toNotificationMessage=${isNotification}, route=${route}`);
}
if (isActivity !== (route === 'activity')) {
mismatches.push(`${type}: toActivityMessage=${isActivity}, route=${route}`);
}
if (route === 'tool_progress') {
const progress = toolProgressMessage(sample as Extract<Event, { type: 'tool.progress' }>);
if (progress['type'] !== 'tool_progress') {
mismatches.push(`${type}: toolProgressMessage did not produce a tool_progress line`);
}
}
}
expect(mismatches).toEqual([]);
});
});