fix(agent): guarantee single valid JSON on stdout for --output json

This commit is contained in:
chenanran555
2026-07-24 16:25:09 +08:00
parent 1bf4fec9e6
commit 1e6165d7ff
3 changed files with 48 additions and 28 deletions
@@ -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<ProviderSessionEvent>,
json: boolean,
): Promise<void> {
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<ProviderSessionEvent>,
json: boolean,
): Promise<void> {
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) {
@@ -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) {
@@ -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) {