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 78c6e12..f1cb7ac 100644 --- a/packages/commands/src/commands/managed-agent/_engine/session-render.ts +++ b/packages/commands/src/commands/managed-agent/_engine/session-render.ts @@ -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, json: boolean, + context: SessionRenderContext = {}, ): Promise { 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, diff --git a/packages/commands/src/commands/managed-agent/session-run.ts b/packages/commands/src/commands/managed-agent/session-run.ts index 820ce47..2a6637c 100644 --- a/packages/commands/src/commands/managed-agent/session-run.ts +++ b/packages/commands/src/commands/managed-agent/session-run.ts @@ -62,7 +62,10 @@ export default defineCommand({ usageArgs: "--prompt [--agent ] [--no-stream] [--file ]", 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, + }); } }), ); diff --git a/packages/commands/src/commands/managed-agent/session-send.ts b/packages/commands/src/commands/managed-agent/session-send.ts index c424262..96df4b6 100644 --- a/packages/commands/src/commands/managed-agent/session-send.ts +++ b/packages/commands/src/commands/managed-agent/session-send.ts @@ -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, + }); } }), ); diff --git a/packages/commands/tests/session-render.test.ts b/packages/commands/tests/session-render.test.ts new file mode 100644 index 0000000..01f7a73 --- /dev/null +++ b/packages/commands/tests/session-render.test.ts @@ -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 { + // 契约:整个 stdout 拼起来是单个合法 JSON + return JSON.parse(stdoutChunks.join("")) as Record; +} + +async function* fakeEventStream(): AsyncIterable { + 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"); +}); diff --git a/skills/bailian-cli/reference/managed-agent.md b/skills/bailian-cli/reference/managed-agent.md index 9014686..3724477 100644 --- a/skills/bailian-cli/reference/managed-agent.md +++ b/skills/bailian-cli/reference/managed-agent.md @@ -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