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

@@ -94,6 +94,8 @@ export const SessionsDeleteParamsSchema = Type.Object(
{
key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()),
// Internal control: when false, still unbind thread bindings but skip hook emission.
emitLifecycleHooks: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);

View File

@@ -12,8 +12,14 @@ import {
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import { unbindThreadBindingsBySessionKey } from "../../discord/monitor/thread-bindings.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
@@ -132,6 +138,41 @@ function archiveSessionTranscriptsForSession(params: {
});
}
async function emitSessionUnboundLifecycleEvent(params: {
targetSessionKey: string;
reason: "session-reset" | "session-delete";
emitHooks?: boolean;
}) {
const targetKind = isSubagentSessionKey(params.targetSessionKey) ? "subagent" : "acp";
unbindThreadBindingsBySessionKey({
targetSessionKey: params.targetSessionKey,
targetKind,
reason: params.reason,
sendFarewell: true,
});
if (params.emitHooks === false) {
return;
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("subagent_ended")) {
return;
}
await hookRunner.runSubagentEnded(
{
targetSessionKey: params.targetSessionKey,
targetKind,
reason: params.reason,
sendFarewell: true,
outcome: params.reason === "session-reset" ? "reset" : "deleted",
},
{
childSessionKey: params.targetSessionKey,
},
);
}
async function ensureSessionRuntimeCleanup(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
@@ -306,6 +347,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
const { entry } = loadSessionEntry(key);
const hadExistingEntry = Boolean(entry);
const commandReason = p.reason === "new" ? "new" : "reset";
const hookEvent = createInternalHookEvent(
"command",
@@ -367,6 +409,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
agentId: target.agentId,
reason: "reset",
});
if (hadExistingEntry) {
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? key,
reason: "session-reset",
});
}
respond(true, { ok: true, key: target.canonicalKey, entry: next }, undefined);
},
"sessions.delete": async ({ params, respond, client, isWebchatConnect }) => {
@@ -397,30 +445,40 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const { entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
const existed = Boolean(entry);
const cleanupError = await ensureSessionRuntimeCleanup({ cfg, key, target, sessionId });
if (cleanupError) {
respond(false, undefined, cleanupError);
return;
}
await updateSessionStore(storePath, (store) => {
const deleted = await updateSessionStore(storePath, (store) => {
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
if (store[primaryKey]) {
const hadEntry = Boolean(store[primaryKey]);
if (hadEntry) {
delete store[primaryKey];
}
return hadEntry;
});
const archived = deleteTranscript
? archiveSessionTranscriptsForSession({
sessionId,
storePath,
sessionFile: entry?.sessionFile,
agentId: target.agentId,
reason: "deleted",
})
: [];
const archived =
deleted && deleteTranscript
? archiveSessionTranscriptsForSession({
sessionId,
storePath,
sessionFile: entry?.sessionFile,
agentId: target.agentId,
reason: "deleted",
})
: [];
if (deleted) {
const emitLifecycleHooks = p.emitLifecycleHooks !== false;
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? key,
reason: "session-delete",
emitHooks: emitLifecycleHooks,
});
}
respond(true, { ok: true, key: target.canonicalKey, deleted: existed, archived }, undefined);
respond(true, { ok: true, key: target.canonicalKey, deleted, archived }, undefined);
},
"sessions.compact": async ({ params, respond }) => {
if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) {

View File

@@ -26,6 +26,18 @@ const sessionHookMocks = vi.hoisted(() => ({
triggerInternalHook: vi.fn(async () => {}),
}));
const subagentLifecycleHookMocks = vi.hoisted(() => ({
runSubagentEnded: vi.fn(async () => {}),
}));
const subagentLifecycleHookState = vi.hoisted(() => ({
hasSubagentEndedHook: true,
}));
const threadBindingMocks = vi.hoisted(() => ({
unbindThreadBindingsBySessionKey: vi.fn((_params?: unknown) => []),
}));
vi.mock("../auto-reply/reply/queue.js", async () => {
const actual = await vi.importActual<typeof import("../auto-reply/reply/queue.js")>(
"../auto-reply/reply/queue.js",
@@ -56,6 +68,27 @@ vi.mock("../hooks/internal-hooks.js", async () => {
};
});
vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/hook-runner-global.js")>();
return {
...actual,
getGlobalHookRunner: vi.fn(() => ({
hasHooks: (hookName: string) =>
hookName === "subagent_ended" && subagentLifecycleHookState.hasSubagentEndedHook,
runSubagentEnded: subagentLifecycleHookMocks.runSubagentEnded,
})),
};
});
vi.mock("../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../discord/monitor/thread-bindings.js")>();
return {
...actual,
unbindThreadBindingsBySessionKey: (params: unknown) =>
threadBindingMocks.unbindThreadBindingsBySessionKey(params),
};
});
installGatewayTestHooks({ scope: "suite" });
let harness: GatewayServerHarness;
@@ -134,6 +167,9 @@ describe("gateway server sessions", () => {
sessionCleanupMocks.clearSessionQueues.mockClear();
sessionCleanupMocks.stopSubagentsForRequester.mockClear();
sessionHookMocks.triggerInternalHook.mockClear();
subagentLifecycleHookMocks.runSubagentEnded.mockClear();
subagentLifecycleHookState.hasSubagentEndedHook = true;
threadBindingMocks.unbindThreadBindingsBySessionKey.mockClear();
});
test("lists and patches session store via sessions.* RPC", async () => {
@@ -605,6 +641,148 @@ describe("gateway server sessions", () => {
["discord:group:dev", "agent:main:discord:group:dev", "sess-active"],
"sess-active",
);
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith(
{
targetSessionKey: "agent:main:discord:group:dev",
targetKind: "acp",
reason: "session-delete",
sendFarewell: true,
outcome: "deleted",
},
{
childSessionKey: "agent:main:discord:group:dev",
},
);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:discord:group:dev",
targetKind: "acp",
reason: "session-delete",
sendFarewell: true,
});
ws.close();
});
test("sessions.delete does not emit lifecycle events when nothing was deleted", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
const { ws } = await openClient();
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
key: "agent:main:subagent:missing",
});
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(false);
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).not.toHaveBeenCalled();
ws.close();
});
test("sessions.delete emits subagent targetKind for subagent sessions", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-subagent", "hello");
await writeSessionStore({
entries: {
"agent:main:subagent:worker": {
sessionId: "sess-subagent",
updatedAt: Date.now(),
},
},
});
const { ws } = await openClient();
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
key: "agent:main:subagent:worker",
});
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
const event = (subagentLifecycleHookMocks.runSubagentEnded.mock.calls as unknown[][])[0]?.[0] as
| { targetKind?: string; targetSessionKey?: string; reason?: string; outcome?: string }
| undefined;
expect(event).toMatchObject({
targetSessionKey: "agent:main:subagent:worker",
targetKind: "subagent",
reason: "session-delete",
outcome: "deleted",
});
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:worker",
targetKind: "subagent",
reason: "session-delete",
sendFarewell: true,
});
ws.close();
});
test("sessions.delete can skip lifecycle hooks while still unbinding thread bindings", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-subagent", "hello");
await writeSessionStore({
entries: {
"agent:main:subagent:worker": {
sessionId: "sess-subagent",
updatedAt: Date.now(),
},
},
});
const { ws } = await openClient();
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
key: "agent:main:subagent:worker",
emitLifecycleHooks: false,
});
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:worker",
targetKind: "subagent",
reason: "session-delete",
sendFarewell: true,
});
ws.close();
});
test("sessions.delete directly unbinds thread bindings when hooks are unavailable", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-subagent", "hello");
await writeSessionStore({
entries: {
"agent:main:subagent:worker": {
sessionId: "sess-subagent",
updatedAt: Date.now(),
},
},
});
subagentLifecycleHookState.hasSubagentEndedHook = false;
const { ws } = await openClient();
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
key: "agent:main:subagent:worker",
});
expect(deleted.ok).toBe(true);
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:worker",
targetKind: "subagent",
reason: "session-delete",
sendFarewell: true,
});
ws.close();
});
@@ -632,6 +810,125 @@ describe("gateway server sessions", () => {
["main", "agent:main:main", "sess-main"],
"sess-main",
);
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith(
{
targetSessionKey: "agent:main:main",
targetKind: "acp",
reason: "session-reset",
sendFarewell: true,
outcome: "reset",
},
{
childSessionKey: "agent:main:main",
},
);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:main",
targetKind: "acp",
reason: "session-reset",
sendFarewell: true,
});
ws.close();
});
test("sessions.reset does not emit lifecycle events when key does not exist", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
const { ws } = await openClient();
const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string } }>(
ws,
"sessions.reset",
{
key: "agent:main:subagent:missing",
},
);
expect(reset.ok).toBe(true);
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).not.toHaveBeenCalled();
ws.close();
});
test("sessions.reset emits subagent targetKind for subagent sessions", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-subagent", "hello");
await writeSessionStore({
entries: {
"agent:main:subagent:worker": {
sessionId: "sess-subagent",
updatedAt: Date.now(),
},
},
});
const { ws } = await openClient();
const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string } }>(
ws,
"sessions.reset",
{
key: "agent:main:subagent:worker",
},
);
expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("agent:main:subagent:worker");
expect(reset.payload?.entry.sessionId).not.toBe("sess-subagent");
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
const event = (subagentLifecycleHookMocks.runSubagentEnded.mock.calls as unknown[][])[0]?.[0] as
| { targetKind?: string; targetSessionKey?: string; reason?: string; outcome?: string }
| undefined;
expect(event).toMatchObject({
targetSessionKey: "agent:main:subagent:worker",
targetKind: "subagent",
reason: "session-reset",
outcome: "reset",
});
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:worker",
targetKind: "subagent",
reason: "session-reset",
sendFarewell: true,
});
ws.close();
});
test("sessions.reset directly unbinds thread bindings when hooks are unavailable", async () => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
subagentLifecycleHookState.hasSubagentEndedHook = false;
const { ws } = await openClient();
const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", {
key: "main",
});
expect(reset.ok).toBe(true);
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
targetSessionKey: "agent:main:main",
targetKind: "acp",
reason: "session-reset",
sendFarewell: true,
});
ws.close();
});