feat(cli): expand print mode to match kimi-cli stream-json capabilities

- emit model thinking as its own `{"role":"assistant","type":"thinking"}` JSONL line
- add `--input-format text|stream-json` (multi-turn JSON prompts over stdin)
- add `--final-message-only` and the `--quiet` shorthand
- surface background-task/cron events as `{"type":"notification",...}` lines
- report turn failures as `{"type":"error",...}` JSON and map retryable
  provider errors to exit code 75 (keeps stream-json stdout entirely JSON)
- wait for background tasks before exit, gated by background.keepAliveOnExit,
  bounded by background.printWaitCeilingS

Adds CLI option/validation tests, prompt-runner behaviour tests, and updates
the kimi-command reference docs (en/zh).
This commit is contained in:
Kaiyi 2026-06-29 16:09:15 +08:00
parent 49e93893a6
commit 2b1113b4ba
8 changed files with 1247 additions and 69 deletions

View file

@ -66,6 +66,24 @@ export function createProgram(
'Output format for prompt mode. Defaults to text.',
).choices(['text', 'stream-json']),
)
.addOption(
new Option(
'--input-format <format>',
'Read prompts from stdin in this format instead of --prompt. `stream-json` reads one JSON user message per line (multi-turn); `text` reads all of stdin as a single prompt. Implies prompt mode.',
).choices(['text', 'stream-json']),
)
.addOption(
new Option(
'--final-message-only',
'In prompt mode, emit only the final assistant message of each turn.',
).default(false),
)
.addOption(
new Option(
'--quiet',
'Shorthand for prompt mode with --output-format text --final-message-only.',
).default(false),
)
.addOption(
new Option(
'--skills-dir <dir>',
@ -131,6 +149,9 @@ export function createProgram(
plan: raw['plan'] as boolean,
model: raw['model'] as string | undefined,
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
inputFormat: raw['inputFormat'] as CLIOptions['inputFormat'],
finalMessageOnly: raw['finalMessageOnly'] === true,
quiet: raw['quiet'] === true,
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
addDirs: raw['addDir'] as string[],

View file

@ -1,5 +1,6 @@
export type UIMode = 'shell' | 'print';
export type PromptOutputFormat = 'text' | 'stream-json';
export type PromptInputFormat = 'text' | 'stream-json';
export interface CLIOptions {
session: string | undefined;
@ -9,6 +10,9 @@ export interface CLIOptions {
plan: boolean;
model: string | undefined;
outputFormat: PromptOutputFormat | undefined;
inputFormat?: PromptInputFormat | undefined;
finalMessageOnly?: boolean;
quiet?: boolean;
prompt: string | undefined;
skillsDirs: string[];
addDirs?: string[];
@ -28,8 +32,11 @@ export class OptionConflictError extends Error {
export function validateOptions(opts: CLIOptions): ValidatedOptions {
const prompt = opts.prompt;
const promptMode = prompt !== undefined;
if (promptMode && prompt.trim().length === 0) {
const hasPrompt = prompt !== undefined;
// Prompt mode is entered by `--prompt`, by `--input-format` (the prompt is
// then read from stdin instead of the flag), or by `--quiet`.
const promptMode = hasPrompt || opts.inputFormat !== undefined || opts.quiet === true;
if (hasPrompt && prompt.trim().length === 0) {
throw new OptionConflictError('Prompt cannot be empty.');
}
if (opts.model !== undefined && opts.model.trim().length === 0) {
@ -38,6 +45,19 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions {
if (!promptMode && opts.outputFormat !== undefined) {
throw new OptionConflictError('Output format is only supported in prompt mode.');
}
if (!promptMode && opts.finalMessageOnly) {
throw new OptionConflictError('Final-message-only output is only supported in prompt mode.');
}
// `--quiet` is shorthand for `--output-format text --final-message-only`, so a
// conflicting explicit output format is rejected.
if (opts.quiet === true && opts.outputFormat !== undefined && opts.outputFormat !== 'text') {
throw new OptionConflictError('Quiet mode implies --output-format text.');
}
if (hasPrompt && opts.inputFormat !== undefined) {
throw new OptionConflictError(
'Cannot combine --prompt with --input-format; the prompt is read from stdin.',
);
}
if (promptMode && opts.yolo) {
throw new OptionConflictError('Cannot combine --prompt with --yolo.');
}

View file

@ -18,6 +18,7 @@ import {
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { resolve } from 'pathe';
import { createInterface } from 'node:readline';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
@ -40,9 +41,18 @@ interface PromptOutput {
interface PromptRunIO {
readonly stdout?: PromptOutput;
readonly stderr?: PromptOutput;
/** Source of stdin lines for `--input-format`. Defaults to a reader over `process.stdin`. */
readonly stdin?: AsyncIterable<string>;
/** Injectable clock for the background-task drain loop (tests). */
readonly clock?: PromptClock;
readonly process?: PromptProcess;
}
interface PromptClock {
now(): number;
sleep(ms: number): Promise<void>;
}
interface PromptProcess {
once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
@ -53,6 +63,34 @@ const PROMPT_UI_MODE = 'print';
const PROMPT_MAIN_AGENT_ID = 'main';
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
/** Generic non-retryable failure. */
const CLI_EXIT_FAILURE = 1;
/** Transient provider failure (connection/timeout/rate-limit/5xx) — safe to retry. Mirrors EX_TEMPFAIL. */
const CLI_EXIT_RETRYABLE = 75;
/** Env override for keeping background tasks alive past exit (matches the session resolver). */
const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT';
/** Default ceiling (seconds) for waiting on background tasks at exit when none is configured. */
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
/** Poll interval (ms) while draining background tasks at exit. */
const BACKGROUND_DRAIN_POLL_MS = 500;
/**
* A turn-level failure (provider/turn error). Carries the structured code and
* retryable flag so the prompt runner can emit a machine-readable error and pick
* an exit code. Setup errors (no model, session not found, ) are plain Errors
* and are not caught by the turn handler.
*/
class PromptTurnError extends Error {
constructor(
readonly code: string,
readonly detail: string,
readonly retryable: boolean,
) {
super(`${code}: ${detail}`);
this.name = 'PromptTurnError';
}
}
export async function runPrompt(
opts: CLIOptions,
@ -139,18 +177,74 @@ export async function runPrompt(
});
setCrashPhase('runtime');
const outputFormat = opts.outputFormat ?? 'text';
// Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver keeps
// the turn-run alive across continuation turns, so the normal prompt-turn
// waiter blocks until the goal is terminal; we then emit a summary and set a
// distinct exit code.
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr);
} else {
await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr);
// `--quiet` is shorthand for `--output-format text --final-message-only`.
const finalOnly = (opts.finalMessageOnly ?? false) || opts.quiet === true;
const outputFormat: PromptOutputFormat =
opts.quiet === true ? 'text' : (opts.outputFormat ?? 'text');
const inputFormat = opts.inputFormat;
try {
if (inputFormat !== undefined) {
// Prompts come from stdin instead of `--prompt`. `stream-json` reads one
// JSON user message per line and runs a turn for each (multi-turn);
// `text` reads all of stdin as a single prompt.
const lines = io.stdin ?? defaultStdinLines(process.stdin);
if (inputFormat === 'stream-json') {
for await (const command of readUserCommands(lines, stderr)) {
await runPromptTurn(session, command, outputFormat, finalOnly, stdout, stderr);
}
} else {
const command = (await collectLines(lines)).trim();
if (command.length > 0) {
await runPromptTurn(session, command, outputFormat, finalOnly, stdout, stderr);
}
}
} else {
// Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver
// keeps the turn-run alive across continuation turns, so the normal
// prompt-turn waiter blocks until the goal is terminal; we then emit a
// summary and set a distinct exit code.
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runHeadlessGoal(
session,
goalCreate,
goalModel,
outputFormat,
finalOnly,
stdout,
stderr,
);
} else {
await runPromptTurn(session, opts.prompt!, outputFormat, finalOnly, stdout, stderr);
}
}
} catch (error) {
// A turn-level failure is reported through the chosen output format (so a
// stream-json consumer keeps receiving JSON) and mapped to an exit code;
// setup errors (no model, session not found, …) are not PromptTurnErrors
// and propagate to the top-level handler instead.
if (!(error instanceof PromptTurnError)) throw error;
emitPromptError(error, outputFormat, stdout, stderr);
process.exitCode = error.retryable ? CLI_EXIT_RETRYABLE : CLI_EXIT_FAILURE;
}
// Give background tasks a chance to finish before the session is closed (and
// any stragglers killed). Gated by the same `keepAliveOnExit` config the
// session uses: when keep-alive is on, tasks are left running and we skip the
// wait entirely.
await drainBackgroundTasksOnExit(
session,
resolveKeepAliveOnExit(config, process.env),
resolvePrintWaitCeilingMs(config),
outputFormat,
stdout,
stderr,
io.clock ?? defaultPromptClock,
);
// `--final-message-only` keeps stdout to just the answer(s), so the resume
// hint is suppressed in that mode.
if (!finalOnly) {
writeResumeHint(session.id, outputFormat, stdout, stderr);
}
writeResumeHint(session.id, outputFormat, stdout, stderr);
withTelemetryContext({ sessionId: session.id }).track('exit', {
duration_ms: Date.now() - startedAt,
@ -165,6 +259,7 @@ async function runHeadlessGoal(
goal: HeadlessGoalCreate,
model: string | undefined,
outputFormat: PromptOutputFormat,
finalOnly: boolean,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
@ -187,7 +282,7 @@ async function runHeadlessGoal(
try {
// The objective is sent as the normal prompt; goal continuation keeps the
// turn alive until a terminal state is reached.
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr);
await runPromptTurn(session, goal.objective, outputFormat, finalOnly, stdout, stderr);
} finally {
unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
@ -382,15 +477,13 @@ function runPromptTurn(
session: Session,
prompt: string,
outputFormat: PromptOutputFormat,
finalOnly: boolean,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
let activeTurnId: number | undefined;
let activeAgentId: string | undefined;
const outputWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
: new PromptTranscriptWriter(stdout, stderr);
const outputWriter = createPromptTurnWriter(outputFormat, finalOnly, stdout, stderr);
let settled = false;
let unsubscribe: (() => void) | undefined;
@ -408,11 +501,18 @@ function runPromptTurn(
};
unsubscribe = session.onEvent((event) => {
// Session-level notifications (background tasks, cron) are not tied to the
// main turn, so they are surfaced before the turn/agent filter below.
const notification = toNotificationMessage(event);
if (notification !== undefined) {
outputWriter.writeNotification(notification);
return;
}
if (event.type === 'error') {
if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
return;
}
finish(new Error(`${event.code}: ${event.message}`));
finish(new PromptTurnError(event.code, event.message, event.retryable === true));
return;
}
if (event.type === 'turn.started' && activeTurnId === undefined) {
@ -470,7 +570,7 @@ function runPromptTurn(
finish();
return;
}
finish(new Error(formatTurnEndedFailure(event)));
finish(turnEndedError(event));
return;
case 'agent.status.updated':
case 'background.task.started':
@ -498,7 +598,7 @@ function runPromptTurn(
});
session.prompt(prompt).catch((error: unknown) => {
finish(error instanceof Error ? error : new Error(String(error)));
finish(toPromptTurnError(error));
});
});
}
@ -514,11 +614,26 @@ interface PromptTurnWriter {
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
writeNotification(message: PromptJsonNotificationMessage): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
function createPromptTurnWriter(
outputFormat: PromptOutputFormat,
finalOnly: boolean,
stdout: PromptOutput,
stderr: PromptOutput,
): PromptTurnWriter {
if (outputFormat === 'stream-json') {
return finalOnly ? new PromptFinalJsonWriter(stdout) : new PromptJsonWriter(stdout);
}
return finalOnly
? new PromptFinalTextWriter(stdout)
: new PromptTranscriptWriter(stdout, stderr);
}
class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
@ -550,6 +665,8 @@ class PromptTranscriptWriter implements PromptTurnWriter {
writeToolResult(): void {}
writeNotification(): void {}
flushAssistant(): void {}
discardAssistant(): void {}
@ -581,6 +698,35 @@ interface PromptJsonToolMessage {
content: string;
}
interface PromptJsonThinkingMessage {
role: 'assistant';
type: 'thinking';
content: string;
}
/**
* Asynchronous, non-turn notifications (background tasks, cron) surfaced as
* their own JSONL line. `event` carries the originating session event type; the
* remaining fields are event-specific.
*/
interface PromptJsonNotificationMessage {
type: 'notification';
event: string;
taskId?: string;
kind?: string;
status?: string;
description?: string;
prompt?: string;
}
/** A turn-level failure surfaced as its own JSONL line in `stream-json` mode. */
interface PromptJsonErrorMessage {
type: 'error';
code: string;
message: string;
retryable: boolean;
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
@ -613,6 +759,7 @@ function writeResumeHint(
class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private thinkingText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
@ -629,7 +776,16 @@ class PromptJsonWriter implements PromptTurnWriter {
});
}
writeThinkingDelta(): void {}
writeThinkingDelta(delta: string): void {
this.thinkingText += delta;
}
writeNotification(message: PromptJsonNotificationMessage): void {
// Flush any in-flight assistant/thinking content first so the notification
// never splits a streaming assistant message.
this.flushAssistant();
this.writeJsonLine(message);
}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
@ -672,6 +828,9 @@ class PromptJsonWriter implements PromptTurnWriter {
}
flushAssistant(): void {
// Thinking precedes the assistant/tool output of the same step so a viewer
// can render the reasoning before the answer it produced.
this.flushThinking();
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
@ -684,6 +843,7 @@ class PromptJsonWriter implements PromptTurnWriter {
discardAssistant(): void {
this.assistantText = '';
this.thinkingText = '';
this.toolCalls.length = 0;
}
@ -691,6 +851,16 @@ class PromptJsonWriter implements PromptTurnWriter {
this.flushAssistant();
}
private flushThinking(): void {
if (this.thinkingText.length === 0) return;
this.writeJsonLine({
role: 'assistant',
type: 'thinking',
content: this.thinkingText,
});
this.thinkingText = '';
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
@ -706,11 +876,97 @@ class PromptJsonWriter implements PromptTurnWriter {
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
private writeJsonLine(
message:
| PromptJsonAssistantMessage
| PromptJsonToolMessage
| PromptJsonThinkingMessage
| PromptJsonNotificationMessage,
): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
/**
* `--final-message-only` + `stream-json`: emit exactly one assistant message per
* turn the final step's text. Thinking, tool calls/results and notifications
* are dropped. Each step boundary discards the previous step's buffer so only
* the last step survives to `finish()`.
*/
class PromptFinalJsonWriter implements PromptTurnWriter {
private assistantText = '';
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(): void {}
writeThinkingDelta(): void {}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
writeNotification(): void {}
flushAssistant(): void {
// A new step supersedes the previous one; keep only the latest step's text.
this.assistantText = '';
}
discardAssistant(): void {
this.assistantText = '';
}
finish(): void {
this.stdout.write(`${JSON.stringify({ role: 'assistant', content: this.assistantText })}\n`);
}
}
/**
* `--final-message-only` + `text`: emit only the final step's assistant text.
*/
class PromptFinalTextWriter implements PromptTurnWriter {
private assistantText = '';
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(): void {}
writeThinkingDelta(): void {}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
writeNotification(): void {}
flushAssistant(): void {
this.assistantText = '';
}
discardAssistant(): void {
this.assistantText = '';
}
finish(): void {
if (this.assistantText.length > 0) {
this.stdout.write(`${this.assistantText}\n`);
}
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
@ -804,10 +1060,249 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
return 'turnId' in event;
}
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
if (event.reason === 'filtered') {
return 'Provider safety policy blocked the response.';
function turnEndedError(event: Extract<Event, { type: 'turn.ended' }>): PromptTurnError {
if (event.error !== undefined) {
return new PromptTurnError(
event.error.code,
event.error.message,
event.error.retryable === true,
);
}
return `Prompt turn ended with reason: ${event.reason}`;
if (event.reason === 'filtered') {
return new PromptTurnError(
'provider.filtered',
'Provider safety policy blocked the response.',
false,
);
}
return new PromptTurnError(
`turn.${event.reason}`,
`Prompt turn ended with reason: ${event.reason}`,
false,
);
}
/** Normalizes an error thrown by `session.prompt()` into a {@link PromptTurnError}. */
function toPromptTurnError(error: unknown): PromptTurnError {
if (error instanceof PromptTurnError) return error;
if (error !== null && typeof error === 'object') {
const payload = error as { code?: unknown; message?: unknown; retryable?: unknown };
if (typeof payload.code === 'string' && typeof payload.message === 'string') {
return new PromptTurnError(payload.code, payload.message, payload.retryable === true);
}
}
return new PromptTurnError('internal', error instanceof Error ? error.message : String(error), false);
}
/**
* Reports a turn-level failure through the active output format: a structured
* `{"type":"error",…}` JSONL line on stdout for `stream-json`, or a plain-text
* line on stderr for `text` (keeping `stream-json` stdout entirely JSON).
*/
function emitPromptError(
error: PromptTurnError,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
if (outputFormat === 'stream-json') {
const message: PromptJsonErrorMessage = {
type: 'error',
code: error.code,
message: error.detail,
retryable: error.retryable,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`Error: ${error.message}\n`);
}
const defaultPromptClock: PromptClock = {
now: () => Date.now(),
sleep: (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
}),
};
/**
* Waits for active background tasks to finish before the session is closed,
* bounded by the print-wait ceiling. Skipped entirely when `keepAliveOnExit` is
* set (tasks are intentionally left running). In `stream-json` mode each task's
* terminal outcome is surfaced as a notification line while waiting.
*/
async function drainBackgroundTasksOnExit(
session: Session,
keepAliveOnExit: boolean,
ceilingMs: number,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
clock: PromptClock,
): Promise<void> {
if (keepAliveOnExit) return;
let active = await session.listBackgroundTasks({ activeOnly: true });
if (active.length === 0) return;
const unsubscribe =
outputFormat === 'stream-json'
? session.onEvent((event) => {
const notification = toNotificationMessage(event);
if (notification?.event === 'background.task.terminated') {
stdout.write(`${JSON.stringify(notification)}\n`);
}
})
: undefined;
try {
const deadline = clock.now() + ceilingMs;
while (active.length > 0) {
if (clock.now() >= deadline) {
stderr.write(
`Timed out after ${Math.round(ceilingMs / 1000)}s waiting for ${active.length} background task(s); they will be stopped.\n`,
);
return;
}
await clock.sleep(BACKGROUND_DRAIN_POLL_MS);
active = await session.listBackgroundTasks({ activeOnly: true });
}
} finally {
unsubscribe?.();
}
}
/**
* Resolves whether background tasks are kept alive past exit, mirroring the
* session resolver: env override, then config, defaulting to false (drain).
*/
function resolveKeepAliveOnExit(
config: { readonly background?: unknown },
env: NodeJS.ProcessEnv,
): boolean {
const envValue = parsePromptBooleanEnv(env[BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV]);
if (envValue !== undefined) return envValue;
const background = config.background as { keepAliveOnExit?: unknown } | undefined;
return typeof background?.keepAliveOnExit === 'boolean' ? background.keepAliveOnExit : false;
}
/** Resolves the background-drain ceiling in milliseconds from config. */
function resolvePrintWaitCeilingMs(config: { readonly background?: unknown }): number {
const background = config.background as { printWaitCeilingS?: unknown } | undefined;
const seconds =
typeof background?.printWaitCeilingS === 'number' && background.printWaitCeilingS > 0
? background.printWaitCeilingS
: DEFAULT_PRINT_WAIT_CEILING_S;
return seconds * 1000;
}
function parsePromptBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') {
return true;
}
if (
normalized === '0' ||
normalized === 'false' ||
normalized === 'no' ||
normalized === 'off'
) {
return false;
}
return 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 {
switch (event.type) {
case 'background.task.started':
case 'background.task.terminated':
return {
type: 'notification',
event: event.type,
taskId: event.info.taskId,
kind: event.info.kind,
status: event.info.status,
description: event.info.description,
};
case 'cron.fired':
return {
type: 'notification',
event: 'cron.fired',
prompt: event.prompt,
};
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 });
}
/** Joins all input lines into a single string (for `--input-format text`). */
async function collectLines(lines: AsyncIterable<string>): Promise<string> {
const collected: string[] = [];
for await (const line of lines) {
collected.push(line);
}
return collected.join('\n');
}
/**
* Yields a prompt command per `user` message read from stdin as JSONL. Blank
* lines, malformed JSON and non-`user` messages are skipped with a stderr note,
* matching the kimi-cli `stream-json` input contract.
*/
async function* readUserCommands(
lines: AsyncIterable<string>,
stderr: PromptOutput,
): AsyncGenerator<string> {
for await (const raw of lines) {
const line = raw.trim();
if (line.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
stderr.write(`Ignoring invalid JSON input line: ${line}\n`);
continue;
}
const command = extractUserCommand(parsed);
if (command === undefined) {
stderr.write(`Ignoring non-user input message: ${line}\n`);
continue;
}
if (command.length === 0) continue;
yield command;
}
}
/** Extracts the prompt text from a parsed `user` message, or `undefined`. */
function extractUserCommand(parsed: unknown): string | undefined {
if (typeof parsed !== 'object' || parsed === null) return undefined;
const message = parsed as { role?: unknown; content?: unknown };
if (message.role !== 'user') return undefined;
return extractMessageText(message.content);
}
/** Concatenates the text of a message's content (string or content-part array). */
function extractMessageText(content: unknown): string {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
const texts: string[] = [];
for (const part of content) {
if (
typeof part === 'object' &&
part !== null &&
(part as { type?: unknown }).type === 'text' &&
typeof (part as { text?: unknown }).text === 'string'
) {
texts.push((part as { text: string }).text);
}
}
return texts.join('\n').trim();
}

View file

@ -90,6 +90,7 @@ const mocks = vi.hoisted(() => {
eventHandlers.add(handler);
return () => eventHandlers.delete(handler);
}),
listBackgroundTasks: vi.fn(async () => [] as readonly unknown[]),
prompt: vi.fn(async () => {
for (const handler of eventHandlers) {
handler(mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));

View file

@ -303,6 +303,83 @@ describe('CLI options parsing', () => {
});
});
describe('--input-format', () => {
it('parses --input-format stream-json and enters print mode without --prompt', () => {
const opts = parse(['--input-format', 'stream-json']);
expect(opts.inputFormat).toBe('stream-json');
expect(opts.prompt).toBeUndefined();
expect(validateOptions(opts).uiMode).toBe('print');
});
it('parses --input-format text and enters print mode', () => {
const opts = parse(['--input-format=text']);
expect(opts.inputFormat).toBe('text');
expect(validateOptions(opts).uiMode).toBe('print');
});
it('allows --input-format with --output-format stream-json', () => {
const opts = parse(['--input-format', 'stream-json', '--output-format', 'stream-json']);
expect(opts.inputFormat).toBe('stream-json');
expect(opts.outputFormat).toBe('stream-json');
expect(validateOptions(opts).uiMode).toBe('print');
});
it('rejects combining --prompt with --input-format', () => {
const opts = parse(['-p', 'hi', '--input-format', 'stream-json']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow(
'Cannot combine --prompt with --input-format; the prompt is read from stdin.',
);
});
it('rejects an unknown --input-format value', () => {
expect(() => parse(['--input-format', 'yaml'])).toThrow();
});
});
describe('--final-message-only', () => {
it('defaults to false', () => {
expect(parse([]).finalMessageOnly).toBe(false);
});
it('parses --final-message-only in prompt mode', () => {
const opts = parse(['-p', 'hi', '--final-message-only']);
expect(opts.finalMessageOnly).toBe(true);
expect(validateOptions(opts).uiMode).toBe('print');
});
it('rejects --final-message-only outside prompt mode', () => {
const opts = parse(['--final-message-only']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow(
'Final-message-only output is only supported in prompt mode.',
);
});
});
describe('--quiet', () => {
it('defaults to false', () => {
expect(parse([]).quiet).toBe(false);
});
it('enters print mode on its own', () => {
const opts = parse(['--quiet']);
expect(opts.quiet).toBe(true);
expect(validateOptions(opts).uiMode).toBe('print');
});
it('is allowed alongside an explicit --output-format text', () => {
const opts = parse(['--quiet', '--output-format', 'text']);
expect(() => validateOptions(opts)).not.toThrow();
});
it('rejects --quiet with --output-format stream-json', () => {
const opts = parse(['--quiet', '--output-format', 'stream-json']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow('Quiet mode implies --output-format text.');
});
});
describe('--skills-dir', () => {
it('collects repeated skill directories', () => {
expect(parse(['--skills-dir', '/one', '--skills-dir=/two']).skillsDirs).toEqual([
@ -408,9 +485,6 @@ describe('CLI options parsing', () => {
'--agent=default',
'--raw-model',
'--config-file=x',
'--quiet',
'--final-message-only',
'--input-format=text',
'--agent-file=x',
'--mcp-config={}',
'--mcp-config-file=/',

View file

@ -1,5 +1,5 @@
import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runPrompt } from '#/cli/run-prompt';
@ -13,6 +13,14 @@ const mocks = vi.hoisted(() => {
...event,
});
const mainEvent = (event: Record<string, unknown>) => agentEvent('main', event);
const defaultPromptImpl = async (_command?: string) => {
for (const handler of eventHandlers) {
handler(mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'hello' }));
handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: ' world' }));
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
};
const session = {
id: 'ses_prompt',
setModel: vi.fn(),
@ -28,16 +36,8 @@ const mocks = vi.hoisted(() => {
eventHandlers.add(handler);
return () => eventHandlers.delete(handler);
}),
prompt: vi.fn(async () => {
for (const handler of eventHandlers) {
handler(
mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }),
);
handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'hello' }));
handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: ' world' }));
handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
}
}),
listBackgroundTasks: vi.fn(async () => [] as readonly unknown[]),
prompt: vi.fn(defaultPromptImpl),
};
return {
@ -45,6 +45,7 @@ const mocks = vi.hoisted(() => {
eventHandlers,
agentEvent,
mainEvent,
defaultPromptImpl,
kimiHarnessConstructor: vi.fn(),
harnessEnsureConfigFile: vi.fn(),
harnessGetConfig: vi.fn(
@ -133,6 +134,8 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) {
plan: false,
model: undefined,
outputFormat: undefined,
inputFormat: undefined,
finalMessageOnly: false,
prompt: 'say hello',
skillsDirs: [],
addDirs: [],
@ -183,9 +186,18 @@ async function waitForAssertion(assertion: () => void): Promise<void> {
}
describe('runPrompt', () => {
let savedExitCode: typeof process.exitCode;
beforeEach(() => {
savedExitCode = process.exitCode;
});
afterEach(() => {
process.exitCode = savedExitCode;
vi.clearAllMocks();
mocks.eventHandlers.clear();
mocks.session.prompt.mockImplementation(mocks.defaultPromptImpl);
mocks.session.listBackgroundTasks.mockResolvedValue([]);
mocks.createKimiDeviceId.mockImplementation(() => 'device-1');
mocks.resolveKimiHome.mockImplementation(
(homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home',
@ -571,6 +583,331 @@ describe('runPrompt', () => {
);
});
it('writes stream-json thinking as its own JSONL line before the assistant content', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 9, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 9, delta: 'let me ' }));
handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 9, delta: 'think' }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 9, delta: 'the answer' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 9, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe(
[
'{"role":"assistant","type":"thinking","content":"let me think"}',
'{"role":"assistant","content":"the answer"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
expect(stderr.text()).toBe('');
});
it('writes stream-json thinking before tool calls and keeps the full chain order', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 10, delta: 'inspect dir' }));
handler(
mocks.mainEvent({
type: 'tool.call.started',
turnId: 10,
toolCallId: 'tc_1',
name: 'Bash',
args: { command: 'ls' },
}),
);
handler(
mocks.mainEvent({ type: 'tool.result', turnId: 10, toolCallId: 'tc_1', output: 'file1' }),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'found it' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe(
[
'{"role":"assistant","type":"thinking","content":"inspect dir"}',
'{"role":"assistant","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Bash","arguments":"{\\"command\\":\\"ls\\"}"}}]}',
'{"role":"tool","tool_call_id":"tc_1","content":"file1"}',
'{"role":"assistant","content":"found it"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
});
it('flushes stream-json thinking at each step boundary within a turn', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 11, origin: { kind: 'user' } }));
handler(
mocks.mainEvent({ type: 'thinking.delta', turnId: 11, delta: 'step one thinking' }),
);
handler(
mocks.mainEvent({
type: 'tool.call.started',
turnId: 11,
toolCallId: 'tc_1',
name: 'Bash',
args: { command: 'ls' },
}),
);
handler(
mocks.mainEvent({ type: 'tool.result', turnId: 11, toolCallId: 'tc_1', output: 'ok' }),
);
handler(
mocks.mainEvent({ type: 'thinking.delta', turnId: 11, delta: 'step two thinking' }),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 11, delta: 'all done' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 11, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe(
[
'{"role":"assistant","type":"thinking","content":"step one thinking"}',
'{"role":"assistant","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Bash","arguments":"{\\"command\\":\\"ls\\"}"}}]}',
'{"role":"tool","tool_call_id":"tc_1","content":"ok"}',
'{"role":"assistant","type":"thinking","content":"step two thinking"}',
'{"role":"assistant","content":"all done"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
});
it('discards partial stream-json thinking when a step retries', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 12, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 12, delta: 'wrong path' }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 12, delta: 'partial' }));
handler(mocks.mainEvent({ type: 'turn.step.retrying', turnId: 12 }));
handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 12, delta: 'right path' }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 12, delta: 'final' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 12, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe(
[
'{"role":"assistant","type":"thinking","content":"right path"}',
'{"role":"assistant","content":"final"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
});
it('emits notification events as JSON lines, flushing the assistant first (output C)', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 30, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 30, delta: 'starting' }));
handler(
mocks.mainEvent({
type: 'background.task.terminated',
info: {
taskId: 'b1',
kind: 'agent',
status: 'completed',
description: 'build',
startedAt: 0,
endedAt: 1,
},
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 30, delta: 'done' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 30, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe(
[
'{"role":"assistant","content":"starting"}',
'{"type":"notification","event":"background.task.terminated","taskId":"b1","kind":"agent","status":"completed","description":"build"}',
'{"role":"assistant","content":"done"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
});
it('does not emit notification JSON in text output mode (output C)', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 31, origin: { kind: 'user' } }));
handler(
mocks.mainEvent({
type: 'background.task.terminated',
info: {
taskId: 'b1',
kind: 'process',
status: 'completed',
description: 'build',
startedAt: 0,
endedAt: 1,
},
}),
);
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 31, delta: 'ok' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 31, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts(), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe('• ok\n\n');
expect(stdout.text()).not.toContain('notification');
});
it('emits only the final assistant message in stream-json final-message-only mode (output B)', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 40, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'turn.step.started', turnId: 40 }));
handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 40, delta: 'secret' }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 40, delta: 'first step' }));
handler(mocks.mainEvent({ type: 'turn.step.started', turnId: 40 }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 40, delta: 'final' }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 40, delta: ' answer' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 40, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json', finalMessageOnly: true }), '1.2.3-test', {
stdout,
stderr,
});
expect(stdout.text()).toBe('{"role":"assistant","content":"final answer"}\n');
expect(stderr.text()).toBe('');
});
it('emits only the final text in text final-message-only mode and skips the resume hint (output B)', async () => {
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ finalMessageOnly: true }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe('hello world\n');
expect(stderr.text()).toBe('');
});
it('reads multiple JSON user messages from stdin and runs a turn for each (input A)', async () => {
mocks.session.prompt.mockImplementation(async (command?: string) => {
for (const handler of Array.from(mocks.eventHandlers)) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 50, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 50, delta: `echo:${command}` }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 50, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
const stdin = (async function* () {
yield JSON.stringify({ role: 'user', content: 'first' });
yield JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'second' }] });
})();
await runPrompt(
opts({ inputFormat: 'stream-json', outputFormat: 'stream-json', prompt: undefined }),
'1.2.3-test',
{ stdout, stderr, stdin },
);
expect(mocks.session.prompt).toHaveBeenNthCalledWith(1, 'first');
expect(mocks.session.prompt).toHaveBeenNthCalledWith(2, 'second');
expect(stdout.text()).toBe(
[
'{"role":"assistant","content":"echo:first"}',
'{"role":"assistant","content":"echo:second"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
});
it('skips blank, malformed, and non-user stdin lines (input A)', async () => {
mocks.session.prompt.mockImplementation(async (command?: string) => {
for (const handler of Array.from(mocks.eventHandlers)) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 51, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 51, delta: `echo:${command}` }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 51, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
const stdin = (async function* () {
yield '';
yield 'not json';
yield JSON.stringify({ role: 'assistant', content: 'ignore me' });
yield JSON.stringify({ role: 'user', content: 'real' });
})();
await runPrompt(
opts({ inputFormat: 'stream-json', outputFormat: 'stream-json', prompt: undefined }),
'1.2.3-test',
{ stdout, stderr, stdin },
);
expect(mocks.session.prompt).toHaveBeenCalledTimes(1);
expect(mocks.session.prompt).toHaveBeenCalledWith('real');
expect(stdout.text()).toBe(
[
'{"role":"assistant","content":"echo:real"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
expect(stderr.text()).toContain('Ignoring invalid JSON input line: not json');
expect(stderr.text()).toContain('Ignoring non-user input message');
});
it('reads a single prompt from stdin with --input-format text (input A)', async () => {
const stdout = writer();
const stderr = writer();
const stdin = (async function* () {
yield 'line one';
yield 'line two';
})();
await runPrompt(opts({ inputFormat: 'text', prompt: undefined }), '1.2.3-test', {
stdout,
stderr,
stdin,
});
expect(mocks.session.prompt).toHaveBeenCalledWith('line one\nline two');
expect(stdout.text()).toBe('• hello world\n\n');
});
it('resumes a concrete session without a configured default model', async () => {
mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true });
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' });
@ -631,7 +968,7 @@ describe('runPrompt', () => {
);
});
it('restores resumed session permission even when the turn fails', async () => {
it('restores resumed session permission and reports a failed turn', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(
@ -647,14 +984,15 @@ describe('runPrompt', () => {
);
}
});
const stderr = writer();
await expect(
runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
}),
).rejects.toThrow('provider.error: model failed');
await runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr,
});
expect(stderr.text()).toContain('Error: provider.error: model failed');
expect(process.exitCode).toBe(1);
expect(mocks.session.setPermission).toHaveBeenNthCalledWith(1, 'auto');
expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual');
expect(mocks.session.setPermission.mock.invocationCallOrder[1]).toBeLessThan(
@ -826,7 +1164,7 @@ describe('runPrompt', () => {
expect(mocks.harnessClose).toHaveBeenCalled();
});
it('rejects when the turn fails and still closes resources', async () => {
it('reports a failed turn to stderr (text mode), sets exit code 1, and still closes resources', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(
@ -842,19 +1180,80 @@ describe('runPrompt', () => {
);
}
});
const stderr = writer();
await expect(
runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
}),
).rejects.toThrow('provider.error: model failed');
await runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr,
});
expect(stderr.text()).toContain('Error: provider.error: model failed');
expect(process.exitCode).toBe(1);
expect(mocks.shutdownTelemetry).toHaveBeenCalled();
expect(mocks.harnessClose).toHaveBeenCalled();
});
it('rejects with a friendly message when the provider filters the response', async () => {
it('emits a JSON error line on stdout when the turn fails in stream-json mode (output)', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } }),
);
handler(
mocks.mainEvent({
type: 'turn.ended',
turnId: 2,
reason: 'failed',
error: { code: 'provider.api_error', message: 'model failed', retryable: false },
}),
);
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe(
[
'{"type":"error","code":"provider.api_error","message":"model failed","retryable":false}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
expect(stderr.text()).toBe('');
expect(process.exitCode).toBe(1);
});
it('maps a retryable provider error to exit code 75', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(
mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } }),
);
handler(
mocks.mainEvent({
type: 'turn.ended',
turnId: 2,
reason: 'failed',
error: { code: 'provider.rate_limit', message: 'slow down', retryable: true },
}),
);
}
});
const stdout = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', {
stdout,
stderr: writer(),
});
expect(stdout.text()).toContain('"type":"error"');
expect(stdout.text()).toContain('"retryable":true');
expect(process.exitCode).toBe(75);
});
it('reports a filtered response as a JSON error in stream-json mode', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } }));
@ -867,18 +1266,150 @@ describe('runPrompt', () => {
);
}
});
const stdout = writer();
const stderr = writer();
await expect(
runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
}),
).rejects.toThrow('Provider safety policy blocked the response.');
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toContain(
'{"type":"error","code":"provider.filtered","message":"Provider safety policy blocked the response.","retryable":false}',
);
expect(process.exitCode).toBe(1);
expect(mocks.shutdownTelemetry).toHaveBeenCalled();
expect(mocks.harnessClose).toHaveBeenCalled();
});
it('reports a filtered response to stderr in text mode and sets exit code 1', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } }));
handler(
mocks.mainEvent({
type: 'turn.ended',
turnId: 2,
reason: 'filtered',
}),
);
}
});
const stderr = writer();
await runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr,
});
expect(stderr.text()).toContain('Provider safety policy blocked the response.');
expect(process.exitCode).toBe(1);
expect(mocks.shutdownTelemetry).toHaveBeenCalled();
expect(mocks.harnessClose).toHaveBeenCalled();
});
it('treats --quiet as text output with final-message-only and no resume hint', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
handler(mocks.mainEvent({ type: 'turn.started', turnId: 60, origin: { kind: 'user' } }));
handler(mocks.mainEvent({ type: 'turn.step.started', turnId: 60 }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 60, delta: 'first' }));
handler(mocks.mainEvent({ type: 'turn.step.started', turnId: 60 }));
handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 60, delta: 'final answer' }));
handler(mocks.mainEvent({ type: 'turn.ended', turnId: 60, reason: 'completed' }));
}
});
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ quiet: true, outputFormat: undefined }), '1.2.3-test', {
stdout,
stderr,
});
expect(stdout.text()).toBe('final answer\n');
expect(stderr.text()).toBe('');
});
it('waits for active background tasks before exit and emits completions (Tier 2)', async () => {
let listCalls = 0;
mocks.session.listBackgroundTasks.mockImplementation(async () => {
listCalls += 1;
return listCalls === 1 ? [{ taskId: 'b1', status: 'running' }] : [];
});
const fakeClock = {
now: () => 0,
sleep: vi.fn(async () => {
// The task terminates while we wait; the drain subscription emits it.
for (const handler of Array.from(mocks.eventHandlers)) {
handler(
mocks.mainEvent({
type: 'background.task.terminated',
info: {
taskId: 'b1',
kind: 'process',
status: 'completed',
description: 'build',
startedAt: 0,
endedAt: 1,
},
}),
);
}
}),
};
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', {
stdout,
stderr,
clock: fakeClock,
});
expect(fakeClock.sleep).toHaveBeenCalled();
expect(mocks.session.listBackgroundTasks).toHaveBeenCalledWith({ activeOnly: true });
expect(stdout.text()).toContain(
'{"type":"notification","event":"background.task.terminated","taskId":"b1","kind":"process","status":"completed","description":"build"}',
);
});
it('skips the background-task wait when keepAliveOnExit is set via env (Tier 2)', async () => {
process.env['KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'] = '1';
mocks.session.listBackgroundTasks.mockResolvedValue([{ taskId: 'b1', status: 'running' }]);
const fakeClock = { now: () => 0, sleep: vi.fn(async () => {}) };
try {
await runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
clock: fakeClock,
});
} finally {
delete process.env['KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'];
}
expect(mocks.session.listBackgroundTasks).not.toHaveBeenCalled();
expect(fakeClock.sleep).not.toHaveBeenCalled();
});
it('stops waiting for background tasks after the print-wait ceiling (Tier 2)', async () => {
mocks.session.listBackgroundTasks.mockResolvedValue([{ taskId: 'b1', status: 'running' }]);
let clockMs = 0;
const fakeClock = {
now: () => clockMs,
sleep: vi.fn(async () => {
clockMs += 10_000_000_000; // jump well past the ceiling
}),
};
const stderr = writer();
await runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr,
clock: fakeClock,
});
expect(stderr.text()).toContain('Timed out');
expect(stderr.text()).toContain('background task');
});
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

@ -19,7 +19,10 @@ All flags are optional — run `kimi` directly to enter an interactive session:
| `--continue` | `-c` | Continue the most recent session in the current working directory, without specifying an ID manually |
| `--model <model>` | `-m` | Specify a model alias for this launch. When omitted, new sessions use `default_model` from the config file |
| `--prompt <prompt>` | `-p` | Run a single prompt non-interactively and stream the Assistant output to stdout. This mode does not open the TUI |
| `--output-format <format>` | | Set the non-interactive output format; supports `text` and `stream-json`. Can only be used with `--prompt`; defaults to `text` |
| `--output-format <format>` | | Set the non-interactive output format; supports `text` and `stream-json`. Only available in prompt mode; defaults to `text` |
| `--input-format <format>` | | Read prompts from stdin instead of `--prompt`; supports `text` (all of stdin as one prompt) and `stream-json` (one JSON user message per line, run as successive turns). Implies prompt mode and cannot be combined with `--prompt` |
| `--final-message-only` | | In prompt mode, emit only the final Assistant message of each turn (thinking, tool calls and notifications are dropped, and the resume hint is suppressed) |
| `--quiet` | | Shorthand for prompt mode with `--output-format text --final-message-only` |
| `--yolo` | `-y` | Auto-approve regular tool calls, skipping approval requests |
| `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions |
| `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning |
@ -39,7 +42,9 @@ The following combinations are rejected at startup:
- `--continue` and `--session` are mutually exclusive — both mean "resume a previous session"
- `--yolo` and `--auto` are mutually exclusive — the two permission modes cannot be combined
- `--prompt` cannot be used with `--yolo`, `--auto`, or `--plan` — non-interactive mode uses `auto` permission by default
- `--output-format` can only be used together with `--prompt`
- `--output-format`, `--input-format` and `--final-message-only` only apply in prompt mode (entered by `--prompt`, `--input-format` or `--quiet`)
- `--prompt` cannot be combined with `--input-format` — the prompt is read from stdin instead
- `--quiet` implies `--output-format text`, so combining it with `--output-format stream-json` is rejected
When resuming a session, you can override its saved permission or plan mode by adding `--auto`, `--yolo`, or `--plan`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode.
@ -116,7 +121,20 @@ 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 not written to JSONL; 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. Background-task and cron notifications are emitted as `{"type":"notification", ...}` lines. Tool progress and "resuming session" notices are still written to stderr.
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:
```sh
printf '%s\n' '{"role":"user","content":"List changed files"}' \
| kimi --input-format stream-json --output-format stream-json
```
Use `--final-message-only` when you only want the final answer of each turn rather than the full stream — for example `kimi -p "..." --output-format stream-json --final-message-only` emits a single Assistant message per turn.
When a turn fails, the error is reported in the active format so the output stays machine-readable: in `stream-json` mode a `{"type":"error","code":"…","message":"…","retryable":…}` line is written to stdout (keeping stdout entirely JSON); in `text` mode it is written to stderr. The process exit code is `0` on success, `75` for a retryable provider error (connection/timeout/rate-limit/5xx), and `1` for any other failure.
Before exiting, prompt mode waits for any still-running background tasks to finish (bounded by `background.print_wait_ceiling_s`, default 3600s), surfacing each task's completion as a notification line in `stream-json` mode. This wait is skipped when `background.keep_alive_on_exit` is set (or `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT=1`), in which case the tasks are left running.
## Subcommands

View file

@ -19,7 +19,10 @@ kimi <subcommand> [options]
| `--continue` | `-c` | 继续当前工作目录下最近一次的会话,无需手动指定 ID |
| `--model <model>` | `-m` | 为本次启动指定模型别名。省略时新会话使用配置文件中的 `default_model` |
| `--prompt <prompt>` | `-p` | 非交互执行单次 prompt并把 Assistant 输出流式写到 stdout。该模式不会打开 TUI |
| `--output-format <format>` | | 设置非交互输出格式,支持 `text``stream-json`。仅可与 `--prompt` 一起使用,默认 `text` |
| `--output-format <format>` | | 设置非交互输出格式,支持 `text``stream-json`。仅在 prompt 模式下可用,默认 `text` |
| `--input-format <format>` | | 从 stdin 读取 prompt替代 `--prompt`),支持 `text`(整个 stdin 作为一条 prompt`stream-json`(每行一个 JSON user 消息,依次作为多轮执行)。会进入 prompt 模式,且不能与 `--prompt` 同时使用 |
| `--final-message-only` | | prompt 模式下只输出每轮最后一条 Assistant 消息(丢弃 thinking、工具调用与 notification并不再输出恢复会话提示 |
| `--quiet` | | `--output-format text --final-message-only` 的 prompt 模式简写 |
| `--yolo` | `-y` | 自动批准普通工具调用,跳过审批请求 |
| `--auto` | | 以 auto 权限模式启动工具审批自动处理Agent 不会向用户提问 |
| `--plan` | | 以 Plan 模式启动新会话AI 会优先使用只读工具进行探索和规划 |
@ -39,7 +42,9 @@ kimi <subcommand> [options]
- `--continue``--session` 互斥——两者都表示"恢复历史会话"
- `--yolo``--auto` 互斥——两种权限模式互斥
- `--prompt` 不能与 `--yolo``--auto``--plan` 同时使用——非交互模式固定使用 `auto` 权限
- `--output-format` 只能与 `--prompt` 一起使用
- `--output-format``--input-format``--final-message-only` 仅在 prompt 模式下生效(由 `--prompt``--input-format``--quiet` 进入)
- `--prompt` 不能与 `--input-format` 同时使用——此时 prompt 从 stdin 读取
- `--quiet` 隐含 `--output-format text`,因此不能与 `--output-format stream-json` 同时使用
恢复会话时,可以通过 `--auto``--yolo``--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。
@ -116,7 +121,20 @@ 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 内容不会写入 JSONL工具进度和恢复会话提示仍写到 stderr。
`stream-json` 模式下,普通回复输出 Assistant 消息;模型调用工具时,先输出带 `tool_calls` 的 Assistant 消息,再输出对应的 Tool 消息,最后继续输出后续 Assistant 消息。thinking 内容会单独成行——一条带 `"type":"thinking"` 标记的 Assistant 消息,排在它所产生的回复之前。后台任务与 cron 通知会输出为 `{"type":"notification", ...}` 行。工具进度和恢复会话提示仍写到 stderr。
需要让其它程序驱动 Kimi Code 时,把 `stream-json` 输出与 `--input-format stream-json` 搭配使用:从 stdin 每行喂一个 JSON user 消息,依次作为多轮执行:
```sh
printf '%s\n' '{"role":"user","content":"List changed files"}' \
| kimi --input-format stream-json --output-format stream-json
```
只想要每轮最终答案时使用 `--final-message-only`,例如 `kimi -p "..." --output-format stream-json --final-message-only` 每轮只输出一条 Assistant 消息。
turn 失败时,错误会以当前格式上报,保证输出仍可被机器解析:`stream-json` 模式下向 stdout 写一行 `{"type":"error","code":"…","message":"…","retryable":…}`stdout 保持全 JSON`text` 模式下写到 stderr。进程退出码成功为 `0`,可重试的 provider 错误(连接/超时/限流/5xx`75`,其它失败为 `1`
退出前prompt 模式会等待仍在运行的后台任务结束(上限为 `background.print_wait_ceiling_s`,默认 3600s并在 `stream-json` 模式下把每个任务的完成作为 notification 行输出。当设置了 `background.keep_alive_on_exit`(或 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT=1`)时跳过等待,后台任务被保留继续运行。
## 子命令