diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index d575c870a8c..9d0ea0dc991 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -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, diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index 3f65ea0e47f..b117f06d600 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -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[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); } diff --git a/src/agents/openclaw-tools.subagents.test-harness.ts b/src/agents/openclaw-tools.subagents.test-harness.ts index 44b6ea79118..4a832738ab6 100644 --- a/src/agents/openclaw-tools.subagents.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.test-harness.ts @@ -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), })); diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index 89004289369..4330c8115cd 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -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({ diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 34313d86ddb..ba49d860d13 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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[1], +) { + return await subagentSpawnDeps.updateSessionStore(storePath, mutator); +} + +async function callSubagentGateway( + params: Parameters[0], +): Promise>> { + return await subagentSpawnDeps.callGateway(params); +} + +function loadSubagentConfig() { + return subagentSpawnDeps.loadConfig(); +} + async function persistInitialChildSessionRuntimeModel(params: { cfg: ReturnType; 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 { 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): Promise => { 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 = overrides + ? { + ...defaultSubagentSpawnDeps, + ...overrides, + } + : defaultSubagentSpawnDeps; + }, +};