mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 09:01:40 +00:00
584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
/** Tests Code Mode wait, scope, and suspended runs. */
|
|
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { runWithAgentToolExecutionContext } from "../../packages/agent-core/src/tool-execution-context.js";
|
|
import { applyCodeModeCatalog, createCodeModeTools } from "./code-mode.js";
|
|
import {
|
|
resetCodeModeTestState,
|
|
pluginTool,
|
|
pluginToolWithExecute,
|
|
resultDetails,
|
|
createCodeModeHarness,
|
|
testing,
|
|
} from "./code-mode.test-support.js";
|
|
import { createToolSearchCatalogRef } from "./tool-search.js";
|
|
|
|
describe("Code Mode wait, scope, and suspended runs", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
resetCodeModeTestState();
|
|
});
|
|
|
|
it("marks yield suspensions and resumes the snapshot with wait", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const first = resultDetails(
|
|
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
|
|
"code-call-yield",
|
|
{
|
|
restartSafe: true,
|
|
code: `
|
|
text("before");
|
|
await yield_control("pause");
|
|
text("after");
|
|
return "done";
|
|
`,
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(first.status).toBe("waiting");
|
|
expect(first.reason).toBe("yield");
|
|
expect(first.replaySafe).toBe(true);
|
|
expect(first.output).toEqual([{ type: "text", text: "before" }]);
|
|
|
|
const runId = first.runId;
|
|
expect(typeof runId).toBe("string");
|
|
const resumed = resultDetails(
|
|
await expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
|
|
"code-wait-yield",
|
|
{ runId },
|
|
),
|
|
);
|
|
|
|
expect(resumed.status).toBe("completed");
|
|
expect(resumed.value).toBe("done");
|
|
expect(resumed.output).toEqual([{ type: "text", text: "after" }]);
|
|
});
|
|
|
|
it("delivers each yielded output block exactly once across repeated waits", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const execTool = expectDefined(codeModeTools[0], "Code Mode exec test invariant");
|
|
const waitTool = expectDefined(codeModeTools[1], "Code Mode wait test invariant");
|
|
const first = resultDetails(
|
|
await execTool.execute("code-call-incremental-output", {
|
|
code: `
|
|
text("phase 1");
|
|
await yield_control("first pause");
|
|
text("phase 2");
|
|
await yield_control("second pause");
|
|
text("phase 3");
|
|
return "done";
|
|
`,
|
|
}),
|
|
);
|
|
|
|
expect(first.status).toBe("waiting");
|
|
expect(first.output).toEqual([{ type: "text", text: "phase 1" }]);
|
|
|
|
const second = resultDetails(
|
|
await waitTool.execute("code-wait-incremental-output-1", { runId: first.runId }),
|
|
);
|
|
|
|
expect(second.status).toBe("waiting");
|
|
expect(second.output).toEqual([{ type: "text", text: "phase 2" }]);
|
|
|
|
const third = resultDetails(
|
|
await waitTool.execute("code-wait-incremental-output-2", { runId: second.runId }),
|
|
);
|
|
|
|
expect(third.status).toBe("completed");
|
|
expect(third.value).toBe("done");
|
|
expect(third.output).toEqual([{ type: "text", text: "phase 3" }]);
|
|
});
|
|
|
|
it("returns only newly emitted output when a resumed guest fails", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const first = resultDetails(
|
|
await expectDefined(codeModeTools[0], "Code Mode exec test invariant").execute(
|
|
"code-call-incremental-failure",
|
|
{
|
|
code: `
|
|
text("before pause");
|
|
await yield_control("pause");
|
|
text("before failure");
|
|
throw new Error("resumed failure");
|
|
`,
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(first.status).toBe("waiting");
|
|
expect(first.output).toEqual([{ type: "text", text: "before pause" }]);
|
|
|
|
const second = resultDetails(
|
|
await expectDefined(codeModeTools[1], "Code Mode wait test invariant").execute(
|
|
"code-wait-incremental-failure",
|
|
{ runId: first.runId },
|
|
),
|
|
);
|
|
|
|
expect(second.status).toBe("failed");
|
|
expect(second.error).toContain("resumed failure");
|
|
expect(second.output).toEqual([{ type: "text", text: "before failure" }]);
|
|
expect(testing.activeRuns.has(first.runId as string)).toBe(false);
|
|
});
|
|
|
|
it("preserves the original exec identity for tool calls after yield and wait", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
const target = pluginTool("fake_resumed_identity", "Resumed identity helper");
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, target],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const suspended = resultDetails(
|
|
await expectDefined(codeModeTools[0], "Code Mode exec test invariant").execute(
|
|
"code-call-original-parent",
|
|
{
|
|
code: 'await yield_control("pause"); return await tools.callValue("fake_resumed_identity", {});',
|
|
},
|
|
),
|
|
);
|
|
expect(suspended.status).toBe("waiting");
|
|
|
|
const resumed = resultDetails(
|
|
await expectDefined(codeModeTools[1], "Code Mode wait test invariant").execute(
|
|
"code-wait-different-parent",
|
|
{ runId: suspended.runId },
|
|
),
|
|
);
|
|
|
|
expect(resumed.status).toBe("completed");
|
|
expect(target.execute).toHaveBeenCalledOnce();
|
|
expect(vi.mocked(target.execute).mock.calls[0]?.[0]).toContain("code-call-original-parent");
|
|
expect(vi.mocked(target.execute).mock.calls[0]?.[0]).not.toContain(
|
|
"code-wait-different-parent",
|
|
);
|
|
});
|
|
|
|
it("allocates distinct replay identities when a later turn reuses a tool-call id", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
const execTool = expectDefined(codeModeTools[0], "codeModeTools[0] test invariant");
|
|
const input = { code: 'await yield_control("pause"); return "done";' };
|
|
const executionContext = (turnId: string) =>
|
|
({
|
|
assistantMessage: { responseId: " ", turnId },
|
|
toolCall: { type: "toolCall", id: "reused-call-id", name: "exec", arguments: input },
|
|
}) as never;
|
|
|
|
const first = resultDetails(
|
|
await runWithAgentToolExecutionContext(executionContext("response-turn-1"), () =>
|
|
execTool.execute("reused-call-id", input),
|
|
),
|
|
);
|
|
const second = resultDetails(
|
|
await runWithAgentToolExecutionContext(executionContext("response-turn-2"), () =>
|
|
execTool.execute("reused-call-id", input),
|
|
),
|
|
);
|
|
|
|
expect(first.status).toBe("waiting");
|
|
expect(second.status).toBe("waiting");
|
|
expect(second.runId).not.toBe(first.runId);
|
|
expect(testing.activeRuns.size).toBe(2);
|
|
expect(new Set([...testing.activeRuns.values()].map((state) => state.replayId)).size).toBe(2);
|
|
});
|
|
|
|
it("fails yield suspension when snapshot expiry would exceed the Date range", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
|
let details: Record<string, unknown>;
|
|
try {
|
|
details = resultDetails(
|
|
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
|
|
"code-call-yield-overflow",
|
|
{
|
|
code: 'await yield_control("pause"); return "done";',
|
|
},
|
|
),
|
|
);
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(details.status).toBe("failed");
|
|
expect(details.error).toBe("code mode run expiry is unavailable.");
|
|
expect(testing.activeRuns.size).toBe(0);
|
|
});
|
|
|
|
it("expires suspended runs with invalid expiry timestamps", async () => {
|
|
const { tools: codeModeTools } = createCodeModeHarness();
|
|
testing.activeRuns.set("invalid-expiry-run", {
|
|
expiresAt: 8_640_000_000_000_001,
|
|
} as never);
|
|
|
|
await expect(
|
|
expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
|
|
"code-wait-invalid-expiry",
|
|
{ runId: "invalid-expiry-run" },
|
|
),
|
|
).rejects.toThrow("code mode run is unavailable or expired");
|
|
expect(testing.activeRuns.has("invalid-expiry-run")).toBe(false);
|
|
});
|
|
|
|
it("rejects wait calls from a different session scope", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const first = resultDetails(
|
|
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
|
|
"code-call-wrong-session",
|
|
{
|
|
code: 'await yield_control("pause"); return "done";',
|
|
},
|
|
),
|
|
);
|
|
expect(first.status).toBe("waiting");
|
|
const otherWaitTool = expectDefined(
|
|
createCodeModeTools({
|
|
config,
|
|
runtimeConfig: config,
|
|
sessionId: "other-session",
|
|
sessionKey: "agent:other:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
})[1],
|
|
'createCodeModeTools({ config, runtimeConfig: config, sessionId: "othe... test invariant',
|
|
);
|
|
|
|
await expect(
|
|
otherWaitTool.execute("code-wait-wrong-session", { runId: first.runId }),
|
|
).rejects.toThrow("different session");
|
|
});
|
|
|
|
it.each(["runId", "sessionId", "sessionKey", "agentId"] as const)(
|
|
"rejects suspended-run callers missing the owner %s",
|
|
async (missingIdentity) => {
|
|
const {
|
|
config,
|
|
catalogRef,
|
|
ctx,
|
|
tools: codeModeTools,
|
|
} = createCodeModeHarness({
|
|
agentId: "owner",
|
|
});
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: ctx.sessionId,
|
|
sessionKey: ctx.sessionKey,
|
|
agentId: ctx.agentId,
|
|
runId: ctx.runId,
|
|
catalogRef,
|
|
});
|
|
|
|
const suspended = resultDetails(
|
|
await expectDefined(codeModeTools[0], "Code Mode exec test invariant").execute(
|
|
"code-call-scoped-owner",
|
|
{ code: 'await yield_control("pause"); return "owner-secret";' },
|
|
),
|
|
);
|
|
expect(suspended.status).toBe("waiting");
|
|
|
|
const missingIdentityWait = expectDefined(
|
|
createCodeModeTools({
|
|
config,
|
|
runtimeConfig: config,
|
|
catalogRef,
|
|
...(missingIdentity === "runId" ? {} : { runId: ctx.runId }),
|
|
...(missingIdentity === "sessionId" ? {} : { sessionId: ctx.sessionId }),
|
|
...(missingIdentity === "sessionKey" ? {} : { sessionKey: ctx.sessionKey }),
|
|
...(missingIdentity === "agentId" ? {} : { agentId: ctx.agentId }),
|
|
})[1],
|
|
"Unscoped Code Mode wait test invariant",
|
|
);
|
|
|
|
await expect(
|
|
missingIdentityWait.execute("code-wait-missing-owner", { runId: suspended.runId }),
|
|
).rejects.toThrow(missingIdentity === "runId" ? "different agent run" : "different session");
|
|
expect(testing.activeRuns.has(suspended.runId as string)).toBe(true);
|
|
|
|
const rightfulResult = resultDetails(
|
|
await expectDefined(codeModeTools[1], "Owner Code Mode wait test invariant").execute(
|
|
"code-wait-rightful-owner",
|
|
{ runId: suspended.runId },
|
|
),
|
|
);
|
|
expect(rightfulResult.status).toBe("completed");
|
|
expect(rightfulResult.value).toBe("owner-secret");
|
|
},
|
|
);
|
|
|
|
it("rejects concurrent waits for the same suspended run", async () => {
|
|
const catalogRef = createToolSearchCatalogRef();
|
|
const config = {
|
|
tools: {
|
|
codeMode: {
|
|
enabled: true,
|
|
timeoutMs: 500,
|
|
},
|
|
},
|
|
} as never;
|
|
const ctx = {
|
|
config,
|
|
runtimeConfig: config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
};
|
|
const codeModeTools = createCodeModeTools(ctx);
|
|
applyCodeModeCatalog({
|
|
tools: [
|
|
...codeModeTools,
|
|
pluginToolWithExecute(
|
|
"fake_slow",
|
|
"Slow helper",
|
|
async () => await new Promise<never>(() => {}),
|
|
),
|
|
],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const first = resultDetails(
|
|
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
|
|
"code-call-concurrent-wait",
|
|
{
|
|
code: "await tools.fake_slow({}); return 'done';",
|
|
},
|
|
),
|
|
);
|
|
expect(first.status).toBe("waiting");
|
|
|
|
const firstWait = expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
|
|
"code-wait-concurrent-a",
|
|
{
|
|
runId: first.runId,
|
|
},
|
|
);
|
|
await expect(
|
|
expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
|
|
"code-wait-concurrent-b",
|
|
{ runId: first.runId },
|
|
),
|
|
).rejects.toThrow("already being resumed");
|
|
const stillWaiting = resultDetails(await firstWait);
|
|
|
|
expect(stillWaiting.status).toBe("waiting");
|
|
expect(stillWaiting.runId).toBe(first.runId);
|
|
});
|
|
|
|
it("resumes and reparks a yielding run at the suspended-run capacity limit", async () => {
|
|
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
|
|
applyCodeModeCatalog({
|
|
tools: [...codeModeTools, pluginTool("fake_noop", "Noop")],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const first = resultDetails(
|
|
await expectDefined(codeModeTools[0], "Code Mode exec test invariant").execute(
|
|
"code-call-at-capacity",
|
|
{
|
|
code: `
|
|
await yield_control("first");
|
|
await yield_control("second");
|
|
return "done";
|
|
`,
|
|
},
|
|
),
|
|
);
|
|
expect(first.status).toBe("waiting");
|
|
const firstRunId = first.runId;
|
|
expect(typeof firstRunId).toBe("string");
|
|
if (typeof firstRunId !== "string") {
|
|
throw new Error("expected a parked Code Mode run");
|
|
}
|
|
const parked = testing.activeRuns.get(firstRunId);
|
|
expect(parked).toBeDefined();
|
|
if (!parked) {
|
|
throw new Error("expected a parked Code Mode snapshot");
|
|
}
|
|
|
|
// Inert snapshots occupy real capacity without starting 63 extra workers.
|
|
for (let index = 0; index < 63; index += 1) {
|
|
const runId = `cm_code_mode_capacity_${index}`;
|
|
testing.activeRuns.set(runId, { ...parked, runId, pending: [] });
|
|
}
|
|
|
|
const second = resultDetails(
|
|
await expectDefined(codeModeTools[1], "Code Mode wait test invariant").execute(
|
|
"code-wait-at-capacity",
|
|
{ runId: firstRunId },
|
|
),
|
|
);
|
|
|
|
expect(second.status).toBe("waiting");
|
|
expect(second.reason).toBe("yield");
|
|
expect(testing.activeRuns.size).toBe(64);
|
|
|
|
const completed = resultDetails(
|
|
await expectDefined(codeModeTools[1], "Code Mode wait test invariant").execute(
|
|
"code-wait-after-capacity",
|
|
{ runId: second.runId },
|
|
),
|
|
);
|
|
|
|
expect(completed).toMatchObject({ status: "completed", value: "done" });
|
|
expect(testing.activeRuns.size).toBe(63);
|
|
});
|
|
|
|
it("reports only unsettled pending tool calls when wait times out", async () => {
|
|
const catalogRef = createToolSearchCatalogRef();
|
|
const config = {
|
|
tools: {
|
|
codeMode: {
|
|
enabled: true,
|
|
timeoutMs: 500,
|
|
},
|
|
},
|
|
} as never;
|
|
const ctx = {
|
|
config,
|
|
runtimeConfig: config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
};
|
|
const codeModeTools = createCodeModeTools(ctx);
|
|
applyCodeModeCatalog({
|
|
tools: [
|
|
...codeModeTools,
|
|
pluginTool("fake_fast", "Fast helper"),
|
|
pluginToolWithExecute(
|
|
"fake_slow",
|
|
"Slow helper",
|
|
async () => await new Promise<never>(() => {}),
|
|
),
|
|
],
|
|
config,
|
|
sessionId: "session-code-mode",
|
|
sessionKey: "agent:main:main",
|
|
runId: "run-code-mode",
|
|
catalogRef,
|
|
});
|
|
|
|
const first = resultDetails(
|
|
await expectDefined(codeModeTools[0], "codeModeTools[0] test invariant").execute(
|
|
"code-call-timeout",
|
|
{
|
|
code: `
|
|
text("before timeout");
|
|
const fast = tools.fake_fast({});
|
|
const slow = tools.fake_slow({});
|
|
await fast;
|
|
await slow;
|
|
return "done";
|
|
`,
|
|
},
|
|
),
|
|
);
|
|
expect(first.status).toBe("waiting");
|
|
expect(first.output).toEqual([{ type: "text", text: "before timeout" }]);
|
|
expect(first.pendingToolCalls).toEqual([expect.objectContaining({ method: "callValue" })]);
|
|
const runId = first.runId;
|
|
expect(typeof runId).toBe("string");
|
|
if (typeof runId !== "string") {
|
|
throw new Error("expected code mode run id");
|
|
}
|
|
|
|
const activeRun = testing.activeRuns.get(runId);
|
|
expect(activeRun).toBeDefined();
|
|
activeRun!.config.timeoutMs = 100;
|
|
|
|
const second = resultDetails(
|
|
await expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
|
|
"code-wait-timeout",
|
|
{ runId },
|
|
),
|
|
);
|
|
|
|
expect(second.status).toBe("waiting");
|
|
expect(second.output).toEqual([]);
|
|
expect(second.pendingToolCalls).toEqual([expect.objectContaining({ method: "callValue" })]);
|
|
|
|
const third = resultDetails(
|
|
await expectDefined(codeModeTools[1], "codeModeTools[1] test invariant").execute(
|
|
"code-wait-timeout-again",
|
|
{ runId },
|
|
),
|
|
);
|
|
|
|
expect(third.status).toBe("waiting");
|
|
expect(third.output).toEqual([]);
|
|
expect(third.pendingToolCalls).toEqual([expect.objectContaining({ method: "callValue" })]);
|
|
});
|
|
});
|