mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(agent): session and destroy failed error
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user