openclaw/extensions/discord/src/subagent-hooks.test.ts
Onur 8178ea472d
feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00

431 lines
12 KiB
TypeScript

import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordSubagentHooks } from "./subagent-hooks.js";
type ThreadBindingRecord = {
accountId: string;
threadId: string;
};
type MockResolvedDiscordAccount = {
accountId: string;
config: {
threadBindings?: {
enabled?: boolean;
spawnSubagentSessions?: boolean;
};
};
};
const hookMocks = vi.hoisted(() => ({
resolveDiscordAccount: vi.fn(
(params?: { accountId?: string }): MockResolvedDiscordAccount => ({
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSubagentSessions: true,
},
},
}),
),
autoBindSpawnedDiscordSubagent: vi.fn(
async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }),
),
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
}));
vi.mock("openclaw/plugin-sdk", () => ({
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
}));
function registerHandlersForTest(
config: Record<string, unknown> = {
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
},
},
},
},
) {
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
const api = {
config,
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
handlers.set(hookName, handler);
},
} as unknown as OpenClawPluginApi;
registerDiscordSubagentHooks(api);
return handlers;
}
describe("discord subagent hook handlers", () => {
beforeEach(() => {
hookMocks.resolveDiscordAccount.mockClear();
hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSubagentSessions: true,
},
},
}));
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
hookMocks.listThreadBindingsBySessionKey.mockClear();
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
});
it("registers subagent hooks", () => {
const handlers = registerHandlersForTest();
expect(handlers.has("subagent_spawning")).toBe(true);
expect(handlers.has("subagent_delivery_target")).toBe(true);
expect(handlers.has("subagent_spawned")).toBe(false);
expect(handlers.has("subagent_ended")).toBe(true);
});
it("binds thread routing on subagent_spawning", async () => {
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "banana",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "456",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({
accountId: "work",
channel: "discord",
to: "channel:123",
threadId: "456",
childSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "banana",
boundBy: "system",
});
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("returns error when thread-bound subagent spawn is disabled", async () => {
const handlers = registerHandlersForTest({
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: false,
},
},
},
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toContain("spawnSubagentSessions=true");
});
it("returns error when global thread bindings are disabled", async () => {
const handlers = registerHandlersForTest({
session: {
threadBindings: {
enabled: false,
},
},
channels: {
discord: {
threadBindings: {
spawnSubagentSessions: true,
},
},
},
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toContain("threadBindings.enabled=true");
});
it("allows account-level threadBindings.enabled to override global disable", async () => {
const handlers = registerHandlersForTest({
session: {
threadBindings: {
enabled: false,
},
},
channels: {
discord: {
accounts: {
work: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
},
},
},
},
},
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
const handlers = registerHandlersForTest({
channels: {
discord: {
threadBindings: {},
},
},
});
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: "error" });
});
it("no-ops when thread binding is requested on non-discord channel", async () => {
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
mode: "session",
requester: {
channel: "signal",
to: "+123",
},
threadRequested: true,
},
{},
);
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("returns error when thread bind fails", async () => {
hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null);
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_spawning");
if (!handler) {
throw new Error("expected subagent_spawning hook handler");
}
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "main",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
);
expect(result).toMatchObject({ status: "error" });
const errorText = (result as { error?: string }).error ?? "";
expect(errorText).toMatch(/unable to create or bind/i);
});
it("unbinds thread routing on subagent_ended", () => {
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_ended");
if (!handler) {
throw new Error("expected subagent_ended hook handler");
}
handler(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "subagent-complete",
sendFarewell: true,
accountId: "work",
},
{},
);
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "work",
targetKind: "subagent",
reason: "subagent-complete",
sendFarewell: true,
});
});
it("resolves delivery target from matching bound thread", () => {
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
{ accountId: "work", threadId: "777" },
]);
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_delivery_target");
if (!handler) {
throw new Error("expected subagent_delivery_target hook handler");
}
const result = handler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "777",
},
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{},
);
expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "work",
targetKind: "subagent",
});
expect(result).toEqual({
origin: {
channel: "discord",
accountId: "work",
to: "channel:777",
threadId: "777",
},
});
});
it("keeps original routing when delivery target is ambiguous", () => {
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
{ accountId: "work", threadId: "777" },
{ accountId: "work", threadId: "888" },
]);
const handlers = registerHandlersForTest();
const handler = handlers.get("subagent_delivery_target");
if (!handler) {
throw new Error("expected subagent_delivery_target hook handler");
}
const result = handler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{},
);
expect(result).toBeUndefined();
});
});