Skip to content

Commit 1e6165d

Browse files
committed
fix(agent): guarantee single valid JSON on stdout for --output json
1 parent 1bf4fec commit 1e6165d

3 files changed

Lines changed: 48 additions & 28 deletions

File tree

packages/commands/src/commands/managed-agent/_engine/session-render.ts

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,42 @@ import {
33
isTerminalSessionStatus,
44
type ProviderSessionEvent,
55
} from "@openagentpack/sdk";
6-
import { sanitizeSessionEvent, sanitizeSessionEvents } from "@openagentpack/sdk/session-events";
6+
import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events";
77

88
/** Skip user echo + thinking noise in live rendering (mirrors OpenAgentPack CLI). */
99
function shouldRenderLiveEvent(event: ProviderSessionEvent): boolean {
1010
return event.type !== "thinking" && !(event.type === "message" && event.role === "user");
1111
}
1212

13-
function writeJsonLine(value: unknown): void {
14-
process.stdout.write(`${JSON.stringify(value)}\n`);
13+
function renderTerminalStatus(status: string, json: boolean): void {
14+
if (json) return;
15+
process.stderr.write(`\n[session ${status}]\n`);
16+
}
17+
18+
/**
19+
* Consume an SSE stream. Text mode renders live (assistant text → stdout,
20+
* diagnostics → stderr). JSON mode collects every event and emits exactly one
21+
* JSON document at the end — `--output json` guarantees a single valid JSON
22+
* result on stdout (mirrors `text chat --stream --output json`).
23+
*/
24+
export async function streamAndRenderEvents(
25+
events: AsyncIterable<ProviderSessionEvent>,
26+
json: boolean,
27+
): Promise<void> {
28+
const collected: ProviderSessionEvent[] = [];
29+
for await (const event of events) {
30+
if (json) collected.push(event);
31+
else renderEvent(event);
32+
if (event.type === "status" && isTerminalSessionStatus(event.status)) {
33+
renderTerminalStatus(event.status ?? "", json);
34+
break;
35+
}
36+
}
37+
if (json) {
38+
process.stdout.write(
39+
`${JSON.stringify({ events: sanitizeSessionEvents(collected) }, null, 2)}\n`,
40+
);
41+
}
1542
}
1643

1744
/** Assistant text → stdout (data channel); everything else → stderr (diagnostics). */
@@ -32,26 +59,6 @@ function renderEvent(event: ProviderSessionEvent): void {
3259
}
3360
}
3461

35-
function renderTerminalStatus(status: string, json: boolean): void {
36-
if (json) return;
37-
process.stderr.write(`\n[session ${status}]\n`);
38-
}
39-
40-
/** Consume an SSE stream, rendering live (text) or as JSONL (json). */
41-
export async function streamAndRenderEvents(
42-
events: AsyncIterable<ProviderSessionEvent>,
43-
json: boolean,
44-
): Promise<void> {
45-
for await (const event of events) {
46-
if (json) writeJsonLine(sanitizeSessionEvent(event));
47-
else renderEvent(event);
48-
if (event.type === "status" && isTerminalSessionStatus(event.status)) {
49-
renderTerminalStatus(event.status ?? "", json);
50-
break;
51-
}
52-
}
53-
}
54-
5562
/** Render a polled (non-streaming) collected result. */
5663
export function renderCollectedEvents(result: CollectedSessionEvents, json: boolean): void {
5764
if (json) {

packages/commands/src/commands/managed-agent/apply.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,24 @@ export default defineCommand({
8585
);
8686

8787
const plan = planned.plan;
88+
// In --output json, stdout must stay a single-JSON data channel: diagnostics
89+
// and the action preview are progress info → stderr; text mode keeps stdout.
90+
const emitProgress = (line: string): void => {
91+
if (format === "json") process.stderr.write(`${line}\n`);
92+
else emitBare(line);
93+
};
8894
if (plan.diagnostics.some((diag) => diag.severity === "error")) {
8995
for (const diag of plan.diagnostics) {
90-
if (diag.severity === "error") emitBare(`[error] ${diag.code}: ${diag.message}`);
96+
if (diag.severity === "error") emitProgress(`[error] ${diag.code}: ${diag.message}`);
9197
}
9298
throw new BailianError("Cannot apply: resolve the errors above first.", ExitCode.GENERAL);
9399
}
94100

95101
const actionable = plan.actions.filter((action) => action.action !== "no-op");
96102
if (actionable.length === 0) {
97-
emitBare("No changes. Infrastructure is up-to-date.");
103+
if (format === "json")
104+
emitResult({ succeeded: 0, failed: 0, skipped: 0, results: [] }, format);
105+
else emitBare("No changes. Infrastructure is up-to-date.");
98106
return;
99107
}
100108

@@ -104,7 +112,7 @@ export default defineCommand({
104112

105113
for (const action of actionable) {
106114
const icon = action.action === "create" ? "+" : action.action === "update" ? "~" : "-";
107-
emitBare(` ${icon} ${formatResourceLabel(action.address)}`);
115+
emitProgress(` ${icon} ${formatResourceLabel(action.address)}`);
108116
}
109117

110118
if (!flags.yes) {

packages/commands/src/commands/managed-agent/destroy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,17 @@ export default defineCommand({
6161

6262
const resources = planned.resources;
6363
if (resources.length === 0) {
64-
emitBare("No resources in state. Nothing to destroy.");
64+
if (format === "json") emitResult({ destroyed: 0, total: 0 }, format);
65+
else emitBare("No resources in state. Nothing to destroy.");
6566
return;
6667
}
6768

69+
// In --output json, stdout must stay a single-JSON data channel: the
70+
// resource preview is progress info → stderr; text mode keeps stdout.
6871
for (const resource of resources) {
69-
emitBare(` - ${formatResourceLabel(resource.address)} [${resource.remote_id}]`);
72+
const line = ` - ${formatResourceLabel(resource.address)} [${resource.remote_id}]`;
73+
if (format === "json") process.stderr.write(`${line}\n`);
74+
else emitBare(line);
7075
}
7176

7277
if (!flags.yes) {

0 commit comments

Comments
 (0)