mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 16:08:34 +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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user