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

@@ -1,6 +1,41 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeDiscordOutboundTarget } from "../normalize/discord.js";
const hoisted = vi.hoisted(() => {
const sendMessageDiscordMock = vi.fn();
const sendPollDiscordMock = vi.fn();
const sendWebhookMessageDiscordMock = vi.fn();
const getThreadBindingManagerMock = vi.fn();
return {
sendMessageDiscordMock,
sendPollDiscordMock,
sendWebhookMessageDiscordMock,
getThreadBindingManagerMock,
};
});
vi.mock("../../../discord/send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../discord/send.js")>();
return {
...actual,
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args),
sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) =>
hoisted.sendWebhookMessageDiscordMock(...args),
};
});
vi.mock("../../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../discord/monitor/thread-bindings.js")>();
return {
...actual,
getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args),
};
});
const { discordOutbound } = await import("./discord.js");
describe("normalizeDiscordOutboundTarget", () => {
it("normalizes bare numeric IDs to channel: prefix", () => {
expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({
@@ -33,3 +68,203 @@ describe("normalizeDiscordOutboundTarget", () => {
expect(normalizeDiscordOutboundTarget(" 123 ")).toEqual({ ok: true, to: "channel:123" });
});
});
describe("discordOutbound", () => {
beforeEach(() => {
hoisted.sendMessageDiscordMock.mockReset().mockResolvedValue({
messageId: "msg-1",
channelId: "ch-1",
});
hoisted.sendPollDiscordMock.mockReset().mockResolvedValue({
messageId: "poll-1",
channelId: "ch-1",
});
hoisted.sendWebhookMessageDiscordMock.mockReset().mockResolvedValue({
messageId: "msg-webhook-1",
channelId: "thread-1",
});
hoisted.getThreadBindingManagerMock.mockReset().mockReturnValue(null);
});
it("routes text sends to thread target when threadId is provided", async () => {
const result = await discordOutbound.sendText?.({
cfg: {},
to: "channel:parent-1",
text: "hello",
accountId: "default",
threadId: "thread-1",
});
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:thread-1",
"hello",
expect.objectContaining({
accountId: "default",
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
channelId: "ch-1",
});
});
it("uses webhook persona delivery for bound thread text replies", async () => {
hoisted.getThreadBindingManagerMock.mockReturnValue({
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "codex-thread",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
const result = await discordOutbound.sendText?.({
cfg: {},
to: "channel:parent-1",
text: "hello from persona",
accountId: "default",
threadId: "thread-1",
replyToId: "reply-1",
identity: {
name: "Codex",
avatarUrl: "https://example.com/avatar.png",
},
});
expect(hoisted.sendWebhookMessageDiscordMock).toHaveBeenCalledWith(
"hello from persona",
expect.objectContaining({
webhookId: "wh-1",
webhookToken: "tok-1",
accountId: "default",
threadId: "thread-1",
replyTo: "reply-1",
username: "Codex",
avatarUrl: "https://example.com/avatar.png",
}),
);
expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled();
expect(result).toEqual({
channel: "discord",
messageId: "msg-webhook-1",
channelId: "thread-1",
});
});
it("falls back to bot send for silent delivery on bound threads", async () => {
hoisted.getThreadBindingManagerMock.mockReturnValue({
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
const result = await discordOutbound.sendText?.({
cfg: {},
to: "channel:parent-1",
text: "silent update",
accountId: "default",
threadId: "thread-1",
silent: true,
});
expect(hoisted.sendWebhookMessageDiscordMock).not.toHaveBeenCalled();
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:thread-1",
"silent update",
expect.objectContaining({
accountId: "default",
silent: true,
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
channelId: "ch-1",
});
});
it("falls back to bot send when webhook send fails", async () => {
hoisted.getThreadBindingManagerMock.mockReturnValue({
getByThreadId: () => ({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
boundBy: "system",
boundAt: Date.now(),
}),
});
hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
const result = await discordOutbound.sendText?.({
cfg: {},
to: "channel:parent-1",
text: "fallback",
accountId: "default",
threadId: "thread-1",
});
expect(hoisted.sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:thread-1",
"fallback",
expect.objectContaining({
accountId: "default",
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
channelId: "ch-1",
});
});
it("routes poll sends to thread target when threadId is provided", async () => {
const result = await discordOutbound.sendPoll?.({
cfg: {},
to: "channel:parent-1",
poll: {
question: "Best snack?",
options: ["banana", "apple"],
},
accountId: "default",
threadId: "thread-1",
});
expect(hoisted.sendPollDiscordMock).toHaveBeenCalledWith(
"channel:thread-1",
{
question: "Best snack?",
options: ["banana", "apple"],
},
expect.objectContaining({
accountId: "default",
}),
);
expect(result).toEqual({
messageId: "poll-1",
channelId: "ch-1",
});
});
});

View File

@@ -1,16 +1,101 @@
import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js";
import {
getThreadBindingManager,
type ThreadBindingRecord,
} from "../../../discord/monitor/thread-bindings.js";
import {
sendMessageDiscord,
sendPollDiscord,
sendWebhookMessageDiscord,
} from "../../../discord/send.js";
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
import { normalizeDiscordOutboundTarget } from "../normalize/discord.js";
import type { ChannelOutboundAdapter } from "../types.js";
function resolveDiscordOutboundTarget(params: {
to: string;
threadId?: string | number | null;
}): string {
if (params.threadId == null) {
return params.to;
}
const threadId = String(params.threadId).trim();
if (!threadId) {
return params.to;
}
return `channel:${threadId}`;
}
function resolveDiscordWebhookIdentity(params: {
identity?: OutboundIdentity;
binding: ThreadBindingRecord;
}): { username?: string; avatarUrl?: string } {
const usernameRaw = params.identity?.name?.trim();
const fallbackUsername = params.binding.label?.trim() || params.binding.agentId;
const username = (usernameRaw || fallbackUsername || "").slice(0, 80) || undefined;
const avatarUrl = params.identity?.avatarUrl?.trim() || undefined;
return { username, avatarUrl };
}
async function maybeSendDiscordWebhookText(params: {
text: string;
threadId?: string | number | null;
accountId?: string | null;
identity?: OutboundIdentity;
replyToId?: string | null;
}): Promise<{ messageId: string; channelId: string } | null> {
if (params.threadId == null) {
return null;
}
const threadId = String(params.threadId).trim();
if (!threadId) {
return null;
}
const manager = getThreadBindingManager(params.accountId ?? undefined);
if (!manager) {
return null;
}
const binding = manager.getByThreadId(threadId);
if (!binding?.webhookId || !binding?.webhookToken) {
return null;
}
const persona = resolveDiscordWebhookIdentity({
identity: params.identity,
binding,
});
const result = await sendWebhookMessageDiscord(params.text, {
webhookId: binding.webhookId,
webhookToken: binding.webhookToken,
accountId: binding.accountId,
threadId: binding.threadId,
replyTo: params.replyToId ?? undefined,
username: persona.username,
avatarUrl: persona.avatarUrl,
});
return result;
}
export const discordOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {
const webhookResult = await maybeSendDiscordWebhookText({
text,
threadId,
accountId,
identity,
replyToId,
}).catch(() => null);
if (webhookResult) {
return { channel: "discord", ...webhookResult };
}
}
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
@@ -26,10 +111,12 @@ export const discordOutbound: ChannelOutboundAdapter = {
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, {
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
mediaUrl,
mediaLocalRoots,
@@ -39,9 +126,11 @@ export const discordOutbound: ChannelOutboundAdapter = {
});
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId, silent }) =>
await sendPollDiscord(to, poll, {
sendPoll: async ({ to, poll, accountId, threadId, silent }) => {
const target = resolveDiscordOutboundTarget({ to, threadId });
return await sendPollDiscord(target, poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
}),
});
},
};