Files
openclaw/src/agents/subagent-spawn.thread-binding.test.ts
lukeboyett c39314c14a fix(agents): prefer target agent's bound Matrix account for subagent spawns (#67508)
Merged via squash.

Prepared head SHA: 9300111038
Co-authored-by: lukeboyett <46942646+lukeboyett@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-18 14:02:53 -04:00

295 lines
9.3 KiB
TypeScript

import os from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createSubagentSpawnTestConfig,
installSessionStoreCaptureMock,
loadSubagentSpawnModuleForTest,
} from "./subagent-spawn.test-helpers.js";
import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gateway.js";
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
updateSessionStoreMock: vi.fn(),
registerSubagentRunMock: vi.fn(),
emitSessionLifecycleEventMock: vi.fn(),
hookRunner: {
hasHooks: vi.fn(),
runSubagentSpawning: vi.fn(),
},
}));
describe("spawnSubagentDirect thread binding delivery", () => {
beforeEach(() => {
vi.resetModules();
hoisted.callGatewayMock.mockReset();
hoisted.updateSessionStoreMock.mockReset();
hoisted.registerSubagentRunMock.mockReset();
hoisted.emitSessionLifecycleEventMock.mockReset();
hoisted.hookRunner.hasHooks.mockReset();
hoisted.hookRunner.runSubagentSpawning.mockReset();
installAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
});
it("passes the target agent's bound account to thread binding hooks", async () => {
const boundRoom = "!room:example.org";
let hookRequester:
| { channel?: string; accountId?: string; to?: string; threadId?: string | number }
| undefined;
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockImplementation(async (event: unknown) => {
hookRequester = (
event as {
requester?: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
}
).requester;
return {
status: "ok",
threadBindingReady: true,
deliveryOrigin: {
channel: "matrix",
to: `room:${boundRoom}`,
threadId: "$thread-root",
},
};
});
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock: hoisted.callGatewayMock,
loadConfig: () =>
createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
workspace: os.tmpdir(),
subagents: {
allowAgents: ["bot-alpha"],
},
},
list: [
{ id: "main", workspace: "/tmp/workspace-main" },
{ id: "bot-alpha", workspace: "/tmp/workspace-bot-alpha" },
],
},
bindings: [
{
type: "route",
agentId: "bot-alpha",
match: {
channel: "matrix",
peer: {
kind: "channel",
id: boundRoom,
},
accountId: "bot-alpha",
},
},
],
}),
updateSessionStoreMock: hoisted.updateSessionStoreMock,
registerSubagentRunMock: hoisted.registerSubagentRunMock,
emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock,
hookRunner: hoisted.hookRunner,
resolveSubagentSpawnModelSelection: () => "openai-codex/gpt-5.4",
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
});
const result = await spawnSubagentDirect(
{
task: "reply with a marker",
agentId: "bot-alpha",
thread: true,
mode: "session",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "matrix",
agentAccountId: "bot-beta",
agentTo: `room:${boundRoom}`,
},
);
expect(result.status).toBe("accepted");
expect(hookRequester).toMatchObject({
channel: "matrix",
accountId: "bot-alpha",
to: `room:${boundRoom}`,
});
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record<string, unknown> } | undefined;
expect(agentCall?.params).toMatchObject({
channel: "matrix",
accountId: "bot-alpha",
to: `room:${boundRoom}`,
threadId: "$thread-root",
deliver: true,
});
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
expect.objectContaining({
requesterOrigin: {
channel: "matrix",
accountId: "bot-alpha",
to: `room:${boundRoom}`,
threadId: "$thread-root",
},
}),
);
});
it("seeds a thread-bound child session from the binding created during spawn", async () => {
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
status: "ok",
threadBindingReady: true,
deliveryOrigin: {
channel: "matrix",
accountId: "sut",
to: "room:!room:example",
threadId: "$thread-root",
},
});
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock: hoisted.callGatewayMock,
loadConfig: () =>
createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
workspace: os.tmpdir(),
},
list: [{ id: "main", workspace: "/tmp/workspace-main" }],
},
}),
updateSessionStoreMock: hoisted.updateSessionStoreMock,
registerSubagentRunMock: hoisted.registerSubagentRunMock,
emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock,
hookRunner: hoisted.hookRunner,
resolveSubagentSpawnModelSelection: () => "openai-codex/gpt-5.4",
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
});
const result = await spawnSubagentDirect(
{
task: "reply with a marker",
thread: true,
mode: "session",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "matrix",
agentAccountId: "sut",
agentTo: "room:!room:example",
},
);
expect(result.status).toBe("accepted");
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record<string, unknown> } | undefined;
expect(agentCall?.params).toMatchObject({
channel: "matrix",
accountId: "sut",
to: "room:!room:example",
threadId: "$thread-root",
deliver: true,
});
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
expect.objectContaining({
requesterOrigin: {
channel: "matrix",
accountId: "sut",
to: "room:!room:example",
threadId: "$thread-root",
},
expectsCompletionMessage: false,
spawnMode: "session",
}),
);
});
it("keeps completion announcements when only a generic binding is available", async () => {
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
status: "ok",
threadBindingReady: true,
});
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock: hoisted.callGatewayMock,
loadConfig: () =>
createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
workspace: os.tmpdir(),
},
list: [{ id: "main", workspace: "/tmp/workspace-main" }],
},
}),
updateSessionStoreMock: hoisted.updateSessionStoreMock,
registerSubagentRunMock: hoisted.registerSubagentRunMock,
emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock,
hookRunner: hoisted.hookRunner,
getSessionBindingService: () => ({
listBySession: () => [
{
status: "active",
conversation: {
channel: "feishu",
accountId: "work",
conversationId: "oc_dm_chat_1",
},
},
],
}),
resolveConversationDeliveryTarget: () => ({
to: "channel:oc_dm_chat_1",
}),
resolveSubagentSpawnModelSelection: () => "openai-codex/gpt-5.4",
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
});
const result = await spawnSubagentDirect(
{
task: "reply with a marker",
thread: true,
mode: "session",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "matrix",
agentAccountId: "sut",
agentTo: "room:!parent:example",
},
);
expect(result.status).toBe("accepted");
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record<string, unknown> } | undefined;
expect(agentCall?.params).toMatchObject({
channel: "matrix",
accountId: "sut",
to: "room:!parent:example",
deliver: false,
});
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
expect.objectContaining({
expectsCompletionMessage: true,
requesterOrigin: {
channel: "matrix",
accountId: "sut",
to: "room:!parent:example",
},
}),
);
});
});