diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 32a65eb0e..e715b96b7 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -66,6 +66,24 @@ export function createProgram( 'Output format for prompt mode. Defaults to text.', ).choices(['text', 'stream-json']), ) + .addOption( + new Option( + '--input-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 ', @@ -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[], diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 2e0c33a42..89822ce45 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -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.'); } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 79a301ba1..b3f8c14d5 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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; + /** Injectable clock for the background-task drain loop (tests). */ + readonly clock?: PromptClock; readonly process?: PromptProcess; } +interface PromptClock { + now(): number; + sleep(ms: number): Promise; +} + interface PromptProcess { once(signal: NodeJS.Signals, listener: () => Promise): unknown; off(signal: NodeJS.Signals, listener: () => Promise): 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 "`. 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 "`. 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 { @@ -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 { 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): 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): 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 { + 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 { + return createInterface({ input, crlfDelay: Infinity }); +} + +/** Joins all input lines into a single string (for `--input-format text`). */ +async function collectLines(lines: AsyncIterable): Promise { + 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, + stderr: PromptOutput, +): AsyncGenerator { + 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(); } diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 04780bd26..4cbf33365 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -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' } })); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 73d759841..c5ce73738 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -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=/', diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 0d1fc3ea1..cec907ca7 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -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) => 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[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 { } 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) }, diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index d14fafa4a..510ebbc65 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -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 ` | `-m` | Specify a model alias for this launch. When omitted, new sessions use `default_model` from the config file | | `--prompt ` | `-p` | Run a single prompt non-interactively and stream the Assistant output to stdout. This mode does not open the TUI | -| `--output-format ` | | Set the non-interactive output format; supports `text` and `stream-json`. Can only be used with `--prompt`; defaults to `text` | +| `--output-format ` | | Set the non-interactive output format; supports `text` and `stream-json`. Only available in prompt mode; defaults to `text` | +| `--input-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 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 187cc48aa..01bf3e3c5 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -19,7 +19,10 @@ kimi [options] | `--continue` | `-c` | 继续当前工作目录下最近一次的会话,无需手动指定 ID | | `--model ` | `-m` | 为本次启动指定模型别名。省略时新会话使用配置文件中的 `default_model` | | `--prompt ` | `-p` | 非交互执行单次 prompt,并把 Assistant 输出流式写到 stdout。该模式不会打开 TUI | -| `--output-format ` | | 设置非交互输出格式,支持 `text` 与 `stream-json`。仅可与 `--prompt` 一起使用,默认 `text` | +| `--output-format ` | | 设置非交互输出格式,支持 `text` 与 `stream-json`。仅在 prompt 模式下可用,默认 `text` | +| `--input-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 [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`)时跳过等待,后台任务被保留继续运行。 ## 子命令