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

@@ -8,6 +8,7 @@ import type {
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as globalsModule from "../../globals.js";
import * as timeoutModule from "../../utils/with-timeout.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
@@ -15,6 +16,7 @@ import {
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
} from "./native-command.js";
import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.js";
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
const byProvider = new Map<string, Set<string>>();
@@ -70,6 +72,7 @@ function createModelPickerContext(): ModelPickerContext {
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
threadBindings: createNoopThreadBindingManager("default"),
};
}
@@ -99,6 +102,38 @@ function createInteraction(params?: { userId?: string; values?: string[] }): Moc
};
}
function createBoundThreadBindingManager(params: {
accountId: string;
threadId: string;
targetSessionKey: string;
agentId: string;
}): ThreadBindingManager {
return {
accountId: params.accountId,
getSessionTtlMs: () => 24 * 60 * 60 * 1000,
getByThreadId: (threadId: string) =>
threadId === params.threadId
? {
accountId: params.accountId,
channelId: "parent-1",
threadId: params.threadId,
targetKind: "subagent",
targetSessionKey: params.targetSessionKey,
agentId: params.agentId,
boundBy: "system",
boundAt: Date.now(),
}
: undefined,
getBySessionKey: () => undefined,
listBySessionKey: () => [],
listBindings: () => [],
bindTarget: async () => null,
unbindThread: () => null,
unbindBySessionKey: () => [],
stop: () => {},
};
}
describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -375,4 +410,78 @@ describe("Discord model picker interactions", () => {
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
});
it("verifies model state against the bound thread session", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const modelCommand: ChatCommandDefinition = {
key: "model",
nativeName: "model",
description: "Switch model",
textAliases: ["/model"],
acceptsArgs: true,
argsParsing: "none" as CommandArgsParsing,
scope: "native",
};
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
name === "model" ? modelCommand : undefined,
);
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({} as never);
const verboseSpy = vi.spyOn(globalsModule, "logVerbose").mockImplementation(() => {});
const select = createDiscordModelPickerFallbackSelect(context);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
selectInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
const selectData: PickerSelectData = {
cmd: "model",
act: "model",
view: "models",
u: "owner",
p: "openai",
pg: "1",
};
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
const button = createDiscordModelPickerFallbackButton(context);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
const submitData: PickerButtonData = {
cmd: "model",
act: "submit",
view: "models",
u: "owner",
p: "openai",
pg: "1",
mi: "2",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
const mismatchLog = verboseSpy.mock.calls.find((call) =>
String(call[0] ?? "").includes("model picker override mismatch"),
)?.[0];
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
});
});