mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 12:21:25 +00:00
273 lines
7.8 KiB
TypeScript
273 lines
7.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createSubagentSpawnTestConfig,
|
|
loadSubagentSpawnModuleForTest,
|
|
setupAcceptedSubagentGatewayMock,
|
|
} from "./subagent-spawn.test-helpers.js";
|
|
|
|
type TestAgentConfig = {
|
|
id?: string;
|
|
workspace?: string;
|
|
subagents?: {
|
|
allowAgents?: string[];
|
|
};
|
|
};
|
|
|
|
type TestConfig = {
|
|
agents?: {
|
|
list?: TestAgentConfig[];
|
|
};
|
|
};
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
callGatewayMock: vi.fn(),
|
|
configOverride: {} as Record<string, unknown>,
|
|
registerSubagentRunMock: vi.fn(),
|
|
hookRunner: {
|
|
hasHooks: vi.fn(() => false),
|
|
runSubagentSpawning: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
|
|
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
|
|
|
|
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
|
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
|
"@mariozechner/pi-ai/oauth",
|
|
);
|
|
return {
|
|
...actual,
|
|
getOAuthApiKey: () => "",
|
|
getOAuthProviders: () => [],
|
|
};
|
|
});
|
|
|
|
function createConfigOverride(overrides?: Record<string, unknown>) {
|
|
return createSubagentSpawnTestConfig("/tmp/workspace-main", {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
workspace: "/tmp/workspace-main",
|
|
},
|
|
],
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function resolveTestAgentConfig(cfg: Record<string, unknown>, agentId: string) {
|
|
return (cfg as TestConfig).agents?.list?.find((entry) => entry.id === agentId);
|
|
}
|
|
|
|
function resolveTestAgentWorkspace(cfg: Record<string, unknown>, agentId: string) {
|
|
return resolveTestAgentConfig(cfg, agentId)?.workspace ?? `/tmp/workspace-${agentId}`;
|
|
}
|
|
|
|
function getRegisteredRun() {
|
|
return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
}
|
|
|
|
async function expectAcceptedWorkspace(params: { agentId: string; expectedWorkspaceDir: string }) {
|
|
const result = await spawnSubagentDirect(
|
|
{
|
|
task: "inspect workspace",
|
|
agentId: params.agentId,
|
|
},
|
|
{
|
|
agentSessionKey: "agent:main:main",
|
|
agentChannel: "telegram",
|
|
agentAccountId: "123",
|
|
agentTo: "456",
|
|
workspaceDir: "/tmp/requester-workspace",
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe("accepted");
|
|
expect(getRegisteredRun()).toMatchObject({
|
|
workspaceDir: params.expectedWorkspaceDir,
|
|
});
|
|
}
|
|
|
|
describe("spawnSubagentDirect workspace inheritance", () => {
|
|
beforeEach(async () => {
|
|
({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
|
|
callGatewayMock: hoisted.callGatewayMock,
|
|
loadConfig: () => hoisted.configOverride,
|
|
registerSubagentRunMock: hoisted.registerSubagentRunMock,
|
|
hookRunner: hoisted.hookRunner,
|
|
resolveAgentConfig: resolveTestAgentConfig,
|
|
resolveAgentWorkspaceDir: resolveTestAgentWorkspace,
|
|
}));
|
|
resetSubagentRegistryForTests();
|
|
hoisted.callGatewayMock.mockClear();
|
|
hoisted.registerSubagentRunMock.mockClear();
|
|
hoisted.hookRunner.hasHooks.mockReset();
|
|
hoisted.hookRunner.hasHooks.mockImplementation(() => false);
|
|
hoisted.hookRunner.runSubagentSpawning.mockReset();
|
|
hoisted.configOverride = createConfigOverride();
|
|
setupAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
|
|
});
|
|
|
|
it("uses the target agent workspace for cross-agent spawns", async () => {
|
|
hoisted.configOverride = createConfigOverride({
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
workspace: "/tmp/workspace-main",
|
|
subagents: {
|
|
allowAgents: ["ops"],
|
|
},
|
|
},
|
|
{
|
|
id: "ops",
|
|
workspace: "/tmp/workspace-ops",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await expectAcceptedWorkspace({
|
|
agentId: "ops",
|
|
expectedWorkspaceDir: "/tmp/workspace-ops",
|
|
});
|
|
});
|
|
|
|
it("preserves the inherited workspace for same-agent spawns", async () => {
|
|
await expectAcceptedWorkspace({
|
|
agentId: "main",
|
|
expectedWorkspaceDir: "/tmp/requester-workspace",
|
|
});
|
|
});
|
|
|
|
it("deletes the provisional child session when a non-thread subagent start fails", async () => {
|
|
hoisted.callGatewayMock.mockImplementation(
|
|
async (request: {
|
|
method?: string;
|
|
params?: { key?: string; deleteTranscript?: boolean; emitLifecycleHooks?: boolean };
|
|
}) => {
|
|
if (request.method === "sessions.patch") {
|
|
return { ok: true };
|
|
}
|
|
if (request.method === "agent") {
|
|
throw new Error("spawn startup failed");
|
|
}
|
|
if (request.method === "sessions.delete") {
|
|
return { ok: true };
|
|
}
|
|
return {};
|
|
},
|
|
);
|
|
|
|
const result = await spawnSubagentDirect(
|
|
{
|
|
task: "fail after provisional session creation",
|
|
},
|
|
{
|
|
agentSessionKey: "agent:main:main",
|
|
agentChannel: "discord",
|
|
agentAccountId: "acct-1",
|
|
agentTo: "user-1",
|
|
workspaceDir: "/tmp/requester-workspace",
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: "error",
|
|
error: "spawn startup failed",
|
|
});
|
|
expect(result.childSessionKey).toMatch(/^agent:main:subagent:/);
|
|
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
|
|
|
|
const deleteCall = hoisted.callGatewayMock.mock.calls.find(
|
|
([request]) => (request as { method?: string }).method === "sessions.delete",
|
|
)?.[0] as
|
|
| {
|
|
params?: {
|
|
key?: string;
|
|
deleteTranscript?: boolean;
|
|
emitLifecycleHooks?: boolean;
|
|
};
|
|
}
|
|
| undefined;
|
|
|
|
expect(deleteCall?.params).toMatchObject({
|
|
key: result.childSessionKey,
|
|
deleteTranscript: true,
|
|
emitLifecycleHooks: false,
|
|
});
|
|
});
|
|
|
|
it("keeps lifecycle hooks enabled when registerSubagentRun fails after thread binding succeeds", async () => {
|
|
hoisted.hookRunner.hasHooks.mockImplementation((name?: string) => name === "subagent_spawning");
|
|
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
|
|
status: "ok",
|
|
threadBindingReady: true,
|
|
});
|
|
hoisted.registerSubagentRunMock.mockImplementation(() => {
|
|
throw new Error("registry unavailable");
|
|
});
|
|
hoisted.callGatewayMock.mockImplementation(
|
|
async (request: {
|
|
method?: string;
|
|
params?: { key?: string; deleteTranscript?: boolean; emitLifecycleHooks?: boolean };
|
|
}) => {
|
|
if (request.method === "sessions.patch") {
|
|
return { ok: true };
|
|
}
|
|
if (request.method === "agent") {
|
|
return { runId: "run-thread-register-fail" };
|
|
}
|
|
if (request.method === "sessions.delete") {
|
|
return { ok: true };
|
|
}
|
|
return {};
|
|
},
|
|
);
|
|
|
|
const result = await spawnSubagentDirect(
|
|
{
|
|
task: "fail after register with thread binding",
|
|
thread: true,
|
|
mode: "session",
|
|
},
|
|
{
|
|
agentSessionKey: "agent:main:main",
|
|
agentChannel: "discord",
|
|
agentAccountId: "acct-1",
|
|
agentTo: "user-1",
|
|
workspaceDir: "/tmp/requester-workspace",
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: "error",
|
|
error: "Failed to register subagent run: registry unavailable",
|
|
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
|
|
runId: "run-thread-register-fail",
|
|
});
|
|
|
|
const deleteCall = hoisted.callGatewayMock.mock.calls.findLast(
|
|
([request]) => (request as { method?: string }).method === "sessions.delete",
|
|
)?.[0] as
|
|
| {
|
|
params?: {
|
|
key?: string;
|
|
deleteTranscript?: boolean;
|
|
emitLifecycleHooks?: boolean;
|
|
};
|
|
}
|
|
| undefined;
|
|
|
|
expect(deleteCall?.params).toMatchObject({
|
|
key: result.childSessionKey,
|
|
deleteTranscript: true,
|
|
emitLifecycleHooks: true,
|
|
});
|
|
});
|
|
});
|