diff --git a/packages/commands/src/commands/managed-agent/_engine/session-render.ts b/packages/commands/src/commands/managed-agent/_engine/session-render.ts index 0b08da0..78c6e12 100644 --- a/packages/commands/src/commands/managed-agent/_engine/session-render.ts +++ b/packages/commands/src/commands/managed-agent/_engine/session-render.ts @@ -3,15 +3,42 @@ import { isTerminalSessionStatus, type ProviderSessionEvent, } from "@openagentpack/sdk"; -import { sanitizeSessionEvent, sanitizeSessionEvents } from "@openagentpack/sdk/session-events"; +import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events"; /** Skip user echo + thinking noise in live rendering (mirrors OpenAgentPack CLI). */ function shouldRenderLiveEvent(event: ProviderSessionEvent): boolean { return event.type !== "thinking" && !(event.type === "message" && event.role === "user"); } -function writeJsonLine(value: unknown): void { - process.stdout.write(`${JSON.stringify(value)}\n`); +function renderTerminalStatus(status: string, json: boolean): void { + if (json) return; + process.stderr.write(`\n[session ${status}]\n`); +} + +/** + * Consume an SSE stream. Text mode renders live (assistant text → stdout, + * diagnostics → stderr). JSON mode collects every event and emits exactly one + * JSON document at the end — `--output json` guarantees a single valid JSON + * result on stdout (mirrors `text chat --stream --output json`). + */ +export async function streamAndRenderEvents( + events: AsyncIterable, + json: boolean, +): Promise { + const collected: ProviderSessionEvent[] = []; + for await (const event of events) { + if (json) collected.push(event); + else renderEvent(event); + if (event.type === "status" && isTerminalSessionStatus(event.status)) { + renderTerminalStatus(event.status ?? "", json); + break; + } + } + if (json) { + process.stdout.write( + `${JSON.stringify({ events: sanitizeSessionEvents(collected) }, null, 2)}\n`, + ); + } } /** Assistant text → stdout (data channel); everything else → stderr (diagnostics). */ @@ -32,26 +59,6 @@ function renderEvent(event: ProviderSessionEvent): void { } } -function renderTerminalStatus(status: string, json: boolean): void { - if (json) return; - process.stderr.write(`\n[session ${status}]\n`); -} - -/** Consume an SSE stream, rendering live (text) or as JSONL (json). */ -export async function streamAndRenderEvents( - events: AsyncIterable, - json: boolean, -): Promise { - for await (const event of events) { - if (json) writeJsonLine(sanitizeSessionEvent(event)); - else renderEvent(event); - if (event.type === "status" && isTerminalSessionStatus(event.status)) { - renderTerminalStatus(event.status ?? "", json); - break; - } - } -} - /** Render a polled (non-streaming) collected result. */ export function renderCollectedEvents(result: CollectedSessionEvents, json: boolean): void { if (json) { diff --git a/packages/commands/src/commands/managed-agent/apply.ts b/packages/commands/src/commands/managed-agent/apply.ts index a10899b..a00a166 100644 --- a/packages/commands/src/commands/managed-agent/apply.ts +++ b/packages/commands/src/commands/managed-agent/apply.ts @@ -85,16 +85,24 @@ export default defineCommand({ ); const plan = planned.plan; + // In --output json, stdout must stay a single-JSON data channel: diagnostics + // and the action preview are progress info → stderr; text mode keeps stdout. + const emitProgress = (line: string): void => { + if (format === "json") process.stderr.write(`${line}\n`); + else emitBare(line); + }; if (plan.diagnostics.some((diag) => diag.severity === "error")) { for (const diag of plan.diagnostics) { - if (diag.severity === "error") emitBare(`[error] ${diag.code}: ${diag.message}`); + if (diag.severity === "error") emitProgress(`[error] ${diag.code}: ${diag.message}`); } throw new BailianError("Cannot apply: resolve the errors above first.", ExitCode.GENERAL); } const actionable = plan.actions.filter((action) => action.action !== "no-op"); if (actionable.length === 0) { - emitBare("No changes. Infrastructure is up-to-date."); + if (format === "json") + emitResult({ succeeded: 0, failed: 0, skipped: 0, results: [] }, format); + else emitBare("No changes. Infrastructure is up-to-date."); return; } @@ -104,7 +112,7 @@ export default defineCommand({ for (const action of actionable) { const icon = action.action === "create" ? "+" : action.action === "update" ? "~" : "-"; - emitBare(` ${icon} ${formatResourceLabel(action.address)}`); + emitProgress(` ${icon} ${formatResourceLabel(action.address)}`); } if (!flags.yes) { diff --git a/packages/commands/src/commands/managed-agent/destroy.ts b/packages/commands/src/commands/managed-agent/destroy.ts index 5261e8e..1732067 100644 --- a/packages/commands/src/commands/managed-agent/destroy.ts +++ b/packages/commands/src/commands/managed-agent/destroy.ts @@ -61,12 +61,17 @@ export default defineCommand({ const resources = planned.resources; if (resources.length === 0) { - emitBare("No resources in state. Nothing to destroy."); + if (format === "json") emitResult({ destroyed: 0, total: 0 }, format); + else emitBare("No resources in state. Nothing to destroy."); return; } + // In --output json, stdout must stay a single-JSON data channel: the + // resource preview is progress info → stderr; text mode keeps stdout. for (const resource of resources) { - emitBare(` - ${formatResourceLabel(resource.address)} [${resource.remote_id}]`); + const line = ` - ${formatResourceLabel(resource.address)} [${resource.remote_id}]`; + if (format === "json") process.stderr.write(`${line}\n`); + else emitBare(line); } if (!flags.yes) {