mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 00:10:21 +00:00
test: stabilize subagent spawn harnesses
This commit is contained in:
@@ -1,13 +1,19 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import "./test-helpers/fast-core-tools.js";
|
import "./test-helpers/fast-core-tools.js";
|
||||||
import {
|
import {
|
||||||
getCallGatewayMock,
|
getCallGatewayMock,
|
||||||
getSessionsSpawnTool,
|
getSessionsSpawnTool,
|
||||||
|
resetSessionsSpawnAnnounceFlowOverride,
|
||||||
resetSessionsSpawnConfigOverride,
|
resetSessionsSpawnConfigOverride,
|
||||||
|
resetSessionsSpawnHookRunnerOverride,
|
||||||
|
setSessionsSpawnAnnounceFlowOverride,
|
||||||
|
setSessionsSpawnHookRunnerOverride,
|
||||||
setupSessionsSpawnGatewayMock,
|
setupSessionsSpawnGatewayMock,
|
||||||
setSessionsSpawnConfigOverride,
|
setSessionsSpawnConfigOverride,
|
||||||
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
||||||
|
import { resolveRequesterStoreKey } from "./subagent-announce-delivery.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
const fastModeEnv = vi.hoisted(() => {
|
const fastModeEnv = vi.hoisted(() => {
|
||||||
@@ -16,8 +22,21 @@ const fastModeEnv = vi.hoisted(() => {
|
|||||||
return { previous };
|
return { previous };
|
||||||
});
|
});
|
||||||
|
|
||||||
const acpSpawnMocks = vi.hoisted(() => ({
|
const hookRunnerMocks = vi.hoisted(() => ({
|
||||||
spawnAcpDirect: vi.fn(),
|
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) => {
|
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", () => ({
|
vi.mock("./tools/agent-step.js", () => ({
|
||||||
readLatestAssistantReply: async () => "done",
|
readLatestAssistantReply: async () => "done",
|
||||||
}));
|
}));
|
||||||
@@ -44,6 +57,46 @@ vi.mock("./tools/agent-step.js", () => ({
|
|||||||
const callGatewayMock = getCallGatewayMock();
|
const callGatewayMock = getCallGatewayMock();
|
||||||
const RUN_TIMEOUT_SECONDS = 1;
|
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) {
|
function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
|
||||||
return {
|
return {
|
||||||
onAgentSubagentSpawn: (params: unknown) => {
|
onAgentSubagentSpawn: (params: unknown) => {
|
||||||
@@ -118,6 +171,8 @@ async function emitLifecycleEndAndFlush(params: {
|
|||||||
|
|
||||||
describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
resetSessionsSpawnAnnounceFlowOverride();
|
||||||
|
resetSessionsSpawnHookRunnerOverride();
|
||||||
resetSessionsSpawnConfigOverride();
|
resetSessionsSpawnConfigOverride();
|
||||||
setSessionsSpawnConfigOverride({
|
setSessionsSpawnConfigOverride({
|
||||||
session: {
|
session: {
|
||||||
@@ -131,8 +186,20 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
resetSubagentRegistryForTests();
|
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();
|
callGatewayMock.mockClear();
|
||||||
acpSpawnMocks.spawnAcpDirect.mockReset();
|
installDeterministicAnnounceFlow();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -322,92 +389,6 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
|||||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
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 () => {
|
it("sessions_spawn reports timed out when agent.wait returns timeout", async () => {
|
||||||
const ctx = setupSessionsSpawnGatewayMock({
|
const ctx = setupSessionsSpawnGatewayMock({
|
||||||
includeChatHistory: true,
|
includeChatHistory: true,
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { vi, type Mock } from "vitest";
|
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 SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
|
||||||
|
type SessionsSpawnHookRunner = ReturnType<
|
||||||
|
(typeof import("../plugins/hook-runner-global.js"))["getGlobalHookRunner"]
|
||||||
|
>;
|
||||||
type CreateSessionsSpawnTool =
|
type CreateSessionsSpawnTool =
|
||||||
(typeof import("./tools/sessions-spawn-tool.js"))["createSessionsSpawnTool"];
|
(typeof import("./tools/sessions-spawn-tool.js"))["createSessionsSpawnTool"];
|
||||||
export type CreateOpenClawToolsOpts = Parameters<CreateSessionsSpawnTool>[0];
|
export type CreateOpenClawToolsOpts = Parameters<CreateSessionsSpawnTool>[0];
|
||||||
@@ -24,7 +39,58 @@ const hoisted = vi.hoisted(() => {
|
|||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
},
|
},
|
||||||
} as SessionsSpawnTestConfig;
|
} 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 };
|
return { callGatewayMock, defaultConfigOverride, state };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,9 +118,47 @@ export function setSessionsSpawnConfigOverride(next: SessionsSpawnTestConfig): v
|
|||||||
hoisted.state.configOverride = next;
|
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) {
|
export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
|
||||||
// Dynamic import: ensure harness mocks are installed before tool modules load.
|
subagentSpawnTesting.setDepsForTest({
|
||||||
vi.resetModules();
|
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");
|
const { createSessionsSpawnTool } = await import("./tools/sessions-spawn-tool.js");
|
||||||
return createSessionsSpawnTool(opts);
|
return createSessionsSpawnTool(opts);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { vi } from "vitest";
|
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 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"]>;
|
export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
|
||||||
|
|
||||||
@@ -22,6 +25,21 @@ export function resetSubagentsConfigOverride() {
|
|||||||
configOverride = defaultConfig;
|
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", () => ({
|
vi.mock("../gateway/call.js", () => ({
|
||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
getCallGatewayMock,
|
getCallGatewayMock,
|
||||||
getGatewayMethods,
|
getGatewayMethods,
|
||||||
getSessionsSpawnTool,
|
getSessionsSpawnTool,
|
||||||
|
resetSessionsSpawnHookRunnerOverride,
|
||||||
setSessionsSpawnConfigOverride,
|
setSessionsSpawnConfigOverride,
|
||||||
|
setSessionsSpawnHookRunnerOverride,
|
||||||
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
|
||||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
@@ -36,18 +38,6 @@ const hookRunnerMocks = vi.hoisted(() => ({
|
|||||||
runSubagentEnded: vi.fn(async () => {}),
|
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() {
|
function expectSessionsDeleteWithoutAgentStart() {
|
||||||
const methods = getGatewayMethods();
|
const methods = getGatewayMethods();
|
||||||
expect(methods).toContain("sessions.delete");
|
expect(methods).toContain("sessions.delete");
|
||||||
@@ -136,10 +126,20 @@ function expectThreadBindFailureCleanup(
|
|||||||
describe("sessions_spawn subagent lifecycle hooks", () => {
|
describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetSubagentRegistryForTests();
|
resetSubagentRegistryForTests();
|
||||||
|
resetSessionsSpawnHookRunnerOverride();
|
||||||
hookRunnerMocks.hasSubagentEndedHook = true;
|
hookRunnerMocks.hasSubagentEndedHook = true;
|
||||||
hookRunnerMocks.runSubagentSpawning.mockClear();
|
hookRunnerMocks.runSubagentSpawning.mockClear();
|
||||||
hookRunnerMocks.runSubagentSpawned.mockClear();
|
hookRunnerMocks.runSubagentSpawned.mockClear();
|
||||||
hookRunnerMocks.runSubagentEnded.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();
|
const callGatewayMock = getCallGatewayMock();
|
||||||
callGatewayMock.mockClear();
|
callGatewayMock.mockClear();
|
||||||
setSessionsSpawnConfigOverride({
|
setSessionsSpawnConfigOverride({
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ export type SpawnSubagentSandboxMode = (typeof SUBAGENT_SPAWN_SANDBOX_MODES)[num
|
|||||||
|
|
||||||
export { decodeStrictBase64 };
|
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 = {
|
export type SpawnSubagentParams = {
|
||||||
task: string;
|
task: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -121,6 +137,23 @@ export function splitModelRef(ref?: string) {
|
|||||||
return { provider: undefined, model: trimmed };
|
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: {
|
async function persistInitialChildSessionRuntimeModel(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
childSessionKey: string;
|
childSessionKey: string;
|
||||||
@@ -135,7 +168,7 @@ async function persistInitialChildSessionRuntimeModel(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
key: params.childSessionKey,
|
key: params.childSessionKey,
|
||||||
});
|
});
|
||||||
await updateSessionStore(target.storePath, (store) => {
|
await updateSubagentSessionStore(target.storePath, (store) => {
|
||||||
pruneLegacyStoreKeys({
|
pruneLegacyStoreKeys({
|
||||||
store,
|
store,
|
||||||
canonicalKey: target.canonicalKey,
|
canonicalKey: target.canonicalKey,
|
||||||
@@ -176,7 +209,7 @@ async function cleanupProvisionalSession(
|
|||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callSubagentGateway({
|
||||||
method: "sessions.delete",
|
method: "sessions.delete",
|
||||||
params: {
|
params: {
|
||||||
key: childSessionKey,
|
key: childSessionKey,
|
||||||
@@ -336,8 +369,8 @@ export async function spawnSubagentDirect(
|
|||||||
to: ctx.agentTo,
|
to: ctx.agentTo,
|
||||||
threadId: ctx.agentThreadId,
|
threadId: ctx.agentThreadId,
|
||||||
});
|
});
|
||||||
const hookRunner = getGlobalHookRunner();
|
const hookRunner = subagentSpawnDeps.getGlobalHookRunner();
|
||||||
const cfg = loadConfig();
|
const cfg = loadSubagentConfig();
|
||||||
|
|
||||||
// When agent omits runTimeoutSeconds, use the config default.
|
// When agent omits runTimeoutSeconds, use the config default.
|
||||||
// Falls back to 0 (no timeout) if config key is also unset,
|
// 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> => {
|
const patchChildSession = async (patch: Record<string, unknown>): Promise<string | undefined> => {
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callSubagentGateway({
|
||||||
method: "sessions.patch",
|
method: "sessions.patch",
|
||||||
params: { key: childSessionKey, ...patch },
|
params: { key: childSessionKey, ...patch },
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
@@ -503,7 +536,7 @@ export async function spawnSubagentDirect(
|
|||||||
});
|
});
|
||||||
if (runtimeModelPersistError) {
|
if (runtimeModelPersistError) {
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callSubagentGateway({
|
||||||
method: "sessions.delete",
|
method: "sessions.delete",
|
||||||
params: { key: childSessionKey, emitLifecycleHooks: false },
|
params: { key: childSessionKey, emitLifecycleHooks: false },
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
@@ -536,7 +569,7 @@ export async function spawnSubagentDirect(
|
|||||||
});
|
});
|
||||||
if (bindResult.status === "error") {
|
if (bindResult.status === "error") {
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callSubagentGateway({
|
||||||
method: "sessions.delete",
|
method: "sessions.delete",
|
||||||
params: { key: childSessionKey, emitLifecycleHooks: false },
|
params: { key: childSessionKey, emitLifecycleHooks: false },
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
@@ -654,7 +687,7 @@ export async function spawnSubagentDirect(
|
|||||||
workspaceDir: _workspaceDir,
|
workspaceDir: _workspaceDir,
|
||||||
...publicSpawnedMetadata
|
...publicSpawnedMetadata
|
||||||
} = spawnedMetadata;
|
} = spawnedMetadata;
|
||||||
const response = await callGateway<{ runId: string }>({
|
const response = await callSubagentGateway<{ runId: string }>({
|
||||||
method: "agent",
|
method: "agent",
|
||||||
params: {
|
params: {
|
||||||
message: childTaskMessage,
|
message: childTaskMessage,
|
||||||
@@ -718,7 +751,7 @@ export async function spawnSubagentDirect(
|
|||||||
// Always delete the provisional child session after a failed spawn attempt.
|
// Always delete the provisional child session after a failed spawn attempt.
|
||||||
// If we already emitted subagent_ended above, suppress a duplicate lifecycle hook.
|
// If we already emitted subagent_ended above, suppress a duplicate lifecycle hook.
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callSubagentGateway({
|
||||||
method: "sessions.delete",
|
method: "sessions.delete",
|
||||||
params: {
|
params: {
|
||||||
key: childSessionKey,
|
key: childSessionKey,
|
||||||
@@ -768,7 +801,7 @@ export async function spawnSubagentDirect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await callGateway({
|
await callSubagentGateway({
|
||||||
method: "sessions.delete",
|
method: "sessions.delete",
|
||||||
params: {
|
params: {
|
||||||
key: childSessionKey,
|
key: childSessionKey,
|
||||||
@@ -845,3 +878,14 @@ export async function spawnSubagentDirect(
|
|||||||
attachments: attachmentsReceipt,
|
attachments: attachmentsReceipt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __testing = {
|
||||||
|
setDepsForTest(overrides?: Partial<SubagentSpawnDeps>) {
|
||||||
|
subagentSpawnDeps = overrides
|
||||||
|
? {
|
||||||
|
...defaultSubagentSpawnDeps,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
: defaultSubagentSpawnDeps;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user