
























@@ -2251,9 +2251,12 @@ describe("runCodexAppServerAttempt", () => {
22512251});
2252225222532253it("keeps forced message dynamic tool when toolsAllow is empty", async () => {
2254-testing.setOpenClawCodingToolsFactoryForTests(() => [
2254+testing.setOpenClawCodingToolsFactoryForTests((options) => [
22552255createRuntimeDynamicTool("message"),
22562256createRuntimeDynamicTool("music_generate"),
2257+ ...(options?.forceHeartbeatTool === true
2258+ ? [createRuntimeDynamicTool("heartbeat_respond")]
2259+ : []),
22572260]);
22582261const harness = createStartedThreadHarness();
22592262const params = createParams(
@@ -2281,6 +2284,38 @@ describe("runCodexAppServerAttempt", () => {
22812284expect(dynamicToolNames).toEqual(["message"]);
22822285});
228322862287+it("keeps forced heartbeat registration inside narrow toolsAllow policy", async () => {
2288+testing.setOpenClawCodingToolsFactoryForTests((options) => [
2289+createRuntimeDynamicTool("message"),
2290+ ...(options?.forceHeartbeatTool === true
2291+ ? [createRuntimeDynamicTool("heartbeat_respond")]
2292+ : []),
2293+]);
2294+const harness = createStartedThreadHarness();
2295+const params = createParams(
2296+path.join(tempDir, "session.jsonl"),
2297+path.join(tempDir, "workspace"),
2298+);
2299+params.disableTools = false;
2300+params.runtimePlan = createCodexRuntimePlanFixture();
2301+params.toolsAllow = ["message"];
2302+2303+const run = runCodexAppServerAttempt(params, {
2304+pluginConfig: { appServer: { mode: "yolo" } },
2305+});
2306+await harness.waitForMethod("turn/start", 120_000);
2307+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2308+await run;
2309+2310+const startRequest = harness.requests.find((entry) => entry.method === "thread/start");
2311+const dynamicToolNames =
2312+(
2313+startRequest?.params as { dynamicTools?: Array<{ name?: string }> } | undefined
2314+)?.dynamicTools?.map((tool) => tool.name) ?? [];
2315+2316+expect(dynamicToolNames).toEqual(["message"]);
2317+});
2318+22842319it("keeps searchable OpenClaw dynamic tools when code-mode-only is enabled", async () => {
22852320testing.setOpenClawCodingToolsFactoryForTests(() => [
22862321createRuntimeDynamicTool("message"),
@@ -2331,6 +2366,158 @@ describe("runCodexAppServerAttempt", () => {
23312366expect(startConfig?.["features.code_mode_only"]).toBe(true);
23322367});
233323682369+it("registers heartbeat response durably without advertising it on normal turns", async () => {
2370+testing.setOpenClawCodingToolsFactoryForTests((options) => [
2371+createRuntimeDynamicTool("message"),
2372+ ...(options?.enableHeartbeatTool === true
2373+ ? [createRuntimeDynamicTool("heartbeat_respond")]
2374+ : []),
2375+]);
2376+const harness = createStartedThreadHarness(async (method) => {
2377+if (method === "thread/resume") {
2378+return threadStartResult("thread-1");
2379+}
2380+return undefined;
2381+});
2382+const sessionFile = path.join(tempDir, "session.jsonl");
2383+const workspaceDir = path.join(tempDir, "workspace");
2384+const createRunParams = (trigger?: EmbeddedRunAttemptParams["trigger"]) => {
2385+const params = createParams(sessionFile, workspaceDir);
2386+params.disableTools = false;
2387+params.runtimePlan = createCodexRuntimePlanFixture();
2388+if (trigger) {
2389+params.trigger = trigger;
2390+}
2391+if (trigger === "heartbeat") {
2392+params.sourceReplyDeliveryMode = "message_tool_only";
2393+}
2394+return params;
2395+};
2396+2397+const normalRun = runCodexAppServerAttempt(createRunParams(), {
2398+pluginConfig: { appServer: { mode: "yolo" } },
2399+});
2400+await harness.waitForMethod("turn/start", 120_000);
2401+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2402+await normalRun;
2403+2404+const startRequest = harness.requests.find((entry) => entry.method === "thread/start");
2405+const startParams = startRequest?.params as
2406+| { dynamicTools?: Array<{ name?: string }>; developerInstructions?: string }
2407+| undefined;
2408+const registeredToolNames = startParams?.dynamicTools?.map((tool) => tool.name) ?? [];
2409+2410+expect(registeredToolNames).toContain("message");
2411+expect(registeredToolNames).toContain("heartbeat_respond");
2412+expect(startParams?.developerInstructions).toContain(
2413+"Deferred searchable OpenClaw dynamic tools available: message.",
2414+);
2415+expect(startParams?.developerInstructions).not.toContain(
2416+"Deferred searchable OpenClaw dynamic tools available: heartbeat_respond",
2417+);
2418+2419+const heartbeatRun = runCodexAppServerAttempt(createRunParams("heartbeat"), {
2420+pluginConfig: { appServer: { mode: "yolo" } },
2421+});
2422+await vi.waitFor(
2423+() => {
2424+expect(harness.requests.filter((entry) => entry.method === "turn/start")).toHaveLength(2);
2425+},
2426+{ interval: 1, timeout: 120_000 },
2427+);
2428+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2429+await heartbeatRun;
2430+2431+const nextNormalRun = runCodexAppServerAttempt(createRunParams(), {
2432+pluginConfig: { appServer: { mode: "yolo" } },
2433+});
2434+await vi.waitFor(
2435+() => {
2436+expect(harness.requests.filter((entry) => entry.method === "turn/start")).toHaveLength(3);
2437+},
2438+{ interval: 1, timeout: 120_000 },
2439+);
2440+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2441+await nextNormalRun;
2442+2443+expect(harness.requests.filter((entry) => entry.method === "thread/start")).toHaveLength(1);
2444+expect(harness.requests.filter((entry) => entry.method === "thread/resume")).toHaveLength(2);
2445+});
2446+2447+it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => {
2448+testing.setOpenClawCodingToolsFactoryForTests((options) => [
2449+createRuntimeDynamicTool("message"),
2450+createRuntimeDynamicTool("web_search"),
2451+ ...(options?.enableHeartbeatTool === true
2452+ ? [createRuntimeDynamicTool("heartbeat_respond")]
2453+ : []),
2454+]);
2455+const harness = createStartedThreadHarness(async (method) => {
2456+if (method === "thread/resume") {
2457+return threadStartResult("thread-1");
2458+}
2459+return undefined;
2460+});
2461+const sessionFile = path.join(tempDir, "session.jsonl");
2462+const workspaceDir = path.join(tempDir, "workspace");
2463+const createRunParams = (trigger?: EmbeddedRunAttemptParams["trigger"]) => {
2464+const params = createParams(sessionFile, workspaceDir);
2465+params.disableTools = false;
2466+const runtimePlan = createCodexRuntimePlanFixture();
2467+params.runtimePlan = {
2468+ ...runtimePlan,
2469+tools: {
2470+normalize: (tools: Array<{ name: string }>) =>
2471+trigger === "heartbeat"
2472+ ? tools.filter((tool) => tool.name === "heartbeat_respond")
2473+ : tools,
2474+logDiagnostics: () => undefined,
2475+},
2476+} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
2477+if (trigger) {
2478+params.trigger = trigger;
2479+}
2480+return params;
2481+};
2482+2483+const normalRun = runCodexAppServerAttempt(createRunParams(), {
2484+pluginConfig: { appServer: { mode: "yolo" } },
2485+});
2486+await harness.waitForMethod("turn/start", 120_000);
2487+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2488+await normalRun;
2489+2490+const heartbeatRun = runCodexAppServerAttempt(createRunParams("heartbeat"), {
2491+pluginConfig: { appServer: { mode: "yolo" } },
2492+});
2493+await vi.waitFor(
2494+() => {
2495+expect(harness.requests.filter((entry) => entry.method === "turn/start")).toHaveLength(2);
2496+},
2497+{ interval: 1, timeout: 120_000 },
2498+);
2499+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2500+await heartbeatRun;
2501+2502+const nextNormalRun = runCodexAppServerAttempt(createRunParams(), {
2503+pluginConfig: { appServer: { mode: "yolo" } },
2504+});
2505+await vi.waitFor(
2506+() => {
2507+expect(harness.requests.filter((entry) => entry.method === "turn/start")).toHaveLength(3);
2508+},
2509+{ interval: 1, timeout: 120_000 },
2510+);
2511+await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2512+await nextNormalRun;
2513+2514+expect(
2515+harness.requests
2516+.map((entry) => entry.method)
2517+.filter((method) => method === "thread/start" || method === "thread/resume"),
2518+).toEqual(["thread/start", "thread/resume", "thread/resume"]);
2519+});
2520+23342521it("disables Codex native tool surfaces when runtime toolsAllow is empty", async () => {
23352522testing.setOpenClawCodingToolsFactoryForTests(() => [
23362523createRuntimeDynamicTool("message"),
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。