From a67753cc25933984846ae69553d8ce7cd016c1f8 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Sun, 10 May 2026 17:11:32 +0100 Subject: [PATCH] fix(agents): clarify subagent spawn wait guidance (#79051) Summary: - Replace the subagent spawn accepted-note yield guidance with push-based completion-event guidance. - Cover the prompt with regression assertions that keep sessions_yield out of the note. - Keep current rebased lint/type test helpers green. Verification: - pnpm lint - pnpm check:test-types - env -u OPENCLAW_TESTBOX -u OPENCLAW_TESTBOX_ID pnpm check:changed Co-authored-by: brokemac79 --- CHANGELOG.md | 1 + .../engine/gateway/outbound-dispatch.test.ts | 4 +- extensions/synology-chat/src/client.test.ts | 2 +- .../synology-chat/src/webhook-handler.test.ts | 5 ++- extensions/tavily/src/tavily-tools.test.ts | 7 +++- src/agents/acp-spawn-parent-stream.test.ts | 4 +- ...subagents.sessions-spawn.cron-note.test.ts | 12 ++++++ src/agents/simple-completion-runtime.test.ts | 8 ++-- src/agents/subagent-spawn-accepted-note.ts | 2 +- src/cli/daemon-cli/status.gather.test.ts | 42 ++++++++++--------- .../gateway-cli/run.option-collisions.test.ts | 14 +++---- src/gateway/tools-invoke-http.test.ts | 2 +- 12 files changed, 62 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bff6cb9aec..99e60e0d40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -430,6 +430,7 @@ Docs: https://docs.openclaw.ai - Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews. - Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590. Refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481. - Status: treat CLI runtime aliases such as `claude-cli/` as the canonical selected provider route in `/status`, avoiding spurious fallback/unknown-auth display and preserving fresh context usage from CLI usage snapshots. Fixes #79015. Thanks @ItsThierry. +- Agents/subagents: stop the `sessions_spawn` accepted note from recommending `sessions_yield` as the default wait path in push-based chat and CLI flows. Fixes #78913. Thanks @oiGaDio. - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - Telegram: deduplicate media attachments in non-streaming mode so block-delivered images are not resent in the final reply, and clear legacy `mediaUrl` fallback when all media URLs are filtered. Fixes #78372. - Gateway/auth: allow `gateway.auth.mode: "none"` loopback backend RPC clients to skip device identity only for local non-browser backend connections, restoring subagent spawns and gateway tools without opening remote or browser-origin bypasses. Fixes #75780. Thanks @yozakura-ava. diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts index f1712d14cd1..be2c6f7d647 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts @@ -4,13 +4,13 @@ import { dispatchOutbound } from "./outbound-dispatch.js"; import type { GatewayAccount, GatewayPluginRuntime } from "./types.js"; const sendVoiceMessageMock = vi.hoisted(() => - vi.fn(async () => ({ id: "voice-1", timestamp: "2026-04-25T00:00:00.000Z" })), + vi.fn(async (_params: unknown) => ({ id: "voice-1", timestamp: "2026-04-25T00:00:00.000Z" })), ); const sendMediaMock = vi.hoisted(() => vi.fn(async (_params: unknown) => ({ id: "media-1", timestamp: "2026-04-25T00:00:00.000Z" })), ); const sendTextMock = vi.hoisted(() => - vi.fn(async () => ({ id: "text-1", timestamp: "2026-04-25T00:00:00.000Z" })), + vi.fn(async (_params: unknown) => ({ id: "text-1", timestamp: "2026-04-25T00:00:00.000Z" })), ); const audioFileToSilkBase64Mock = vi.hoisted(() => vi.fn(async () => "silk-base64")); diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index bcdc87ec0e7..d80e40fccc4 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -337,7 +337,7 @@ describe("resolveLegacyWebhookNameToChatUserId", () => { if (!call) { throw new Error("expected Synology Chat user_list request"); } - expect(String(call[0])).toBe(`${baseUrl.replace("method=chatbot", "method=user_list")}`); + expect(String(call[0])).toBe(baseUrl.replace("method=chatbot", "method=user_list")); expect(call[1]).toEqual({ rejectUnauthorized: true }); expect(typeof call[2]).toBe("function"); }); diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 2fcdebc1164..876d457e21c 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -190,9 +190,10 @@ describe("createWebhookHandler", () => { } async function runValidReply(params: { accountIdSuffix: string; reply?: string }) { - const { deliver, handler } = makeTestHandler({ + const deliver = vi.fn().mockResolvedValue(params.reply ?? "Bot reply"); + const { handler } = makeTestHandler({ accountIdSuffix: params.accountIdSuffix, - deliver: vi.fn().mockResolvedValue(params.reply ?? "Bot reply"), + deliver, }); const res = await postToWebhook(handler); expect(res._status).toBe(204); diff --git a/extensions/tavily/src/tavily-tools.test.ts b/extensions/tavily/src/tavily-tools.test.ts index f9d8f423472..ac91c4fae45 100644 --- a/extensions/tavily/src/tavily-tools.test.ts +++ b/extensions/tavily/src/tavily-tools.test.ts @@ -15,7 +15,7 @@ import { const { runTavilySearch, runTavilyExtract } = vi.hoisted(() => ({ runTavilySearch: vi.fn(async (params: Record) => params), - runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })), + runTavilyExtract: vi.fn(async (params: Record) => ({ ok: true, params })), })); type TavilyExtractParams = { @@ -56,7 +56,10 @@ describe("tavily tools", () => { runTavilySearch.mockReset(); runTavilySearch.mockImplementation(async (params: Record) => params); runTavilyExtract.mockReset(); - runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params })); + runTavilyExtract.mockImplementation(async (params: Record) => ({ + ok: true, + params, + })); vi.unstubAllEnvs(); }); diff --git a/src/agents/acp-spawn-parent-stream.test.ts b/src/agents/acp-spawn-parent-stream.test.ts index 17a2d2dec03..5864eca1e48 100644 --- a/src/agents/acp-spawn-parent-stream.test.ts +++ b/src/agents/acp-spawn-parent-stream.test.ts @@ -51,7 +51,9 @@ let resolveAcpSpawnStreamLogPath: typeof import("./acp-spawn-parent-stream.js"). let startAcpSpawnParentStreamRelay: typeof import("./acp-spawn-parent-stream.js").startAcpSpawnParentStreamRelay; function collectedTexts() { - return enqueueSystemEventMock.mock.calls.map((call) => String(call[0] ?? "")); + return enqueueSystemEventMock.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : (JSON.stringify(call[0]) ?? ""), + ); } function expectTextWithFragment(texts: string[], fragment: string): void { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.cron-note.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.cron-note.test.ts index 9d3384aa9e3..ab5ed57826b 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.cron-note.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.cron-note.test.ts @@ -24,6 +24,18 @@ describe("sessions_spawn: cron isolated session note suppression", () => { ).toBe(SUBAGENT_SPAWN_ACCEPTED_NOTE); }); + it("keeps regular run guidance push-based without recommending sessions_yield", () => { + expect(SUBAGENT_SPAWN_ACCEPTED_NOTE).toContain("Auto-announce is push-based"); + expect(SUBAGENT_SPAWN_ACCEPTED_NOTE).toContain("Continue any independent work"); + expect(SUBAGENT_SPAWN_ACCEPTED_NOTE).toContain( + "wait for runtime completion events to arrive as user messages", + ); + expect(SUBAGENT_SPAWN_ACCEPTED_NOTE).toContain( + "only answer after completion events for ALL required children arrive", + ); + expect(SUBAGENT_SPAWN_ACCEPTED_NOTE).not.toContain("sessions_yield"); + }); + it("preserves ACCEPTED_NOTE for non-canonical cron-like keys", () => { expect( resolveSubagentSpawnAcceptedNote({ diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts index 773a4ccc744..10145203a06 100644 --- a/src/agents/simple-completion-runtime.test.ts +++ b/src/agents/simple-completion-runtime.test.ts @@ -100,10 +100,10 @@ function expectPreparedModelResult( } } -function callArg(mock: { mock: { calls: unknown[][] } }, index = 0): T { +function callArg(mock: { mock: { calls: unknown[][] } }, index = 0): unknown { const call = mock.mock.calls[index]; expect(call).toBeDefined(); - return call?.[0] as T; + return call?.[0]; } describe("prepareSimpleCompletionModel", () => { @@ -402,7 +402,7 @@ describe("prepareSimpleCompletionModel", () => { agentDir: "/tmp/openclaw-agent", }); - const runtimeAuthInput = callArg<{ + const runtimeAuthInput = callArg(hoisted.prepareProviderRuntimeAuthMock) as { provider?: string; workspaceDir?: string; context?: { @@ -411,7 +411,7 @@ describe("prepareSimpleCompletionModel", () => { modelId?: string; profileId?: string; }; - }>(hoisted.prepareProviderRuntimeAuthMock); + }; expect(runtimeAuthInput.provider).toBe("amazon-bedrock-mantle"); expect(runtimeAuthInput.workspaceDir).toBe("/tmp/openclaw-agent"); expect(runtimeAuthInput.context?.apiKey).toBe("__amazon_bedrock_mantle_iam__"); diff --git a/src/agents/subagent-spawn-accepted-note.ts b/src/agents/subagent-spawn-accepted-note.ts index a81340bf1c2..d70602357c4 100644 --- a/src/agents/subagent-spawn-accepted-note.ts +++ b/src/agents/subagent-spawn-accepted-note.ts @@ -1,7 +1,7 @@ import { isCronSessionKey } from "../routing/session-key.js"; export const SUBAGENT_SPAWN_ACCEPTED_NOTE = - "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool. Track expected child session keys. If any required child completion has not arrived yet, call sessions_yield to end the turn and wait for completion events as user messages. Only send your final answer after ALL expected completions arrive. If a child completion event arrives AFTER your final answer, reply ONLY with NO_REPLY."; + "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool. Track expected child session keys. Continue any independent work. If your final answer depends on child output, wait for runtime completion events to arrive as user messages and only answer after completion events for ALL required children arrive. If a child completion event arrives AFTER your final answer, reply ONLY with NO_REPLY."; export const SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE = "thread-bound session stays active after this task; continue in-thread for follow-ups."; diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 140dacf16f2..61a0cd01531 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -182,10 +182,10 @@ vi.mock("./restart-health.js", () => ({ inspectGatewayRestart: (opts: unknown) => inspectGatewayRestart(opts), })); -function callArg(mock: { mock: { calls: unknown[][] } }, index = 0): T { +function callArg(mock: { mock: { calls: unknown[][] } }, index = 0): unknown { const call = mock.mock.calls[index]; expect(call).toBeDefined(); - return call?.[0] as T; + return call?.[0]; } describe("gatherDaemonStatus", () => { @@ -241,11 +241,11 @@ describe("gatherDaemonStatus", () => { }); expect(loadGatewayTlsRuntime).toHaveBeenCalledTimes(1); - const probeInput = callArg<{ + const probeInput = callArg(callGatewayStatusProbe) as { url?: string; tlsFingerprint?: string; token?: string; - }>(callGatewayStatusProbe); + }; expect(probeInput.url).toBe("wss://127.0.0.1:19001"); expect(probeInput.tlsFingerprint).toBe("sha256:11:22:33:44"); expect(probeInput.token).toBe("daemon-token"); @@ -269,9 +269,10 @@ describe("gatherDaemonStatus", () => { deep: false, }); - const probeInput = callArg<{ requireRpc?: boolean; configPath?: string }>( - callGatewayStatusProbe, - ); + const probeInput = callArg(callGatewayStatusProbe) as { + requireRpc?: boolean; + configPath?: string; + }; expect(probeInput.requireRpc).toBe(true); expect(probeInput.configPath).toBe("/tmp/openclaw-daemon/openclaw.json"); }); @@ -292,11 +293,11 @@ describe("gatherDaemonStatus", () => { deep: false, }); - const probeInput = callArg<{ + const probeInput = callArg(callGatewayStatusProbe) as { config?: unknown; preauthHandshakeTimeoutMs?: number; timeoutMs?: number; - }>(callGatewayStatusProbe); + }; expect(probeInput.config).toBe(daemonLoadedConfig); expect(probeInput.preauthHandshakeTimeoutMs).toBe(30_000); expect(probeInput.timeoutMs).toBe(30_000); @@ -344,7 +345,10 @@ describe("gatherDaemonStatus", () => { }); expect(loadGatewayTlsRuntime).not.toHaveBeenCalled(); - const probeInput = callArg<{ url?: string; tlsFingerprint?: string }>(callGatewayStatusProbe); + const probeInput = callArg(callGatewayStatusProbe) as { + url?: string; + tlsFingerprint?: string; + }; expect(probeInput.url).toBe("wss://override.example:18790"); expect(probeInput.tlsFingerprint).toBeUndefined(); expect(status.gateway?.probeUrl).toBe("wss://override.example:18790"); @@ -400,9 +404,7 @@ describe("gatherDaemonStatus", () => { }); expect( - serviceReadRuntime.mock.calls.some( - ([env]) => (env as NodeJS.ProcessEnv | undefined)?.OPENCLAW_GATEWAY_PORT === "19001", - ), + serviceReadRuntime.mock.calls.some(([env]) => env?.OPENCLAW_GATEWAY_PORT === "19001"), ).toBe(true); expect(status.service.runtime?.status).toBe("running"); expect((status.service.runtime as { detail?: string }).detail).toBe("19001"); @@ -428,7 +430,7 @@ describe("gatherDaemonStatus", () => { deep: true, }); - const handoffInput = callArg(readGatewayRestartHandoffSync); + const handoffInput = callArg(readGatewayRestartHandoffSync) as NodeJS.ProcessEnv; expect(handoffInput.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-daemon"); expect(handoffInput.OPENCLAW_CONFIG_PATH).toBe("/tmp/openclaw-daemon/openclaw.json"); expect(status.service.restartHandoff?.reason).toBe("plugin source changed"); @@ -558,7 +560,7 @@ describe("gatherDaemonStatus", () => { deep: false, }); - expect(callArg<{ password?: string }>(callGatewayStatusProbe).password).toBe( + expect((callArg(callGatewayStatusProbe) as { password?: string }).password).toBe( "daemon-secretref-password", ); // pragma: allowlist secret }); @@ -587,7 +589,7 @@ describe("gatherDaemonStatus", () => { deep: false, }); - expect(callArg<{ token?: string }>(callGatewayStatusProbe).token).toBe( + expect((callArg(callGatewayStatusProbe) as { token?: string }).token).toBe( "daemon-secretref-token", ); }); @@ -616,7 +618,7 @@ describe("gatherDaemonStatus", () => { deep: false, }); - const probeInput = callArg<{ token?: string; password?: string }>(callGatewayStatusProbe); + const probeInput = callArg(callGatewayStatusProbe) as { token?: string; password?: string }; expect(probeInput.token).toBe("daemon-token"); expect(probeInput.password).toBeUndefined(); }); @@ -644,7 +646,7 @@ describe("gatherDaemonStatus", () => { deep: false, }); - const probeInput = callArg<{ token?: string; password?: string }>(callGatewayStatusProbe); + const probeInput = callArg(callGatewayStatusProbe) as { token?: string; password?: string }; expect(probeInput.token).toBeUndefined(); expect(probeInput.password).toBeUndefined(); expect(status.rpc?.authWarning).toBeUndefined(); @@ -709,7 +711,7 @@ describe("gatherDaemonStatus", () => { deep: false, }); - const probeInput = callArg<{ token?: string; password?: string }>(callGatewayStatusProbe); + const probeInput = callArg(callGatewayStatusProbe) as { token?: string; password?: string }; expect(probeInput.token).toBeUndefined(); expect(probeInput.password).toBe("env-password"); // pragma: allowlist secret }); @@ -750,7 +752,7 @@ describe("gatherDaemonStatus", () => { deep: false, }); - expect(callArg<{ port?: number }>(inspectGatewayRestart).port).toBe(19001); + expect((callArg(inspectGatewayRestart) as { port?: number }).port).toBe(19001); expect(status.health).toEqual({ healthy: false, staleGatewayPids: [9000], diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index b9deb42f667..88ad3e57496 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -196,20 +196,20 @@ describe("gateway run option collisions", () => { await sharedProgram.parseAsync(argv, { from: "user" }); } - function callArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): T { + function callArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown { const call = mock.mock.calls[index]; expect(call).toBeDefined(); - return call?.[argIndex] as T; + return call?.[argIndex]; } function gatewayStartOptions(index = 0) { expect(startGatewayServer.mock.calls[index]?.[0]).toBe(18789); - return callArg<{ + return callArg(startGatewayServer, index, 1) as { auth?: { mode?: string; token?: string; password?: string }; bind?: string; startupConfigSnapshotRead?: { snapshot?: Record }; startupStartedAt?: number; - }>(startGatewayServer, index, 1); + }; } function expectAuthOverrideMode(mode: string) { @@ -230,9 +230,9 @@ describe("gateway run option collisions", () => { expect(forceFreePortAndWait.mock.calls[0]?.[0]).toBe(18789); expect(waitForPortBindable.mock.calls[0]?.[0]).toBe(18789); - expect(callArg<{ intervalMs?: number; timeoutMs?: number }>(waitForPortBindable, 0, 1)).toEqual( - { intervalMs: 150, timeoutMs: 3000 }, - ); + expect( + callArg(waitForPortBindable, 0, 1) as { intervalMs?: number; timeoutMs?: number }, + ).toEqual({ intervalMs: 150, timeoutMs: 3000 }); expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full"); expect(gatewayStartOptions().auth?.token).toBe("tok_run"); }); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index fdfb6755bce..70674f9b9a3 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -403,7 +403,7 @@ const invokeToolsRpc = async (params: Record, scopes = ["operat const respond = vi.fn(); await toolsInvokeHandlers["tools.invoke"]({ params, - respond: respond as never, + respond, context: { getRuntimeConfig: () => cfg } as never, client: { connect: { role: "operator", scopes } } as never, req: { type: "req", id: "req-rpc-1", method: "tools.invoke" },