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>
This commit is contained in:
Onur
2026-02-21 16:14:55 +01:00
committed by GitHub
parent 166068dfbe
commit 8178ea472d
114 changed files with 12214 additions and 1659 deletions

View File

@@ -0,0 +1,293 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
const {
createDiscordNativeCommandMock,
createNoopThreadBindingManagerMock,
createThreadBindingManagerMock,
createdBindingManagers,
listNativeCommandSpecsForConfigMock,
listSkillCommandsForAgentsMock,
monitorLifecycleMock,
resolveDiscordAccountMock,
resolveDiscordAllowlistConfigMock,
resolveNativeCommandsEnabledMock,
resolveNativeSkillsEnabledMock,
} = vi.hoisted(() => {
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
return {
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
createNoopThreadBindingManagerMock: vi.fn(() => {
const manager = { stop: vi.fn() };
createdBindingManagers.push(manager);
return manager;
}),
createThreadBindingManagerMock: vi.fn(() => {
const manager = { stop: vi.fn() };
createdBindingManagers.push(manager);
return manager;
}),
createdBindingManagers,
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
listSkillCommandsForAgentsMock: vi.fn(() => []),
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
params.threadBindings.stop();
}),
resolveDiscordAccountMock: vi.fn(() => ({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
},
})),
resolveDiscordAllowlistConfigMock: vi.fn(async () => ({
guildEntries: undefined,
allowFrom: undefined,
})),
resolveNativeCommandsEnabledMock: vi.fn(() => true),
resolveNativeSkillsEnabledMock: vi.fn(() => false),
};
});
vi.mock("@buape/carbon", () => {
class ReadyListener {}
class Client {
listeners: unknown[];
rest: { put: ReturnType<typeof vi.fn> };
constructor(_options: unknown, handlers: { listeners?: unknown[] }) {
this.listeners = handlers.listeners ?? [];
this.rest = { put: vi.fn(async () => undefined) };
}
async handleDeployRequest() {
return undefined;
}
async fetchUser(_target: string) {
return { id: "bot-1" };
}
getPlugin(_name: string) {
return undefined;
}
}
return { Client, ReadyListener };
});
vi.mock("@buape/carbon/gateway", () => ({
GatewayCloseCodes: { DisallowedIntents: 4014 },
}));
vi.mock("@buape/carbon/voice", () => ({
VoicePlugin: class VoicePlugin {},
}));
vi.mock("../../auto-reply/chunk.js", () => ({
resolveTextChunkLimit: () => 2000,
}));
vi.mock("../../auto-reply/commands-registry.js", () => ({
listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock,
}));
vi.mock("../../auto-reply/skill-commands.js", () => ({
listSkillCommandsForAgents: listSkillCommandsForAgentsMock,
}));
vi.mock("../../config/commands.js", () => ({
isNativeCommandsExplicitlyDisabled: () => false,
resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock,
resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock,
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("../../globals.js", () => ({
danger: (v: string) => v,
logVerbose: vi.fn(),
shouldLogVerbose: () => false,
warn: (v: string) => v,
}));
vi.mock("../../infra/errors.js", () => ({
formatErrorMessage: (err: unknown) => String(err),
}));
vi.mock("../../infra/retry-policy.js", () => ({
createDiscordRetryRunner: () => async (run: () => Promise<unknown>) => run(),
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
}));
vi.mock("../../runtime.js", () => ({
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
}));
vi.mock("../accounts.js", () => ({
resolveDiscordAccount: resolveDiscordAccountMock,
}));
vi.mock("../probe.js", () => ({
fetchDiscordApplicationId: async () => "app-1",
}));
vi.mock("../token.js", () => ({
normalizeDiscordToken: (value?: string) => value,
}));
vi.mock("../voice/command.js", () => ({
createDiscordVoiceCommand: () => ({ name: "voice-command" }),
}));
vi.mock("../voice/manager.js", () => ({
DiscordVoiceManager: class DiscordVoiceManager {},
DiscordVoiceReadyListener: class DiscordVoiceReadyListener {},
}));
vi.mock("./agent-components.js", () => ({
createAgentComponentButton: () => ({ id: "btn" }),
createAgentSelectMenu: () => ({ id: "menu" }),
createDiscordComponentButton: () => ({ id: "btn2" }),
createDiscordComponentChannelSelect: () => ({ id: "channel" }),
createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }),
createDiscordComponentModal: () => ({ id: "modal" }),
createDiscordComponentRoleSelect: () => ({ id: "role" }),
createDiscordComponentStringSelect: () => ({ id: "string" }),
createDiscordComponentUserSelect: () => ({ id: "user" }),
}));
vi.mock("./commands.js", () => ({
resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }),
}));
vi.mock("./exec-approvals.js", () => ({
createExecApprovalButton: () => ({ id: "exec-approval" }),
DiscordExecApprovalHandler: class DiscordExecApprovalHandler {
async start() {
return undefined;
}
async stop() {
return undefined;
}
},
}));
vi.mock("./gateway-plugin.js", () => ({
createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }),
}));
vi.mock("./listeners.js", () => ({
DiscordMessageListener: class DiscordMessageListener {},
DiscordPresenceListener: class DiscordPresenceListener {},
DiscordReactionListener: class DiscordReactionListener {},
DiscordReactionRemoveListener: class DiscordReactionRemoveListener {},
registerDiscordListener: vi.fn(),
}));
vi.mock("./message-handler.js", () => ({
createDiscordMessageHandler: () => ({ handle: vi.fn() }),
}));
vi.mock("./native-command.js", () => ({
createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }),
createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }),
createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }),
createDiscordNativeCommand: createDiscordNativeCommandMock,
}));
vi.mock("./presence.js", () => ({
resolveDiscordPresenceUpdate: () => undefined,
}));
vi.mock("./provider.allowlist.js", () => ({
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
}));
vi.mock("./provider.lifecycle.js", () => ({
runDiscordGatewayLifecycle: monitorLifecycleMock,
}));
vi.mock("./rest-fetch.js", () => ({
resolveDiscordRestFetch: () => async () => undefined,
}));
vi.mock("./thread-bindings.js", () => ({
createNoopThreadBindingManager: createNoopThreadBindingManagerMock,
createThreadBindingManager: createThreadBindingManagerMock,
}));
describe("monitorDiscordProvider", () => {
const baseRuntime = (): RuntimeEnv => {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
};
const baseConfig = (): OpenClawConfig =>
({
channels: {
discord: {
accounts: {
default: {},
},
},
},
}) as OpenClawConfig;
beforeEach(() => {
createDiscordNativeCommandMock.mockReset().mockReturnValue({ name: "mock-command" });
createNoopThreadBindingManagerMock.mockClear();
createThreadBindingManagerMock.mockClear();
createdBindingManagers.length = 0;
listNativeCommandSpecsForConfigMock.mockReset().mockReturnValue([{ name: "cmd" }]);
listSkillCommandsForAgentsMock.mockReset().mockReturnValue([]);
monitorLifecycleMock.mockReset().mockImplementation(async (params) => {
params.threadBindings.stop();
});
resolveDiscordAccountMock.mockClear();
resolveDiscordAllowlistConfigMock.mockReset().mockResolvedValue({
guildEntries: undefined,
allowFrom: undefined,
});
resolveNativeCommandsEnabledMock.mockReset().mockReturnValue(true);
resolveNativeSkillsEnabledMock.mockReset().mockReturnValue(false);
});
it("stops thread bindings when startup fails before lifecycle begins", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
createDiscordNativeCommandMock.mockImplementation(() => {
throw new Error("native command boom");
});
await expect(
monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
}),
).rejects.toThrow("native command boom");
expect(monitorLifecycleMock).not.toHaveBeenCalled();
expect(createdBindingManagers).toHaveLength(1);
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
});
it("does not double-stop thread bindings when lifecycle performs cleanup", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
expect(createdBindingManagers).toHaveLength(1);
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
});
});