mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 15:21:44 +00:00
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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user