mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 16:44:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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__");
|
||||
|
||||
@@ -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.";
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user