mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 21:11:41 +00:00
348 lines
9.8 KiB
TypeScript
348 lines
9.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createDeferred } from "../shared/deferred.js";
|
|
import { runCodeModeScriptHeadless, testing, type CodeModeHeadlessResult } from "./code-mode.js";
|
|
import {
|
|
createToolSearchCatalogRef,
|
|
registerHeadlessToolSearchCatalog,
|
|
type ToolSearchToolContext,
|
|
} from "./tool-search.js";
|
|
import { jsonResult, type AnyAgentTool } from "./tools/common.js";
|
|
|
|
function fakeTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool {
|
|
return {
|
|
name,
|
|
label: name,
|
|
description: `Test tool ${name}`,
|
|
parameters: { type: "object", properties: {} },
|
|
execute: vi.fn(execute) as AnyAgentTool["execute"],
|
|
};
|
|
}
|
|
|
|
function createHeadlessHarness(tools: AnyAgentTool[] = []): ToolSearchToolContext {
|
|
const config = {
|
|
tools: { codeMode: { enabled: false, timeoutMs: 60_000 } },
|
|
} as never;
|
|
const catalogRef = createToolSearchCatalogRef();
|
|
registerHeadlessToolSearchCatalog({ catalogRef, tools });
|
|
return {
|
|
config,
|
|
runtimeConfig: config,
|
|
agentId: "main",
|
|
catalogRef,
|
|
};
|
|
}
|
|
|
|
function expectCompleted(result: CodeModeHeadlessResult) {
|
|
expect(result.status).toBe("completed");
|
|
if (result.status !== "completed") {
|
|
throw new Error(result.error);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function expectFailed(result: CodeModeHeadlessResult) {
|
|
expect(result.status).toBe("failed");
|
|
if (result.status !== "failed") {
|
|
throw new Error("expected headless code mode failure");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe("headless Code Mode", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
expect(testing.activeRuns.size).toBe(0);
|
|
testing.activeRuns.clear();
|
|
testing.resumingRunIds.clear();
|
|
});
|
|
|
|
it("completes multi-round tool calls without publishing active runs", async () => {
|
|
const first = fakeTool("headless_first", async () => {
|
|
expect(testing.activeRuns.size).toBe(0);
|
|
return jsonResult({ value: 2 });
|
|
});
|
|
const second = fakeTool("headless_second", async (_toolCallId, input) => {
|
|
expect(testing.activeRuns.size).toBe(0);
|
|
return jsonResult({ input });
|
|
});
|
|
const ctx = createHeadlessHarness([first, second]);
|
|
|
|
const result = expectCompleted(
|
|
await runCodeModeScriptHeadless({
|
|
ctx,
|
|
code: `
|
|
const first = await tools.call("openclaw:core:headless_first", {});
|
|
const second = await tools.call("openclaw:core:headless_second", {
|
|
value: first.result.details.value,
|
|
});
|
|
return second.result.details;
|
|
`,
|
|
wallClockMs: 120_000,
|
|
}),
|
|
);
|
|
|
|
expect(result.value).toEqual({ input: { value: 2 } });
|
|
expect(result.toolCallCount).toBe(2);
|
|
expect(first.execute).toHaveBeenCalledOnce();
|
|
expect(second.execute).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("injects deeply frozen trigger state and emits replacement state through json", async () => {
|
|
const result = expectCompleted(
|
|
await runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness(),
|
|
code: `
|
|
json({
|
|
fire: true,
|
|
frozen: Object.isFrozen(trigger) &&
|
|
Object.isFrozen(trigger.state) &&
|
|
Object.isFrozen(trigger.state.nested),
|
|
emptyKey: trigger.state[""],
|
|
state: { count: trigger.state.count + 1 },
|
|
});
|
|
return "done";
|
|
`,
|
|
extraNamespaces: [
|
|
{
|
|
id: "cron:trigger",
|
|
globalName: "trigger",
|
|
scope: {
|
|
kind: "object",
|
|
entries: [
|
|
[
|
|
"state",
|
|
{
|
|
kind: "value",
|
|
value: { "": 7, count: 4, nested: { stable: true } },
|
|
},
|
|
],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(result.output).toEqual([
|
|
{
|
|
type: "json",
|
|
value: { fire: true, frozen: true, emptyKey: 7, state: { count: 5 } },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects colliding injected namespace globals", async () => {
|
|
const result = expectFailed(
|
|
await runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness(),
|
|
code: "return true;",
|
|
extraNamespaces: [
|
|
{
|
|
id: "cron:trigger",
|
|
globalName: "trigger",
|
|
scope: { kind: "object", entries: [] },
|
|
},
|
|
{
|
|
id: "plugin:trigger",
|
|
globalName: "trigger",
|
|
scope: { kind: "object", entries: [] },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(result.code).toBe("invalid_input");
|
|
expect(result.error).toContain("namespace collision");
|
|
});
|
|
|
|
it("fails before settling tool calls beyond the total budget", async () => {
|
|
const tool = fakeTool("budgeted", async () => jsonResult({ ok: true }));
|
|
const result = expectFailed(
|
|
await runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness([tool]),
|
|
code: `
|
|
await tools.call("openclaw:core:budgeted", {});
|
|
await tools.call("openclaw:core:budgeted", {});
|
|
return true;
|
|
`,
|
|
maxToolCalls: 1,
|
|
wallClockMs: 120_000,
|
|
}),
|
|
);
|
|
|
|
expect(result.code).toBe("tool_budget_exceeded");
|
|
expect(result.toolCallCount).toBe(2);
|
|
expect(tool.execute).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("enforces one wall-clock deadline across worker and tool legs", async () => {
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
const toolStarted = createDeferred();
|
|
const slow = fakeTool("slow_leg", async (_toolCallId, _input, signal) => {
|
|
toolStarted.resolve();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(resolve, 30_000);
|
|
signal?.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
reject(new Error("aborted"));
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
return jsonResult({ ok: true });
|
|
});
|
|
const resultPromise = runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness([slow]),
|
|
code: `
|
|
await tools.call("openclaw:core:slow_leg", {});
|
|
return true;
|
|
`,
|
|
wallClockMs: 15_000,
|
|
});
|
|
|
|
// Advance the shared deadline only after the real worker reaches the tool leg.
|
|
await toolStarted.promise;
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
const result = expectFailed(await resultPromise);
|
|
|
|
expect(result.code).toBe("timeout");
|
|
expect(result.toolCallCount).toBe(1);
|
|
});
|
|
|
|
it("settles yield_control inline and resumes to completion", async () => {
|
|
const result = expectCompleted(
|
|
await runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness(),
|
|
code: `
|
|
const yielded = await yield_control("pause");
|
|
return { yielded, resumed: true };
|
|
`,
|
|
}),
|
|
);
|
|
|
|
expect(result.value).toEqual({
|
|
yielded: { status: "yielded", reason: "pause" },
|
|
resumed: true,
|
|
});
|
|
expect(result.toolCallCount).toBe(0);
|
|
});
|
|
|
|
it("terminates an in-flight worker leg when aborted", async () => {
|
|
const ctx = createHeadlessHarness();
|
|
const config = testing.resolveCodeModeHeadlessConfig(ctx);
|
|
const controller = new AbortController();
|
|
const resultPromise = testing.runCodeModeWorker(
|
|
{
|
|
kind: "exec",
|
|
source: "while (true) {}",
|
|
config,
|
|
catalog: [],
|
|
apiFiles: [],
|
|
namespaces: [],
|
|
},
|
|
5000,
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
setTimeout(() => controller.abort(), 100);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
status: "failed",
|
|
code: "aborted",
|
|
error: "code mode execution aborted",
|
|
});
|
|
});
|
|
|
|
it("classifies caller aborts before the worker leg as aborted", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
const result = expectFailed(
|
|
await runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness(),
|
|
code: "return true;",
|
|
signal: controller.signal,
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
code: "aborted",
|
|
error: "code mode execution aborted",
|
|
});
|
|
});
|
|
|
|
it("keeps worker-leg wall-clock expiry classified as timeout", async () => {
|
|
const ctx = createHeadlessHarness();
|
|
const config = testing.resolveCodeModeHeadlessConfig(ctx);
|
|
const headlessScope = testing.createHeadlessAbortScope(undefined, 100);
|
|
try {
|
|
const result = await testing.runCodeModeWorker(
|
|
{
|
|
kind: "exec",
|
|
source: "while (true) {}",
|
|
config,
|
|
catalog: [],
|
|
apiFiles: [],
|
|
namespaces: [],
|
|
},
|
|
5_000,
|
|
undefined,
|
|
headlessScope.signal,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: "failed",
|
|
code: "timeout",
|
|
error: "code mode timeout exceeded",
|
|
});
|
|
} finally {
|
|
headlessScope.cleanup();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "syntax errors",
|
|
code: "return (;",
|
|
expectedCode: "internal_error",
|
|
overrides: undefined,
|
|
},
|
|
{
|
|
name: "output overages",
|
|
code: `text("x".repeat(2048)); return true;`,
|
|
expectedCode: "output_limit_exceeded",
|
|
overrides: { maxOutputBytes: 1024 },
|
|
},
|
|
])("classifies $name", async ({ code, expectedCode, overrides }) => {
|
|
const result = expectFailed(
|
|
await runCodeModeScriptHeadless({
|
|
ctx: createHeadlessHarness(),
|
|
code,
|
|
overrides,
|
|
}),
|
|
);
|
|
|
|
expect(result.code).toBe(expectedCode);
|
|
});
|
|
|
|
it("clamps headless limit overrides to worker-safe bounds", () => {
|
|
const config = testing.resolveCodeModeHeadlessConfig(createHeadlessHarness(), {
|
|
timeoutMs: 1,
|
|
memoryLimitBytes: 1,
|
|
maxOutputBytes: 1,
|
|
maxSnapshotBytes: 1,
|
|
maxPendingToolCalls: 999,
|
|
});
|
|
|
|
expect(config).toMatchObject({
|
|
timeoutMs: 100,
|
|
memoryLimitBytes: 1024 * 1024,
|
|
maxOutputBytes: 1024,
|
|
maxSnapshotBytes: 1024,
|
|
maxPendingToolCalls: 128,
|
|
});
|
|
});
|
|
});
|