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 <martin_cleary@yahoo.co.uk>
This commit is contained in:
brokemac79
2026-05-10 17:11:32 +01:00
committed by GitHub
parent dee3d58c8b
commit a67753cc25
12 changed files with 62 additions and 41 deletions

View File

@@ -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/<model>` 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.

View File

@@ -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"));

View File

@@ -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");
});

View File

@@ -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);

View File

@@ -15,7 +15,7 @@ import {
const { runTavilySearch, runTavilyExtract } = vi.hoisted(() => ({
runTavilySearch: vi.fn(async (params: Record<string, unknown>) => params),
runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })),
runTavilyExtract: vi.fn(async (params: Record<string, unknown>) => ({ ok: true, params })),
}));
type TavilyExtractParams = {
@@ -56,7 +56,10 @@ describe("tavily tools", () => {
runTavilySearch.mockReset();
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => params);
runTavilyExtract.mockReset();
runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params }));
runTavilyExtract.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
vi.unstubAllEnvs();
});

View File

@@ -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 {

View File

@@ -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({

View File

@@ -100,10 +100,10 @@ function expectPreparedModelResult(
}
}
function callArg<T>(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__");

View File

@@ -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.";

View File

@@ -182,10 +182,10 @@ vi.mock("./restart-health.js", () => ({
inspectGatewayRestart: (opts: unknown) => inspectGatewayRestart(opts),
}));
function callArg<T>(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<NodeJS.ProcessEnv>(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],

View File

@@ -196,20 +196,20 @@ describe("gateway run option collisions", () => {
await sharedProgram.parseAsync(argv, { from: "user" });
}
function callArg<T>(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<string, unknown> };
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");
});

View File

@@ -403,7 +403,7 @@ const invokeToolsRpc = async (params: Record<string, unknown>, 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" },