fix(agent): session output json with session_id

This commit is contained in:
chenanran555
2026-07-27 20:10:47 +08:00
parent 63ee5aaec3
commit 05860b3bdd
5 changed files with 147 additions and 9 deletions
@@ -15,15 +15,29 @@ function renderTerminalStatus(status: string, json: boolean): void {
process.stderr.write(`\n[session ${status}]\n`);
}
/**
* Session identity echoed at the head of the `--output json` envelope so
* callers can read the (possibly just-created) session id from stdout and
* chain `session send/get/events/delete` — without scraping stderr.
* Undefined fields are dropped by JSON.stringify.
*/
export interface SessionRenderContext {
session_id?: string;
provider?: string;
agent?: string;
}
/**
* 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`).
* result on stdout (mirrors `text chat --stream --output json`). `context`
* prefixes the envelope with the session identity.
*/
export async function streamAndRenderEvents(
events: AsyncIterable<ProviderSessionEvent>,
json: boolean,
context: SessionRenderContext = {},
): Promise<void> {
const collected: ProviderSessionEvent[] = [];
for await (const event of events) {
@@ -36,7 +50,7 @@ export async function streamAndRenderEvents(
}
if (json) {
process.stdout.write(
`${JSON.stringify({ events: sanitizeSessionEvents(collected) }, null, 2)}\n`,
`${JSON.stringify({ ...context, events: sanitizeSessionEvents(collected) }, null, 2)}\n`,
);
}
}
@@ -59,12 +73,17 @@ function renderEvent(event: ProviderSessionEvent): void {
}
}
/** Render a polled (non-streaming) collected result. */
export function renderCollectedEvents(result: CollectedSessionEvents, json: boolean): void {
/** Render a polled (non-streaming) collected result. `context` prefixes the JSON envelope. */
export function renderCollectedEvents(
result: CollectedSessionEvents,
json: boolean,
context: SessionRenderContext = {},
): void {
if (json) {
process.stdout.write(
`${JSON.stringify(
{
...context,
events: sanitizeSessionEvents(result.result.events),
has_more: result.result.has_more,
next_page: result.result.next_page,
@@ -62,7 +62,10 @@ export default defineCommand({
usageArgs: "--prompt <text> [--agent <name>] [--no-stream] [--file <path>]",
flags: SESSION_RUN_FLAGS,
exampleArgs: ['--prompt "hello"', '--agent assistant --prompt "summarize this repo"'],
notes: CREDENTIALS_NOTE,
notes: [
...CREDENTIALS_NOTE,
"--output json emits one envelope: { session_id, provider, agent, events } — read session_id to chain `session send/get/events/delete`.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
@@ -106,11 +109,19 @@ export default defineCommand({
if (flags.noStream) {
const run = await startSessionRunPolling(runtime, flags.prompt, runOptions);
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
renderCollectedEvents(run, asJson);
renderCollectedEvents(run, asJson, {
session_id: run.session.id,
provider: run.provider,
agent: run.agentName,
});
} else {
const run = await startSessionRun(runtime, flags.prompt, runOptions);
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
await streamAndRenderEvents(run.events, asJson);
await streamAndRenderEvents(run.events, asJson, {
session_id: run.session.id,
provider: run.provider,
agent: run.agentName,
});
}
}),
);
@@ -75,7 +75,9 @@ export default defineCommand({
const result = await sendSessionMessagePolling(runtime, flags.sessionId, flags.message, {
provider: flags.provider,
});
renderCollectedEvents(result, asJson);
renderCollectedEvents(result, asJson, {
session_id: flags.sessionId,
});
} else {
const events = await sendSessionMessageStreaming(
runtime,
@@ -85,7 +87,9 @@ export default defineCommand({
provider: flags.provider,
},
);
await streamAndRenderEvents(events, asJson);
await streamAndRenderEvents(events, asJson, {
session_id: flags.sessionId,
});
}
}),
);
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
import type { CollectedSessionEvents, ProviderSessionEvent } from "@openagentpack/sdk";
import {
renderCollectedEvents,
streamAndRenderEvents,
} from "../src/commands/managed-agent/_engine/session-render.ts";
/**
* `--output json` 会话信封契约:stdout 恰好一个合法 JSON,且信封头部携带
* session_id / provider / agent —— session run 的调用方必须能从 stdout 拿到
* 新建 Session ID 以继续 send/get/events/delete(不靠刮 stderr)。
*/
let stdoutChunks: string[] = [];
let originalStdoutWrite: typeof process.stdout.write;
beforeEach(() => {
stdoutChunks = [];
originalStdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: string | Uint8Array) => {
stdoutChunks.push(String(chunk));
return true;
}) as typeof process.stdout.write;
});
afterEach(() => {
process.stdout.write = originalStdoutWrite;
});
function capturedJson(): Record<string, unknown> {
// 契约:整个 stdout 拼起来是单个合法 JSON
return JSON.parse(stdoutChunks.join("")) as Record<string, unknown>;
}
async function* fakeEventStream(): AsyncIterable<ProviderSessionEvent> {
yield {
type: "message",
role: "assistant",
content: "hi",
} as ProviderSessionEvent;
yield { type: "status", status: "completed" } as ProviderSessionEvent;
}
function fakeCollected(): CollectedSessionEvents {
return {
terminalStatus: "completed",
result: {
events: [
{
type: "message",
role: "assistant",
content: "hi",
} as ProviderSessionEvent,
],
has_more: false,
next_page: undefined,
},
} as CollectedSessionEvents;
}
test("stream json:信封携带 session_id/provider/agent + events", async () => {
await streamAndRenderEvents(fakeEventStream(), true, {
session_id: "sess_stream",
provider: "bailian",
agent: "assistant",
});
const data = capturedJson();
expect(data.session_id).toBe("sess_stream");
expect(data.provider).toBe("bailian");
expect(data.agent).toBe("assistant");
expect(Array.isArray(data.events)).toBe(true);
expect((data.events as unknown[]).length).toBe(2);
});
test("polling json:信封携带 session_id/provider/agent,并保留 has_more/next_page", () => {
renderCollectedEvents(fakeCollected(), true, {
session_id: "sess_poll",
provider: "claude",
agent: "assistant",
});
const data = capturedJson();
expect(data.session_id).toBe("sess_poll");
expect(data.provider).toBe("claude");
expect(data.agent).toBe("assistant");
expect(data.has_more).toBe(false);
expect(Array.isArray(data.events)).toBe(true);
});
test("json:不传 context 时信封形状不变(无 session_id 键)", () => {
renderCollectedEvents(fakeCollected(), true);
const data = capturedJson();
expect("session_id" in data).toBe(false);
expect(Array.isArray(data.events)).toBe(true);
});
test("text 模式:context 不影响 stdout(仍只输出助手文本)", async () => {
await streamAndRenderEvents(fakeEventStream(), false, {
session_id: "sess_text",
});
const output = stdoutChunks.join("");
expect(output).toBe("hi");
expect(output).not.toContain("sess_text");
});
@@ -385,6 +385,7 @@ bl managed-agent session list --all
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
- --output json emits one envelope: { session_id, provider, agent, events } — read session_id to chain `session send/get/events/delete`.
#### Examples