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,20 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EMOJIS } from "../../channels/status-reactions.js";
import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
const reactMessageDiscord = vi.fn(async () => {});
const removeReactionDiscord = vi.fn(async () => {});
const editMessageDiscord = vi.fn(async () => ({}));
const deliverDiscordReply = vi.fn(async () => {});
const createDiscordDraftStream = vi.fn(() => ({
update: vi.fn<(text: string) => void>(() => {}),
flush: vi.fn(async () => {}),
messageId: vi.fn(() => "preview-1"),
clear: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
forceNewMessage: vi.fn(() => {}),
const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn(async () => {}),
removeReactionDiscord: vi.fn(async () => {}),
}));
const deliveryMocks = vi.hoisted(() => ({
editMessageDiscord: vi.fn(async () => ({})),
deliverDiscordReply: vi.fn(async () => {}),
createDiscordDraftStream: vi.fn(() => ({
update: vi.fn<(text: string) => void>(() => {}),
flush: vi.fn(async () => {}),
messageId: vi.fn(() => "preview-1"),
clear: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
forceNewMessage: vi.fn(() => {}),
})),
}));
const editMessageDiscord = deliveryMocks.editMessageDiscord;
const deliverDiscordReply = deliveryMocks.deliverDiscordReply;
const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream;
type DispatchInboundParams = {
dispatcher: {
sendFinalReply: (payload: { text?: string }) => boolean | Promise<boolean>;
@@ -36,20 +46,20 @@ const readSessionUpdatedAt = vi.fn(() => undefined);
const resolveStorePath = vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json");
vi.mock("../send.js", () => ({
reactMessageDiscord,
removeReactionDiscord,
reactMessageDiscord: sendMocks.reactMessageDiscord,
removeReactionDiscord: sendMocks.removeReactionDiscord,
}));
vi.mock("../send.messages.js", () => ({
editMessageDiscord,
editMessageDiscord: deliveryMocks.editMessageDiscord,
}));
vi.mock("../draft-stream.js", () => ({
createDiscordDraftStream,
createDiscordDraftStream: deliveryMocks.createDiscordDraftStream,
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply,
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
}));
vi.mock("../../auto-reply/dispatch.js", () => ({
@@ -91,8 +101,8 @@ const createBaseContext = createBaseDiscordMessageContext;
beforeEach(() => {
vi.useRealTimers();
reactMessageDiscord.mockClear();
removeReactionDiscord.mockClear();
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
editMessageDiscord.mockClear();
deliverDiscordReply.mockClear();
createDiscordDraftStream.mockClear();
@@ -107,6 +117,7 @@ beforeEach(() => {
recordInboundSession.mockResolvedValue(undefined);
readSessionUpdatedAt.mockReturnValue(undefined);
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
threadBindingTesting.resetThreadBindingsForTests();
});
function getLastRouteUpdate():
@@ -126,6 +137,16 @@ function getLastRouteUpdate():
return params?.updateLastRoute;
}
function getLastDispatchCtx():
| { SessionKey?: string; MessageThreadId?: string | number }
| undefined {
const callArgs = dispatchInboundMessage.mock.calls.at(-1) as unknown[] | undefined;
const params = callArgs?.[0] as
| { ctx?: { SessionKey?: string; MessageThreadId?: string | number } }
| undefined;
return params?.ctx;
}
describe("processDiscordMessage ack reactions", () => {
it("skips ack reactions for group-mentions when mentions are not required", async () => {
const ctx = await createBaseContext({
@@ -136,7 +157,7 @@ describe("processDiscordMessage ack reactions", () => {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).not.toHaveBeenCalled();
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
});
it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
@@ -148,7 +169,7 @@ describe("processDiscordMessage ack reactions", () => {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]);
expect(sendMocks.reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]);
});
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
@@ -166,7 +187,7 @@ describe("processDiscordMessage ack reactions", () => {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord.mock.calls[0]).toEqual([
expect(sendMocks.reactMessageDiscord.mock.calls[0]).toEqual([
"fallback-channel",
"m1",
"👀",
@@ -187,7 +208,7 @@ describe("processDiscordMessage ack reactions", () => {
await processDiscordMessage(ctx as any);
const emojis = (
reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
).map((call) => call[2]);
expect(emojis).toContain("👀");
expect(emojis).toContain(DEFAULT_EMOJIS.done);
@@ -216,7 +237,7 @@ describe("processDiscordMessage ack reactions", () => {
await runPromise;
const emojis = (
reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
).map((call) => call[2]);
expect(emojis).toContain(DEFAULT_EMOJIS.stallSoft);
expect(emojis).toContain(DEFAULT_EMOJIS.stallHard);
@@ -289,6 +310,52 @@ describe("processDiscordMessage session routing", () => {
accountId: "default",
});
});
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {
const threadBindings = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "c-parent",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
const ctx = await createBaseContext({
messageChannelId: "thread-1",
threadChannel: { id: "thread-1", name: "subagent-thread" },
boundSessionKey: "agent:main:subagent:child",
threadBindings,
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:channel:c1",
mainSessionKey: "agent:main:main",
},
});
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(getLastDispatchCtx()).toMatchObject({
SessionKey: "agent:main:subagent:child",
MessageThreadId: "thread-1",
});
expect(getLastRouteUpdate()).toEqual({
sessionKey: "agent:main:subagent:child",
channel: "discord",
to: "channel:thread-1",
accountId: "default",
});
});
});
describe("processDiscordMessage draft streaming", () => {