mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix(agent): guarantee single valid JSON on stdout for --output json
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user