diff --git a/packages/coding-agent/src/modes/print-mode.test.ts b/packages/coding-agent/src/modes/print-mode.test.ts index f8f6001d0..957095513 100644 --- a/packages/coding-agent/src/modes/print-mode.test.ts +++ b/packages/coding-agent/src/modes/print-mode.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import { + createPrintModeLivenessReporter, PrintModeTimeoutError, promptWithPrintTimeout, resolvePrintModeTimeoutMs, @@ -79,3 +80,61 @@ test("runPrintMode_when_extension_startup_hangs_still_runs_prompt", async () => console.log = originalLog; } }); + +test("createPrintModeLivenessReporter_in_text_mode_reports_progress_to_stderr_once", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const report = createPrintModeLivenessReporter("text"); + report({ type: "message_start" }); + report({ + type: "message_update", + assistantMessageEvent: { type: "thinking_start" }, + }); + report({ + type: "message_update", + assistantMessageEvent: { type: "thinking_delta" }, + }); + report({ + type: "message_update", + assistantMessageEvent: { type: "text_start" }, + }); + report({ + type: "message_update", + assistantMessageEvent: { type: "text_delta" }, + }); + + assert.deepEqual(writes, [ + "[forge] model responding...\n", + "[forge] thinking...\n", + "[forge] writing response...\n", + ]); + } finally { + process.stderr.write = originalWrite; + } +}); + +test("createPrintModeLivenessReporter_in_json_mode_keeps_stderr_quiet", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const report = createPrintModeLivenessReporter("json"); + report({ type: "message_start" }); + report({ + type: "message_update", + assistantMessageEvent: { type: "thinking_start" }, + }); + + assert.deepEqual(writes, []); + } finally { + process.stderr.write = originalWrite; + } +}); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 472a6501d..09083c159 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -126,6 +126,7 @@ export async function runPrintMode( // Print mode intentionally skips extension session_start binding. One-shot // automation needs bounded prompt output; startup hooks are interactive/RPC // lifecycle work and have previously blocked `sf -p` before the prompt ran. + const liveness = createPrintModeLivenessReporter(mode); // Always subscribe to enable session persistence via _handleAgentEvent const unsubscribe = session.subscribe((event) => { @@ -133,6 +134,7 @@ export async function runPrintMode( if (mode === "json") { console.log(JSON.stringify(event)); } + liveness(event); }); let exitCode = 0; @@ -232,3 +234,36 @@ export async function runPrintMode( process.exit(exitCode); } } + +export function createPrintModeLivenessReporter( + mode: "text" | "json", +): (event: { type: string; assistantMessageEvent?: { type: string } }) => void { + if (mode !== "text") return () => {}; + let sawAssistant = false; + let sawThinking = false; + let sawText = false; + return (event) => { + if (event.type === "message_start" && !sawAssistant) { + sawAssistant = true; + process.stderr.write("[forge] model responding...\n"); + return; + } + if (event.type !== "message_update") return; + const streamType = event.assistantMessageEvent?.type; + if ( + (streamType === "thinking_start" || streamType === "thinking_delta") && + !sawThinking + ) { + sawThinking = true; + process.stderr.write("[forge] thinking...\n"); + return; + } + if ( + (streamType === "text_start" || streamType === "text_delta") && + !sawText + ) { + sawText = true; + process.stderr.write("[forge] writing response...\n"); + } + }; +}