Skip to content

Commit 2dce9fe

Browse files
committed
feat(agent): session and destroy failed error
1 parent 9819eb6 commit 2dce9fe

4 files changed

Lines changed: 192 additions & 5 deletions

File tree

packages/commands/src/commands/managed-agent/_engine/session-render.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ProviderSessionEvent,
55
} from "@openagentpack/sdk";
66
import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events";
7+
import { BailianError, ExitCode } from "bailian-cli-core";
78

89
/** Skip user echo + thinking noise in live rendering (mirrors OpenAgentPack CLI). */
910
function shouldRenderLiveEvent(event: ProviderSessionEvent): boolean {
@@ -15,6 +16,19 @@ function renderTerminalStatus(status: string, json: boolean): void {
1516
process.stderr.write(`\n[session ${status}]\n`);
1617
}
1718

19+
function findLastSessionError(events: readonly ProviderSessionEvent[]): string | undefined {
20+
for (let index = events.length - 1; index >= 0; index--) {
21+
const event = events[index];
22+
if (event?.type === "error" && event.content?.trim()) return event.content;
23+
}
24+
return undefined;
25+
}
26+
27+
function throwIfSessionFailed(status: string | undefined, message?: string): void {
28+
if (status !== "failed") return;
29+
throw new BailianError(message ?? "Session failed.", ExitCode.GENERAL);
30+
}
31+
1832
/**
1933
* Session identity echoed at the head of the `--output json` envelope so
2034
* callers can read the (possibly just-created) session id from stdout and
@@ -40,10 +54,14 @@ export async function streamAndRenderEvents(
4054
context: SessionRenderContext = {},
4155
): Promise<void> {
4256
const collected: ProviderSessionEvent[] = [];
57+
let terminalStatus: string | undefined;
58+
let errorMessage: string | undefined;
4359
for await (const event of events) {
4460
if (json) collected.push(event);
4561
else renderEvent(event);
62+
if (event.type === "error" && event.content?.trim()) errorMessage = event.content;
4663
if (event.type === "status" && isTerminalSessionStatus(event.status)) {
64+
terminalStatus = event.status;
4765
renderTerminalStatus(event.status ?? "", json);
4866
break;
4967
}
@@ -53,6 +71,7 @@ export async function streamAndRenderEvents(
5371
`${JSON.stringify({ ...context, events: sanitizeSessionEvents(collected) }, null, 2)}\n`,
5472
);
5573
}
74+
throwIfSessionFailed(terminalStatus, errorMessage);
5675
}
5776

5877
/** Assistant text → stdout (data channel); everything else → stderr (diagnostics). */
@@ -92,10 +111,11 @@ export function renderCollectedEvents(
92111
2,
93112
)}\n`,
94113
);
95-
return;
114+
} else {
115+
for (const event of result.result.events) renderEvent(event);
116+
renderTerminalStatus(result.terminalStatus, json);
96117
}
97-
for (const event of result.result.events) renderEvent(event);
98-
renderTerminalStatus(result.terminalStatus, json);
118+
throwIfSessionFailed(result.terminalStatus, findLastSessionError(result.result.events));
99119
}
100120

101121
/** Split a comma-separated --memory-stores value. */

packages/commands/src/commands/managed-agent/destroy.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,16 @@ export default defineCommand({
9898
if (format === "json") {
9999
emitResult({ destroyed: result.destroyed, total: result.resources.length }, format);
100100
} else {
101-
emitBare(
102-
`\nDestroy complete. ${result.destroyed}/${result.resources.length} resources removed.`,
101+
const status = result.partial ? "Destroy incomplete" : "Destroy complete";
102+
emitBare(`\n${status}. ${result.destroyed}/${result.resources.length} resources removed.`);
103+
}
104+
105+
if (result.partial) {
106+
const firstFailure = result.results.find((item) => item.status !== "success");
107+
throw new BailianError(
108+
firstFailure?.error ||
109+
`Destroy incomplete: ${result.destroyed}/${result.resources.length} resources removed.`,
110+
ExitCode.GENERAL,
103111
);
104112
}
105113
},
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { BailianError, ExitCode } from "bailian-cli-core";
2+
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
3+
4+
const sdkMocks = vi.hoisted(() => ({
5+
destroyPlannedProjectResources: vi.fn(),
6+
planDestroyProjectContext: vi.fn(),
7+
}));
8+
9+
const configLoaderMocks = vi.hoisted(() => ({
10+
buildAgentRuntime: vi.fn(),
11+
}));
12+
13+
vi.mock("@openagentpack/sdk", async (importOriginal) => {
14+
const actual = await importOriginal<typeof import("@openagentpack/sdk")>();
15+
return { ...actual, ...sdkMocks };
16+
});
17+
18+
vi.mock("../src/commands/managed-agent/_engine/config-loader.ts", async (importOriginal) => {
19+
const actual =
20+
await importOriginal<typeof import("../src/commands/managed-agent/_engine/config-loader.ts")>();
21+
return { ...actual, ...configLoaderMocks };
22+
});
23+
24+
import destroyCommand from "../src/commands/managed-agent/destroy.ts";
25+
26+
const resources = [
27+
{
28+
address: { provider: "bailian", type: "agent", name: "assistant" },
29+
remote_id: "agent-ok",
30+
},
31+
{
32+
address: { provider: "bailian", type: "environment", name: "dev" },
33+
remote_id: "env-failed",
34+
},
35+
];
36+
37+
let stdoutChunks: string[] = [];
38+
let originalStdoutWrite: typeof process.stdout.write;
39+
let originalStderrWrite: typeof process.stderr.write;
40+
41+
beforeEach(() => {
42+
stdoutChunks = [];
43+
originalStdoutWrite = process.stdout.write.bind(process.stdout);
44+
originalStderrWrite = process.stderr.write.bind(process.stderr);
45+
process.stdout.write = ((chunk: string | Uint8Array) => {
46+
stdoutChunks.push(String(chunk));
47+
return true;
48+
}) as typeof process.stdout.write;
49+
process.stderr.write = (() => true) as typeof process.stderr.write;
50+
51+
const planned = { resources, executionContext: {} };
52+
configLoaderMocks.buildAgentRuntime.mockResolvedValue({});
53+
sdkMocks.planDestroyProjectContext.mockReturnValue(planned);
54+
sdkMocks.destroyPlannedProjectResources.mockResolvedValue({
55+
...planned,
56+
results: [
57+
{ resource: resources[0], status: "success", reason: "destroyed" },
58+
{
59+
resource: resources[1],
60+
status: "failed",
61+
reason: "failed",
62+
error: "provider refused deletion",
63+
},
64+
],
65+
destroyed: 1,
66+
partial: true,
67+
});
68+
});
69+
70+
afterEach(() => {
71+
process.stdout.write = originalStdoutWrite;
72+
process.stderr.write = originalStderrWrite;
73+
vi.clearAllMocks();
74+
});
75+
76+
test("destroy 部分失败时输出汇总并以首个原始错误抛 GENERAL", async () => {
77+
let thrown: unknown;
78+
try {
79+
await destroyCommand.run({
80+
settings: { output: "json", dryRun: false },
81+
flags: { yes: true },
82+
} as never);
83+
} catch (error) {
84+
thrown = error;
85+
}
86+
87+
expect(thrown).toBeInstanceOf(BailianError);
88+
const mapped = thrown as BailianError;
89+
expect(mapped.exitCode).toBe(ExitCode.GENERAL);
90+
expect(mapped.message).toBe("provider refused deletion");
91+
expect(JSON.parse(stdoutChunks.join(""))).toEqual({ destroyed: 1, total: 2 });
92+
});

packages/commands/tests/session-render.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
22
import type { CollectedSessionEvents, ProviderSessionEvent } from "@openagentpack/sdk";
3+
import { BailianError, ExitCode } from "bailian-cli-core";
34
import {
45
renderCollectedEvents,
56
streamAndRenderEvents,
@@ -41,6 +42,14 @@ async function* fakeEventStream(): AsyncIterable<ProviderSessionEvent> {
4142
yield { type: "status", status: "completed" } as ProviderSessionEvent;
4243
}
4344

45+
async function* fakeFailedEventStream(): AsyncIterable<ProviderSessionEvent> {
46+
yield {
47+
type: "error",
48+
content: "provider quota exceeded",
49+
} as ProviderSessionEvent;
50+
yield { type: "status", status: "failed" } as ProviderSessionEvent;
51+
}
52+
4453
function fakeCollected(): CollectedSessionEvents {
4554
return {
4655
terminalStatus: "completed",
@@ -58,6 +67,32 @@ function fakeCollected(): CollectedSessionEvents {
5867
} as CollectedSessionEvents;
5968
}
6069

70+
function fakeFailedCollected(): CollectedSessionEvents {
71+
return {
72+
terminalStatus: "failed",
73+
result: {
74+
events: [
75+
{
76+
type: "error",
77+
content: "provider quota exceeded",
78+
} as ProviderSessionEvent,
79+
],
80+
has_more: false,
81+
next_page: undefined,
82+
},
83+
} as CollectedSessionEvents;
84+
}
85+
86+
async function catchSessionFailure(run: () => Promise<void> | void): Promise<BailianError> {
87+
try {
88+
await run();
89+
} catch (error) {
90+
expect(error).toBeInstanceOf(BailianError);
91+
return error as BailianError;
92+
}
93+
throw new Error("expected failed session to throw");
94+
}
95+
6196
test("stream json:信封携带 session_id/provider/agent + events", async () => {
6297
await streamAndRenderEvents(fakeEventStream(), true, {
6398
session_id: "sess_stream",
@@ -72,6 +107,22 @@ test("stream json:信封携带 session_id/provider/agent + events", async () =>
72107
expect((data.events as unknown[]).length).toBe(2);
73108
});
74109

110+
test("streaming failed:保留 JSON 信封并以服务端 error 消息抛 GENERAL", async () => {
111+
const error = await catchSessionFailure(() =>
112+
streamAndRenderEvents(fakeFailedEventStream(), true, {
113+
session_id: "sess_failed_stream",
114+
provider: "bailian",
115+
agent: "assistant",
116+
}),
117+
);
118+
expect(error.exitCode).toBe(ExitCode.GENERAL);
119+
expect(error.message).toBe("provider quota exceeded");
120+
121+
const data = capturedJson();
122+
expect(data.session_id).toBe("sess_failed_stream");
123+
expect((data.events as unknown[]).length).toBe(2);
124+
});
125+
75126
test("polling json:信封携带 session_id/provider/agent,并保留 has_more/next_page", () => {
76127
renderCollectedEvents(fakeCollected(), true, {
77128
session_id: "sess_poll",
@@ -86,6 +137,22 @@ test("polling json:信封携带 session_id/provider/agent,并保留 has_more/n
86137
expect(Array.isArray(data.events)).toBe(true);
87138
});
88139

140+
test("polling failed:保留 JSON 信封并以服务端 error 消息抛 GENERAL", async () => {
141+
const error = await catchSessionFailure(() =>
142+
renderCollectedEvents(fakeFailedCollected(), true, {
143+
session_id: "sess_failed_poll",
144+
provider: "claude",
145+
agent: "assistant",
146+
}),
147+
);
148+
expect(error.exitCode).toBe(ExitCode.GENERAL);
149+
expect(error.message).toBe("provider quota exceeded");
150+
151+
const data = capturedJson();
152+
expect(data.session_id).toBe("sess_failed_poll");
153+
expect((data.events as unknown[]).length).toBe(1);
154+
});
155+
89156
test("json:不传 context 时信封形状不变(无 session_id 键)", () => {
90157
renderCollectedEvents(fakeCollected(), true);
91158
const data = capturedJson();

0 commit comments

Comments
 (0)