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

@@ -0,0 +1,50 @@
import { resolveSessionFilePath } from "./paths.js";
import { updateSessionStore } from "./store.js";
import type { SessionEntry } from "./types.js";
export async function resolveAndPersistSessionFile(params: {
sessionId: string;
sessionKey: string;
sessionStore: Record<string, SessionEntry>;
storePath: string;
sessionEntry?: SessionEntry;
agentId?: string;
sessionsDir?: string;
fallbackSessionFile?: string;
activeSessionKey?: string;
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry }> {
const { sessionId, sessionKey, sessionStore, storePath } = params;
const baseEntry = params.sessionEntry ??
sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() };
const fallbackSessionFile = params.fallbackSessionFile?.trim();
const entryForResolve =
!baseEntry.sessionFile && fallbackSessionFile
? { ...baseEntry, sessionFile: fallbackSessionFile }
: baseEntry;
const sessionFile = resolveSessionFilePath(sessionId, entryForResolve, {
agentId: params.agentId,
sessionsDir: params.sessionsDir,
});
const persistedEntry: SessionEntry = {
...baseEntry,
sessionId,
updatedAt: Date.now(),
sessionFile,
};
if (baseEntry.sessionId !== sessionId || baseEntry.sessionFile !== sessionFile) {
sessionStore[sessionKey] = persistedEntry;
await updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...store[sessionKey],
...persistedEntry,
};
},
params.activeSessionKey ? { activeSessionKey: params.activeSessionKey } : undefined,
);
return { sessionFile, sessionEntry: persistedEntry };
}
sessionStore[sessionKey] = persistedEntry;
return { sessionFile, sessionEntry: persistedEntry };
}

View File

@@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
import {
clearSessionStoreCacheForTest,
loadSessionStore,
resolveAndPersistSessionFile,
updateSessionStore,
} from "../sessions.js";
import type { SessionConfig } from "../types.base.js";
@@ -203,3 +204,48 @@ describe("appendAssistantMessageToSessionTranscript", () => {
}
});
});
describe("resolveAndPersistSessionFile", () => {
let tempDir: string;
let storePath: string;
let sessionsDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-file-test-"));
sessionsDir = path.join(tempDir, "agents", "main", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
storePath = path.join(sessionsDir, "sessions.json");
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("persists fallback topic transcript paths for sessions without sessionFile", async () => {
const sessionId = "topic-session-id";
const sessionKey = "agent:main:telegram:group:123:topic:456";
const store = {
[sessionKey]: {
sessionId,
updatedAt: Date.now(),
},
};
fs.writeFileSync(storePath, JSON.stringify(store), "utf-8");
const sessionStore = loadSessionStore(storePath, { skipCache: true });
const fallbackSessionFile = resolveSessionTranscriptPathInDir(sessionId, sessionsDir, 456);
const result = await resolveAndPersistSessionFile({
sessionId,
sessionKey,
sessionStore,
storePath,
sessionEntry: sessionStore[sessionKey],
fallbackSessionFile,
});
expect(result.sessionFile).toBe(fallbackSessionFile);
const saved = loadSessionStore(storePath, { skipCache: true });
expect(saved[sessionKey]?.sessionFile).toBe(fallbackSessionFile);
});
});

View File

@@ -2,8 +2,9 @@ import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { resolveDefaultSessionStorePath, resolveSessionFilePath } from "./paths.js";
import { loadSessionStore, updateSessionStore } from "./store.js";
import { resolveDefaultSessionStorePath } from "./paths.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import { loadSessionStore } from "./store.js";
import type { SessionEntry } from "./types.js";
function stripQuery(value: string): string {
@@ -108,10 +109,16 @@ export async function appendAssistantMessageToSessionTranscript(params: {
let sessionFile: string;
try {
sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
const resolvedSessionFile = await resolveAndPersistSessionFile({
sessionId: entry.sessionId,
sessionKey,
sessionStore: store,
storePath,
sessionEntry: entry,
agentId: params.agentId,
sessionsDir: path.dirname(storePath),
});
sessionFile = resolvedSessionFile.sessionFile;
} catch (err) {
return {
ok: false,
@@ -146,19 +153,6 @@ export async function appendAssistantMessageToSessionTranscript(params: {
timestamp: Date.now(),
});
if (!entry.sessionFile || entry.sessionFile !== sessionFile) {
await updateSessionStore(
storePath,
(current) => {
current[sessionKey] = {
...entry,
sessionFile,
};
},
{ activeSessionKey: sessionKey },
);
}
emitSessionTranscriptUpdate(sessionFile);
return { ok: true, sessionFile };
}