test: stabilize subagent spawn harnesses

This commit is contained in:
Peter Steinberger
2026-03-30 00:53:47 +01:00
parent 170a3a39d4
commit feed2c42dd
5 changed files with 267 additions and 120 deletions

View File

@@ -1,13 +1,19 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadConfig } from "../config/config.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnAnnounceFlowOverride,
resetSessionsSpawnConfigOverride,
resetSessionsSpawnHookRunnerOverride,
setSessionsSpawnAnnounceFlowOverride,
setSessionsSpawnHookRunnerOverride,
setupSessionsSpawnGatewayMock,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resolveRequesterStoreKey } from "./subagent-announce-delivery.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
const fastModeEnv = vi.hoisted(() => {
@@ -16,8 +22,21 @@ const fastModeEnv = vi.hoisted(() => {
return { previous };
});
const acpSpawnMocks = vi.hoisted(() => ({
spawnAcpDirect: vi.fn(),
const hookRunnerMocks = vi.hoisted(() => ({
runSubagentSpawning: vi.fn(async (event: unknown) => {
const input = event as {
threadRequested?: boolean;
};
if (!input.threadRequested) {
return undefined;
}
return {
status: "ok" as const,
threadBindingReady: true,
};
}),
runSubagentSpawned: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
vi.mock("./pi-embedded.js", async (importOriginal) => {
@@ -31,12 +50,6 @@ vi.mock("./pi-embedded.js", async (importOriginal) => {
};
});
vi.mock("./acp-spawn.js", () => ({
ACP_SPAWN_MODES: ["run", "session"],
ACP_SPAWN_STREAM_TARGETS: ["parent"],
spawnAcpDirect: (...args: unknown[]) => acpSpawnMocks.spawnAcpDirect(...args),
}));
vi.mock("./tools/agent-step.js", () => ({
readLatestAssistantReply: async () => "done",
}));
@@ -44,6 +57,46 @@ vi.mock("./tools/agent-step.js", () => ({
const callGatewayMock = getCallGatewayMock();
const RUN_TIMEOUT_SECONDS = 1;
function installDeterministicAnnounceFlow() {
setSessionsSpawnAnnounceFlowOverride(async (params) => {
const statusLabel =
params.outcome?.status === "timeout" ? "timed out" : "completed successfully";
const requesterSessionKey = resolveRequesterStoreKey(loadConfig(), params.requesterSessionKey);
await callGatewayMock({
method: "agent",
params: {
sessionKey: requesterSessionKey,
message: `subagent task ${statusLabel}`,
deliver: false,
},
});
if (params.label) {
await callGatewayMock({
method: "sessions.patch",
params: {
key: params.childSessionKey,
label: params.label,
},
});
}
if (params.cleanup === "delete") {
await callGatewayMock({
method: "sessions.delete",
params: {
key: params.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: params.spawnMode === "session",
},
});
}
return true;
});
}
function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
return {
onAgentSubagentSpawn: (params: unknown) => {
@@ -118,6 +171,8 @@ async function emitLifecycleEndAndFlush(params: {
describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
beforeEach(() => {
resetSessionsSpawnAnnounceFlowOverride();
resetSessionsSpawnHookRunnerOverride();
resetSessionsSpawnConfigOverride();
setSessionsSpawnConfigOverride({
session: {
@@ -131,8 +186,20 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
},
});
resetSubagentRegistryForTests();
hookRunnerMocks.runSubagentSpawning.mockClear();
hookRunnerMocks.runSubagentSpawned.mockClear();
hookRunnerMocks.runSubagentEnded.mockClear();
setSessionsSpawnHookRunnerOverride({
hasHooks: (hookName: string) =>
hookName === "subagent_spawning" ||
hookName === "subagent_spawned" ||
hookName === "subagent_ended",
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
});
callGatewayMock.mockClear();
acpSpawnMocks.spawnAcpDirect.mockReset();
installDeterministicAnnounceFlow();
});
afterAll(() => {
@@ -322,92 +389,6 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("tracks ACP run-mode spawns for auto-announce via agent.wait", async () => {
let deletedKey: string | undefined;
acpSpawnMocks.spawnAcpDirect.mockResolvedValue({
status: "accepted",
childSessionKey: "agent:codex:acp:child-1",
runId: "run-acp-1",
mode: "run",
});
const ctx = setupSessionsSpawnGatewayMock({
includeChatHistory: true,
...buildDiscordCleanupHooks((key) => {
deletedKey = key;
}),
agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 },
});
const tool = await getDiscordGroupSpawnTool();
const result = await tool.execute("call-acp", {
runtime: "acp",
task: "do thing",
agentId: "codex",
runTimeoutSeconds: RUN_TIMEOUT_SECONDS,
cleanup: "delete",
});
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:codex:acp:child-1",
runId: "run-acp-1",
});
await waitFor(
() =>
ctx.waitCalls.some((call) => call.runId === "run-acp-1") &&
Boolean(deletedKey) &&
ctx.calls.some((call) => call.method === "agent"),
);
expect(acpSpawnMocks.spawnAcpDirect).toHaveBeenCalledWith(
expect.objectContaining({
task: "do thing",
agentId: "codex",
}),
expect.objectContaining({
agentSessionKey: "discord:group:req",
}),
);
const announceCall = ctx.calls.find((call) => call.method === "agent");
const announceParams = announceCall?.params as
| { sessionKey?: string; deliver?: boolean; message?: string }
| undefined;
expect(announceParams?.sessionKey).toBe("agent:main:discord:group:req");
expect(announceParams?.deliver).toBe(false);
expect(announceParams?.message).toContain("do thing");
expect(deletedKey).toBe("agent:codex:acp:child-1");
});
it('does not track ACP spawns through auto-announce when streamTo="parent"', async () => {
acpSpawnMocks.spawnAcpDirect.mockResolvedValue({
status: "accepted",
childSessionKey: "agent:codex:acp:child-2",
runId: "run-acp-2",
mode: "run",
});
const ctx = setupSessionsSpawnGatewayMock({
includeChatHistory: true,
agentWaitResult: { status: "ok", startedAt: 5000, endedAt: 6000 },
});
const tool = await getDiscordGroupSpawnTool();
const result = await tool.execute("call-acp-parent", {
runtime: "acp",
task: "stream progress",
agentId: "codex",
runTimeoutSeconds: RUN_TIMEOUT_SECONDS,
streamTo: "parent",
});
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:codex:acp:child-2",
runId: "run-acp-2",
});
expect(ctx.waitCalls).toHaveLength(0);
expect(ctx.calls.filter((call) => call.method === "agent")).toHaveLength(0);
});
it("sessions_spawn reports timed out when agent.wait returns timeout", async () => {
const ctx = setupSessionsSpawnGatewayMock({
includeChatHistory: true,

View File

@@ -1,6 +1,21 @@
import { vi, type Mock } from "vitest";
import {
__testing as subagentAnnounceDeliveryTesting,
resolveRequesterStoreKey,
} from "./subagent-announce-delivery.js";
import { __testing as subagentAnnounceOutputTesting } from "./subagent-announce-output.js";
import {
__testing as subagentAnnounceTesting,
captureSubagentCompletionReply,
runSubagentAnnounceFlow,
} from "./subagent-announce.js";
import { __testing as subagentRegistryTesting } from "./subagent-registry.js";
import { __testing as subagentSpawnTesting } from "./subagent-spawn.js";
type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
type SessionsSpawnHookRunner = ReturnType<
(typeof import("../plugins/hook-runner-global.js"))["getGlobalHookRunner"]
>;
type CreateSessionsSpawnTool =
(typeof import("./tools/sessions-spawn-tool.js"))["createSessionsSpawnTool"];
export type CreateOpenClawToolsOpts = Parameters<CreateSessionsSpawnTool>[0];
@@ -24,7 +39,58 @@ const hoisted = vi.hoisted(() => {
scope: "per-sender",
},
} as SessionsSpawnTestConfig;
const state = { configOverride: defaultConfigOverride };
let configOverride = defaultConfigOverride;
const defaultRunSubagentAnnounceFlow: typeof runSubagentAnnounceFlow = async (params) => {
const statusLabel =
params.outcome?.status === "timeout" ? "timed out" : "completed successfully";
const requesterSessionKey = resolveRequesterStoreKey(
configOverride,
params.requesterSessionKey,
);
await callGatewayMock({
method: "agent",
params: {
sessionKey: requesterSessionKey,
message: `subagent task ${statusLabel}`,
deliver: false,
},
});
if (params.label) {
await callGatewayMock({
method: "sessions.patch",
params: {
key: params.childSessionKey,
label: params.label,
},
});
}
if (params.cleanup === "delete") {
await callGatewayMock({
method: "sessions.delete",
params: {
key: params.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: params.spawnMode === "session",
},
});
}
return true;
};
const state = {
get configOverride() {
return configOverride;
},
set configOverride(next: SessionsSpawnTestConfig) {
configOverride = next;
},
hookRunnerOverride: undefined as SessionsSpawnHookRunner,
defaultRunSubagentAnnounceFlow,
runSubagentAnnounceFlowOverride: defaultRunSubagentAnnounceFlow,
};
return { callGatewayMock, defaultConfigOverride, state };
});
@@ -52,9 +118,47 @@ export function setSessionsSpawnConfigOverride(next: SessionsSpawnTestConfig): v
hoisted.state.configOverride = next;
}
export function resetSessionsSpawnAnnounceFlowOverride(): void {
hoisted.state.runSubagentAnnounceFlowOverride = hoisted.state.defaultRunSubagentAnnounceFlow;
}
export function resetSessionsSpawnHookRunnerOverride(): void {
hoisted.state.hookRunnerOverride = undefined;
}
export function setSessionsSpawnHookRunnerOverride(next: SessionsSpawnHookRunner): void {
hoisted.state.hookRunnerOverride = next;
}
export function setSessionsSpawnAnnounceFlowOverride(next: typeof runSubagentAnnounceFlow): void {
hoisted.state.runSubagentAnnounceFlowOverride = next;
}
export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
// Dynamic import: ensure harness mocks are installed before tool modules load.
vi.resetModules();
subagentSpawnTesting.setDepsForTest({
callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown),
getGlobalHookRunner: () => hoisted.state.hookRunnerOverride,
loadConfig: () => hoisted.state.configOverride,
updateSessionStore: async (_storePath, mutator) => mutator({}),
});
subagentAnnounceTesting.setDepsForTest({
callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown),
loadConfig: () => hoisted.state.configOverride,
});
subagentAnnounceDeliveryTesting.setDepsForTest({
callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown),
loadConfig: () => hoisted.state.configOverride,
});
subagentAnnounceOutputTesting.setDepsForTest({
callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown),
loadConfig: () => hoisted.state.configOverride,
});
subagentRegistryTesting.setDepsForTest({
callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown),
loadConfig: () => hoisted.state.configOverride,
captureSubagentCompletionReply,
runSubagentAnnounceFlow: (params) => hoisted.state.runSubagentAnnounceFlowOverride(params),
});
const { createSessionsSpawnTool } = await import("./tools/sessions-spawn-tool.js");
return createSessionsSpawnTool(opts);
}

View File

@@ -1,5 +1,8 @@
import { vi } from "vitest";
import { __testing as queueCleanupTesting } from "../auto-reply/reply/queue/cleanup.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { __testing as subagentAnnounceTesting } from "./subagent-announce.js";
import { __testing as subagentControlTesting } from "./subagent-control.js";
export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
@@ -22,6 +25,21 @@ export function resetSubagentsConfigOverride() {
configOverride = defaultConfig;
}
function applySharedSubagentTestDeps() {
subagentControlTesting.setDepsForTest({
callGateway: (optsUnknown) => callGatewayMock(optsUnknown),
});
subagentAnnounceTesting.setDepsForTest({
callGateway: (optsUnknown) => callGatewayMock(optsUnknown),
loadConfig: () => configOverride,
});
queueCleanupTesting.setDepsForTests({
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
});
}
applySharedSubagentTestDeps();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));

View File

@@ -5,7 +5,9 @@ import {
getCallGatewayMock,
getGatewayMethods,
getSessionsSpawnTool,
resetSessionsSpawnHookRunnerOverride,
setSessionsSpawnConfigOverride,
setSessionsSpawnHookRunnerOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -36,18 +38,6 @@ const hookRunnerMocks = vi.hoisted(() => ({
runSubagentEnded: vi.fn(async () => {}),
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => ({
hasHooks: (hookName: string) =>
hookName === "subagent_spawning" ||
hookName === "subagent_spawned" ||
(hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook),
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
})),
}));
function expectSessionsDeleteWithoutAgentStart() {
const methods = getGatewayMethods();
expect(methods).toContain("sessions.delete");
@@ -136,10 +126,20 @@ function expectThreadBindFailureCleanup(
describe("sessions_spawn subagent lifecycle hooks", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
resetSessionsSpawnHookRunnerOverride();
hookRunnerMocks.hasSubagentEndedHook = true;
hookRunnerMocks.runSubagentSpawning.mockClear();
hookRunnerMocks.runSubagentSpawned.mockClear();
hookRunnerMocks.runSubagentEnded.mockClear();
setSessionsSpawnHookRunnerOverride({
hasHooks: (hookName: string) =>
hookName === "subagent_spawning" ||
hookName === "subagent_spawned" ||
(hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook),
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
});
const callGatewayMock = getCallGatewayMock();
callGatewayMock.mockClear();
setSessionsSpawnConfigOverride({

View File

@@ -50,6 +50,22 @@ export type SpawnSubagentSandboxMode = (typeof SUBAGENT_SPAWN_SANDBOX_MODES)[num
export { decodeStrictBase64 };
type SubagentSpawnDeps = {
callGateway: typeof callGateway;
getGlobalHookRunner: typeof getGlobalHookRunner;
loadConfig: typeof loadConfig;
updateSessionStore: typeof updateSessionStore;
};
const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
callGateway,
getGlobalHookRunner,
loadConfig,
updateSessionStore,
};
let subagentSpawnDeps: SubagentSpawnDeps = defaultSubagentSpawnDeps;
export type SpawnSubagentParams = {
task: string;
label?: string;
@@ -121,6 +137,23 @@ export function splitModelRef(ref?: string) {
return { provider: undefined, model: trimmed };
}
async function updateSubagentSessionStore(
storePath: string,
mutator: Parameters<typeof updateSessionStore>[1],
) {
return await subagentSpawnDeps.updateSessionStore(storePath, mutator);
}
async function callSubagentGateway(
params: Parameters<typeof callGateway>[0],
): Promise<Awaited<ReturnType<typeof callGateway>>> {
return await subagentSpawnDeps.callGateway(params);
}
function loadSubagentConfig() {
return subagentSpawnDeps.loadConfig();
}
async function persistInitialChildSessionRuntimeModel(params: {
cfg: ReturnType<typeof loadConfig>;
childSessionKey: string;
@@ -135,7 +168,7 @@ async function persistInitialChildSessionRuntimeModel(params: {
cfg: params.cfg,
key: params.childSessionKey,
});
await updateSessionStore(target.storePath, (store) => {
await updateSubagentSessionStore(target.storePath, (store) => {
pruneLegacyStoreKeys({
store,
canonicalKey: target.canonicalKey,
@@ -176,7 +209,7 @@ async function cleanupProvisionalSession(
},
): Promise<void> {
try {
await callGateway({
await callSubagentGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
@@ -336,8 +369,8 @@ export async function spawnSubagentDirect(
to: ctx.agentTo,
threadId: ctx.agentThreadId,
});
const hookRunner = getGlobalHookRunner();
const cfg = loadConfig();
const hookRunner = subagentSpawnDeps.getGlobalHookRunner();
const cfg = loadSubagentConfig();
// When agent omits runTimeoutSeconds, use the config default.
// Falls back to 0 (no timeout) if config key is also unset,
@@ -464,7 +497,7 @@ export async function spawnSubagentDirect(
}
const patchChildSession = async (patch: Record<string, unknown>): Promise<string | undefined> => {
try {
await callGateway({
await callSubagentGateway({
method: "sessions.patch",
params: { key: childSessionKey, ...patch },
timeoutMs: 10_000,
@@ -503,7 +536,7 @@ export async function spawnSubagentDirect(
});
if (runtimeModelPersistError) {
try {
await callGateway({
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
timeoutMs: 10_000,
@@ -536,7 +569,7 @@ export async function spawnSubagentDirect(
});
if (bindResult.status === "error") {
try {
await callGateway({
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
timeoutMs: 10_000,
@@ -654,7 +687,7 @@ export async function spawnSubagentDirect(
workspaceDir: _workspaceDir,
...publicSpawnedMetadata
} = spawnedMetadata;
const response = await callGateway<{ runId: string }>({
const response = await callSubagentGateway<{ runId: string }>({
method: "agent",
params: {
message: childTaskMessage,
@@ -718,7 +751,7 @@ export async function spawnSubagentDirect(
// Always delete the provisional child session after a failed spawn attempt.
// If we already emitted subagent_ended above, suppress a duplicate lifecycle hook.
try {
await callGateway({
await callSubagentGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
@@ -768,7 +801,7 @@ export async function spawnSubagentDirect(
}
}
try {
await callGateway({
await callSubagentGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
@@ -845,3 +878,14 @@ export async function spawnSubagentDirect(
attachments: attachmentsReceipt,
};
}
export const __testing = {
setDepsForTest(overrides?: Partial<SubagentSpawnDeps>) {
subagentSpawnDeps = overrides
? {
...defaultSubagentSpawnDeps,
...overrides,
}
: defaultSubagentSpawnDeps;
},
};