Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

329 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { ClaudeSdkSession, type SdkModule } from "../src/claude-sdk-session";
import { ProviderApiError } from "../src/provider-errors";
/**
* Unit tests for the warm Agent SDK session, driven by a FAKE SdkModule via the
* constructor's injectable `sdkModule` / `zodModule` seam (no real SDK, no real
* `claude` process). Each "turn script" describes what the fake SDK does for one
* turn: optionally invoke the in-process route tool handler (to set a decision),
* optionally stream assistant text, then emit a terminal `result` with a subtype.
*/
interface TurnScript {
/** Never yield a message, used to test the per-turn timeout budget. */
hang?: boolean;
/** ROUTE mode: invoke the captured tool handler with this decision. */
toolCall?: { action: unknown; params?: unknown };
/** Stream this as assistant text before the result. */
text?: string;
/** Terminal result subtype ("success" | "error_max_turns" | ...). */
subtype?: string;
/** The `result` echo string the SDK carries on the terminal message. */
resultText?: string;
/** Omit the terminal `result` message entirely (simulate a mid-turn death). */
noResult?: boolean;
}
type ToolHandler = (args: {
action?: unknown;
params?: unknown;
}) => Promise<{ content: Array<{ type: string; text: string }> }>;
/** Build a fake SdkModule that replays `scripts` turn-by-turn over one warm query. */
function makeFakeSdk(scripts: TurnScript[]): {
sdk: SdkModule;
starts: () => number;
queryOptions: () => Array<Record<string, unknown>>;
} {
let startCount = 0;
const startedOptions: Array<Record<string, unknown>> = [];
// Script progression is GLOBAL across query restarts: a self-heal/restart
// creates a fresh query() but should continue consuming the next scripted
// turn (mirroring a real warm session that gets fresh turns after a restart).
let turn = 0;
const sdk: SdkModule = {
tool: (_name, _desc, _schema, handler) => ({ handler }) as unknown,
createSdkMcpServer: (opts) => ({ tools: opts.tools }) as unknown,
query: ({ options }) => {
startCount += 1;
startedOptions.push(options);
// Reach the route tool handler the session registered (ROUTE mode only).
const servers = options.mcpServers as
| { eliza?: { tools?: Array<{ handler: ToolHandler }> } }
| undefined;
const handler = servers?.eliza?.tools?.[0]?.handler;
async function* gen() {
while (turn < scripts.length) {
const s = scripts[turn++];
if (s.hang) {
await new Promise(() => undefined);
}
if (s.toolCall && handler) {
await handler({ action: s.toolCall.action, params: s.toolCall.params });
}
if (s.text !== undefined) {
yield {
type: "assistant",
message: { content: [{ type: "text", text: s.text }] },
};
}
if (!s.noResult) {
yield {
type: "result",
subtype: s.subtype ?? "success",
result: s.resultText,
};
}
// One script entry == one turn; pause until the next sendAndRead pull.
}
}
const iter = gen();
return {
[Symbol.asyncIterator]: () => iter,
interrupt: async () => {},
} as unknown as ReturnType<SdkModule["query"]>;
},
};
return { sdk, starts: () => startCount, queryOptions: () => startedOptions };
}
const fakeZod = {
z: { string: () => ({}), any: () => ({}), record: () => ({}) },
};
function makeSession(
scripts: TurnScript[],
opts: {
router?: boolean;
restartAfterTurns?: number;
turnTimeoutMs?: number;
subprocessEnv?: Record<string, string | undefined>;
} = {}
) {
const { sdk, starts, queryOptions } = makeFakeSdk(scripts);
const session = new ClaudeSdkSession({
model: "test-model",
systemPrompt: "test system",
router: opts.router ?? false,
restartAfterTurns: opts.restartAfterTurns,
turnTimeoutMs: opts.turnTimeoutMs,
subprocessEnv: opts.subprocessEnv,
sdkModule: sdk,
zodModule: fakeZod,
});
return { session, starts, queryOptions };
}
describe("ClaudeSdkSession — TEXT mode", () => {
it("defaults to the current Opus model when no model is configured", async () => {
const { sdk, queryOptions } = makeFakeSdk([{ text: "hello world", subtype: "success" }]);
const session = new ClaudeSdkSession({
systemPrompt: "test system",
sdkModule: sdk,
zodModule: fakeZod,
});
expect(await session.generate("hi")).toBe("hello world");
expect(queryOptions()[0].model).toBe("claude-opus-4-8");
await session.dispose();
});
it("returns streamed assistant text", async () => {
const { session } = makeSession([{ text: "hello world", subtype: "success" }]);
expect(await session.generate("hi")).toBe("hello world");
await session.dispose();
});
it("passes rotated account env to the Claude SDK query options only", async () => {
const subprocessEnv = { PATH: "/bin", CLAUDE_CODE_OAUTH_TOKEN: "selected-token" };
const { session, queryOptions } = makeSession([{ text: "hello world", subtype: "success" }], {
subprocessEnv,
});
expect(await session.generate("hi")).toBe("hello world");
expect(queryOptions()[0].env).toBe(subprocessEnv);
await session.dispose();
});
it("falls back to result.result only on a clean success turn", async () => {
const { session } = makeSession([{ text: "", subtype: "success", resultText: "the answer" }]);
expect(await session.generate("hi")).toBe("the answer");
await session.dispose();
});
it("THROWS (fails over) on error_max_turns with no streamed text — never returns the SDK meta string", async () => {
const { session } = makeSession([
{ text: "", subtype: "error_max_turns", resultText: "Reached maximum turns" },
]);
await expect(session.generate("hi")).rejects.toThrow(/empty completion/);
await session.dispose();
});
it("THROWS when the generator ends before a result (session died mid-turn)", async () => {
const { session } = makeSession([{ text: "partial", noResult: true }]);
await expect(session.generate("hi")).rejects.toThrow(/session-ended|empty completion/);
await session.dispose();
});
it("THROWS when the subscription-limit envelope is only in result.result", async () => {
const { session } = makeSession([
{
text: "",
subtype: "success",
resultText: "You've hit your session limit · resets 9:30pm (UTC)",
},
]);
await expect(session.generate("hi")).rejects.toThrow(/subscription rate limit reached/);
await session.dispose();
});
it("throws a typed provider error instead of returning streamed API Error text", async () => {
const { session } = makeSession([
{
text: "API Error: 529 Overloaded. This is a server-side issue, check https://status.claude.com.",
subtype: "success",
},
]);
await expect(session.generate("hi")).rejects.toMatchObject({
name: "ProviderApiError",
statusCode: 529,
retryable: true,
});
await session.dispose();
});
it("bounds a hung SDK turn below connector timeouts", async () => {
const { session } = makeSession([{ hang: true }], { turnTimeoutMs: 5 });
const started = Date.now();
await expect(session.generate("hi")).rejects.toBeInstanceOf(ProviderApiError);
expect(Date.now() - started).toBeLessThan(1_000);
await session.dispose();
});
it("self-heals: a throwing turn disposes, the next call re-starts the session", async () => {
const { session, starts } = makeSession([
{ text: "", subtype: "error_max_turns" }, // turn 1 throws
{ text: "recovered", subtype: "success" }, // turn 2 ok (fresh start)
]);
await expect(session.generate("a")).rejects.toThrow();
expect(await session.generate("b")).toBe("recovered");
expect(starts()).toBe(2); // re-started after the failure
await session.dispose();
});
it("restarts the warm session after restartAfterTurns to bound context", async () => {
const { session, starts } = makeSession(
[
{ text: "one", subtype: "success" },
{ text: "two", subtype: "success" },
{ text: "three", subtype: "success" },
],
{ restartAfterTurns: 1 }
);
expect(await session.generate("1")).toBe("one");
expect(await session.generate("2")).toBe("two");
expect(starts()).toBeGreaterThanOrEqual(2); // restarted between turns
await session.dispose();
});
it("rejects an empty prompt body", async () => {
const { session } = makeSession([{ text: "x", subtype: "success" }]);
await expect(session.generate(" ")).rejects.toThrow(/empty prompt body/);
await session.dispose();
});
});
describe("ClaudeSdkSession — ROUTE mode", () => {
it("captures the tool decision and returns it as bare {action,params} JSON", async () => {
const { session } = makeSession(
[{ toolCall: { action: "WEB_FETCH", params: { url: "u" } }, subtype: "error_max_turns" }],
{ router: true }
);
const out = JSON.parse(await session.route("price?"));
expect(out).toEqual({ action: "WEB_FETCH", params: { url: "u" } });
await session.dispose();
});
it("the captured decision wins over any streamed text", async () => {
const { session } = makeSession(
[
{
toolCall: { action: "REPLY", params: { text: "real" } },
text: "I'll route this...", // agentic preamble, must be ignored
subtype: "error_max_turns",
},
],
{ router: true }
);
const out = JSON.parse(await session.route("hi"));
expect(out.action).toBe("REPLY");
expect(out.params.text).toBe("real");
await session.dispose();
});
it("the captured decision wins over residual subscription-limit text", async () => {
const { session } = makeSession(
[
{
toolCall: { action: "WEB_FETCH", params: { url: "https://example.test" } },
text: "You've hit your session limit · resets 9:30pm (UTC)",
subtype: "error_max_turns",
},
],
{ router: true }
);
const out = JSON.parse(await session.route("price?"));
expect(out).toEqual({
action: "WEB_FETCH",
params: { url: "https://example.test" },
});
await session.dispose();
});
it("does NOT surface an agentic preamble as a REPLY when the model skips the tool (error_max_turns)", async () => {
const { session } = makeSession(
[{ text: "I'll route this to WEB_FETCH...", subtype: "error_max_turns" }],
{ router: true }
);
// No decision + non-success subtype => throw, never leak the thought.
await expect(session.route("hi")).rejects.toThrow(/no decision/);
await session.dispose();
});
it("accepts a genuine terminal answer (clean success, no tool) as a REPLY", async () => {
const { session } = makeSession([{ text: "2 + 2 is 4.", subtype: "success" }], {
router: true,
});
const out = JSON.parse(await session.route("2+2?"));
expect(out).toEqual({ action: "REPLY", params: { text: "2 + 2 is 4." } });
await session.dispose();
});
it("coerces malformed params to {} but keeps the action", async () => {
const { session } = makeSession(
[{ toolCall: { action: "IGNORE", params: "not-an-object" }, subtype: "error_max_turns" }],
{ router: true }
);
const out = JSON.parse(await session.route("hi"));
expect(out).toEqual({ action: "IGNORE", params: {} });
await session.dispose();
});
});
describe("ClaudeSdkSession — serialization", () => {
it("serializes concurrent calls so decisions/text never interleave", async () => {
// Two route turns scripted; fire both without awaiting between them.
const { session } = makeSession(
[
{ toolCall: { action: "A", params: {} }, subtype: "error_max_turns" },
{ toolCall: { action: "B", params: {} }, subtype: "error_max_turns" },
],
{ router: true }
);
const [r1, r2] = await Promise.all([session.route("one"), session.route("two")]);
const actions = [JSON.parse(r1).action, JSON.parse(r2).action].sort();
expect(actions).toEqual(["A", "B"]); // both distinct, no cross-contamination
await session.dispose();
});
});