feat(agent): session and destroy failed error

This commit is contained in:
chenanran555
2026-07-27 21:52:52 +08:00
parent 9819eb6ddc
commit 2dce9fe093
4 changed files with 192 additions and 5 deletions
@@ -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<void> {
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. */
@@ -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,
);
}
},
@@ -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<typeof import("@openagentpack/sdk")>();
return { ...actual, ...sdkMocks };
});
vi.mock("../src/commands/managed-agent/_engine/config-loader.ts", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/commands/managed-agent/_engine/config-loader.ts")>();
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 });
});
@@ -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<ProviderSessionEvent> {
yield { type: "status", status: "completed" } as ProviderSessionEvent;
}
async function* fakeFailedEventStream(): AsyncIterable<ProviderSessionEvent> {
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> | void): Promise<BailianError> {
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();