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 f1cb7ac..0f3f619 100644 --- a/packages/commands/src/commands/managed-agent/_engine/session-render.ts +++ b/packages/commands/src/commands/managed-agent/_engine/session-render.ts @@ -4,6 +4,7 @@ import { type ProviderSessionEvent, } from "@openagentpack/sdk"; import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events"; +import { BailianError, ExitCode } from "bailian-cli-core"; /** Skip user echo + thinking noise in live rendering (mirrors OpenAgentPack CLI). */ function shouldRenderLiveEvent(event: ProviderSessionEvent): boolean { @@ -15,6 +16,19 @@ function renderTerminalStatus(status: string, json: boolean): void { process.stderr.write(`\n[session ${status}]\n`); } +function findLastSessionError(events: readonly ProviderSessionEvent[]): string | undefined { + for (let index = events.length - 1; index >= 0; index--) { + const event = events[index]; + if (event?.type === "error" && event.content?.trim()) return event.content; + } + return undefined; +} + +function throwIfSessionFailed(status: string | undefined, message?: string): void { + if (status !== "failed") return; + throw new BailianError(message ?? "Session failed.", ExitCode.GENERAL); +} + /** * Session identity echoed at the head of the `--output json` envelope so * callers can read the (possibly just-created) session id from stdout and @@ -40,10 +54,14 @@ export async function streamAndRenderEvents( context: SessionRenderContext = {}, ): Promise { const collected: ProviderSessionEvent[] = []; + let terminalStatus: string | undefined; + let errorMessage: string | undefined; for await (const event of events) { if (json) collected.push(event); else renderEvent(event); + if (event.type === "error" && event.content?.trim()) errorMessage = event.content; if (event.type === "status" && isTerminalSessionStatus(event.status)) { + terminalStatus = event.status; renderTerminalStatus(event.status ?? "", json); break; } @@ -53,6 +71,7 @@ export async function streamAndRenderEvents( `${JSON.stringify({ ...context, events: sanitizeSessionEvents(collected) }, null, 2)}\n`, ); } + throwIfSessionFailed(terminalStatus, errorMessage); } /** Assistant text → stdout (data channel); everything else → stderr (diagnostics). */ @@ -92,10 +111,11 @@ export function renderCollectedEvents( 2, )}\n`, ); - return; + } else { + for (const event of result.result.events) renderEvent(event); + renderTerminalStatus(result.terminalStatus, json); } - for (const event of result.result.events) renderEvent(event); - renderTerminalStatus(result.terminalStatus, json); + throwIfSessionFailed(result.terminalStatus, findLastSessionError(result.result.events)); } /** Split a comma-separated --memory-stores value. */ diff --git a/packages/commands/src/commands/managed-agent/destroy.ts b/packages/commands/src/commands/managed-agent/destroy.ts index 1732067..4c43f8e 100644 --- a/packages/commands/src/commands/managed-agent/destroy.ts +++ b/packages/commands/src/commands/managed-agent/destroy.ts @@ -98,8 +98,16 @@ export default defineCommand({ if (format === "json") { emitResult({ destroyed: result.destroyed, total: result.resources.length }, format); } else { - emitBare( - `\nDestroy complete. ${result.destroyed}/${result.resources.length} resources removed.`, + const status = result.partial ? "Destroy incomplete" : "Destroy complete"; + emitBare(`\n${status}. ${result.destroyed}/${result.resources.length} resources removed.`); + } + + if (result.partial) { + const firstFailure = result.results.find((item) => item.status !== "success"); + throw new BailianError( + firstFailure?.error || + `Destroy incomplete: ${result.destroyed}/${result.resources.length} resources removed.`, + ExitCode.GENERAL, ); } }, diff --git a/packages/commands/tests/destroy-result.test.ts b/packages/commands/tests/destroy-result.test.ts new file mode 100644 index 0000000..8a37e4b --- /dev/null +++ b/packages/commands/tests/destroy-result.test.ts @@ -0,0 +1,92 @@ +import { BailianError, ExitCode } from "bailian-cli-core"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; + +const sdkMocks = vi.hoisted(() => ({ + destroyPlannedProjectResources: vi.fn(), + planDestroyProjectContext: vi.fn(), +})); + +const configLoaderMocks = vi.hoisted(() => ({ + buildAgentRuntime: vi.fn(), +})); + +vi.mock("@openagentpack/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...sdkMocks }; +}); + +vi.mock("../src/commands/managed-agent/_engine/config-loader.ts", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ...configLoaderMocks }; +}); + +import destroyCommand from "../src/commands/managed-agent/destroy.ts"; + +const resources = [ + { + address: { provider: "bailian", type: "agent", name: "assistant" }, + remote_id: "agent-ok", + }, + { + address: { provider: "bailian", type: "environment", name: "dev" }, + remote_id: "env-failed", + }, +]; + +let stdoutChunks: string[] = []; +let originalStdoutWrite: typeof process.stdout.write; +let originalStderrWrite: typeof process.stderr.write; + +beforeEach(() => { + stdoutChunks = []; + originalStdoutWrite = process.stdout.write.bind(process.stdout); + originalStderrWrite = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + + const planned = { resources, executionContext: {} }; + configLoaderMocks.buildAgentRuntime.mockResolvedValue({}); + sdkMocks.planDestroyProjectContext.mockReturnValue(planned); + sdkMocks.destroyPlannedProjectResources.mockResolvedValue({ + ...planned, + results: [ + { resource: resources[0], status: "success", reason: "destroyed" }, + { + resource: resources[1], + status: "failed", + reason: "failed", + error: "provider refused deletion", + }, + ], + destroyed: 1, + partial: true, + }); +}); + +afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + vi.clearAllMocks(); +}); + +test("destroy 部分失败时输出汇总并以首个原始错误抛 GENERAL", async () => { + let thrown: unknown; + try { + await destroyCommand.run({ + settings: { output: "json", dryRun: false }, + flags: { yes: true }, + } as never); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(BailianError); + const mapped = thrown as BailianError; + expect(mapped.exitCode).toBe(ExitCode.GENERAL); + expect(mapped.message).toBe("provider refused deletion"); + expect(JSON.parse(stdoutChunks.join(""))).toEqual({ destroyed: 1, total: 2 }); +}); diff --git a/packages/commands/tests/session-render.test.ts b/packages/commands/tests/session-render.test.ts index 01f7a73..5b41449 100644 --- a/packages/commands/tests/session-render.test.ts +++ b/packages/commands/tests/session-render.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "vite-plus/test"; import type { CollectedSessionEvents, ProviderSessionEvent } from "@openagentpack/sdk"; +import { BailianError, ExitCode } from "bailian-cli-core"; import { renderCollectedEvents, streamAndRenderEvents, @@ -41,6 +42,14 @@ async function* fakeEventStream(): AsyncIterable { yield { type: "status", status: "completed" } as ProviderSessionEvent; } +async function* fakeFailedEventStream(): AsyncIterable { + yield { + type: "error", + content: "provider quota exceeded", + } as ProviderSessionEvent; + yield { type: "status", status: "failed" } as ProviderSessionEvent; +} + function fakeCollected(): CollectedSessionEvents { return { terminalStatus: "completed", @@ -58,6 +67,32 @@ function fakeCollected(): CollectedSessionEvents { } as CollectedSessionEvents; } +function fakeFailedCollected(): CollectedSessionEvents { + return { + terminalStatus: "failed", + result: { + events: [ + { + type: "error", + content: "provider quota exceeded", + } as ProviderSessionEvent, + ], + has_more: false, + next_page: undefined, + }, + } as CollectedSessionEvents; +} + +async function catchSessionFailure(run: () => Promise | void): Promise { + try { + await run(); + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + return error as BailianError; + } + throw new Error("expected failed session to throw"); +} + test("stream json:信封携带 session_id/provider/agent + events", async () => { await streamAndRenderEvents(fakeEventStream(), true, { session_id: "sess_stream", @@ -72,6 +107,22 @@ test("stream json:信封携带 session_id/provider/agent + events", async () => expect((data.events as unknown[]).length).toBe(2); }); +test("streaming failed:保留 JSON 信封并以服务端 error 消息抛 GENERAL", async () => { + const error = await catchSessionFailure(() => + streamAndRenderEvents(fakeFailedEventStream(), true, { + session_id: "sess_failed_stream", + provider: "bailian", + agent: "assistant", + }), + ); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toBe("provider quota exceeded"); + + const data = capturedJson(); + expect(data.session_id).toBe("sess_failed_stream"); + 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", @@ -86,6 +137,22 @@ test("polling json:信封携带 session_id/provider/agent,并保留 has_more/n expect(Array.isArray(data.events)).toBe(true); }); +test("polling failed:保留 JSON 信封并以服务端 error 消息抛 GENERAL", async () => { + const error = await catchSessionFailure(() => + renderCollectedEvents(fakeFailedCollected(), true, { + session_id: "sess_failed_poll", + provider: "claude", + agent: "assistant", + }), + ); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toBe("provider quota exceeded"); + + const data = capturedJson(); + expect(data.session_id).toBe("sess_failed_poll"); + expect((data.events as unknown[]).length).toBe(1); +}); + test("json:不传 context 时信封形状不变(无 session_id 键)", () => { renderCollectedEvents(fakeCollected(), true); const data = capturedJson();