From 8178ea472db157da4b761df784bd18d06b740f51 Mon Sep 17 00:00:00 2001 From: Onur Date: Sat, 21 Feb 2026 16:14:55 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 6 +- .../OpenClawProtocol/GatewayModels.swift | 6 +- .../plans/session-binding-channel-agnostic.md | 223 +++++ .../plans/thread-bound-subagents.md | 338 ++++++++ docs/tools/slash-commands.md | 4 + extensions/discord/index.ts | 2 + extensions/discord/src/subagent-hooks.test.ts | 430 ++++++++++ extensions/discord/src/subagent-hooks.ts | 152 ++++ .../openclaw-tools.sessions.e2e.test.ts | 2 + ...gents.sessions-spawn.lifecycle.e2e.test.ts | 121 ++- src/agents/pi-tools.policy.ts | 4 +- src/agents/sessions-spawn-hooks.test.ts | 373 +++++++++ .../subagent-announce.format.e2e.test.ts | 660 ++++++++++++++- src/agents/subagent-announce.ts | 319 +++++++- src/agents/subagent-lifecycle-events.ts | 47 ++ src/agents/subagent-registry-cleanup.ts | 67 ++ .../subagent-registry-completion.test.ts | 79 ++ src/agents/subagent-registry-completion.ts | 96 +++ src/agents/subagent-registry-queries.ts | 146 ++++ src/agents/subagent-registry-state.ts | 56 ++ src/agents/subagent-registry.archive.test.ts | 90 ++ .../subagent-registry.steer-restart.test.ts | 275 ++++++- src/agents/subagent-registry.store.ts | 3 +- src/agents/subagent-registry.ts | 594 ++++++++------ src/agents/subagent-registry.types.ts | 35 + src/agents/subagent-spawn.ts | 236 +++++- src/agents/tools/sessions-spawn-tool.ts | 10 +- src/agents/tools/subagents-tool.ts | 4 +- src/auto-reply/commands-registry.data.ts | 51 ++ src/auto-reply/reply/commands-core.ts | 2 + .../reply/commands-session-ttl.test.ts | 147 ++++ src/auto-reply/reply/commands-session.ts | 180 ++++ .../reply/commands-subagents-focus.test.ts | 331 ++++++++ .../reply/commands-subagents-spawn.test.ts | 2 + src/auto-reply/reply/commands-subagents.ts | 721 ++-------------- .../reply/commands-subagents/action-agents.ts | 55 ++ .../reply/commands-subagents/action-focus.ts | 90 ++ .../reply/commands-subagents/action-help.ts | 6 + .../reply/commands-subagents/action-info.ts | 59 ++ .../reply/commands-subagents/action-kill.ts | 86 ++ .../reply/commands-subagents/action-list.ts | 66 ++ .../reply/commands-subagents/action-log.ts | 43 + .../reply/commands-subagents/action-send.ts | 159 ++++ .../reply/commands-subagents/action-spawn.ts | 65 ++ .../commands-subagents/action-unfocus.ts | 42 + .../reply/commands-subagents/shared.ts | 432 ++++++++++ src/auto-reply/reply/reply-payloads.ts | 10 +- src/auto-reply/reply/session.ts | 23 +- src/channels/plugins/outbound/discord.test.ts | 237 +++++- src/channels/plugins/outbound/discord.ts | 103 ++- src/commands/agent.e2e.test.ts | 66 ++ src/commands/agent.ts | 29 +- src/config/agent-limits.ts | 2 + src/config/schema.help.ts | 10 + src/config/schema.labels.ts | 5 + src/config/sessions.ts | 1 + src/config/sessions/session-file.ts | 50 ++ src/config/sessions/sessions.test.ts | 46 ++ src/config/sessions/transcript.ts | 26 +- src/config/types.base.ts | 15 + src/config/types.discord.ts | 21 + src/config/zod-schema.providers-core.ts | 8 + src/config/zod-schema.session.ts | 11 +- ...messages-mentionpatterns-match.e2e.test.ts | 3 + ...ends-status-replies-responseprefix.test.ts | 3 + .../monitor/message-handler.preflight.test.ts | 209 +++++ .../monitor/message-handler.preflight.ts | 88 +- .../message-handler.preflight.types.ts | 6 + .../monitor/message-handler.process.test.ts | 115 ++- .../monitor/message-handler.process.ts | 7 +- .../monitor/message-handler.test-harness.ts | 2 + .../monitor/model-picker-preferences.ts | 8 +- .../native-command.model-picker.test.ts | 109 +++ src/discord/monitor/native-command.ts | 71 +- src/discord/monitor/provider.allowlist.ts | 207 +++++ .../monitor/provider.lifecycle.test.ts | 106 +++ src/discord/monitor/provider.lifecycle.ts | 132 +++ .../monitor/provider.skill-dedupe.test.ts | 35 + src/discord/monitor/provider.test.ts | 293 +++++++ src/discord/monitor/provider.ts | 772 +++++++----------- src/discord/monitor/reply-delivery.test.ts | 148 ++++ src/discord/monitor/reply-delivery.ts | 139 +++- .../thread-bindings.discord-api.test.ts | 85 ++ .../monitor/thread-bindings.discord-api.ts | 289 +++++++ .../monitor/thread-bindings.lifecycle.ts | 225 +++++ .../monitor/thread-bindings.manager.ts | 515 ++++++++++++ .../monitor/thread-bindings.messages.ts | 72 ++ .../thread-bindings.shared-state.test.ts | 31 + src/discord/monitor/thread-bindings.state.ts | 444 ++++++++++ src/discord/monitor/thread-bindings.ts | 28 + .../monitor/thread-bindings.ttl.test.ts | 541 ++++++++++++ src/discord/monitor/thread-bindings.types.ts | 69 ++ src/discord/send.components.test.ts | 2 +- src/discord/send.outbound.ts | 94 +++ src/discord/send.ts | 1 + src/discord/send.webhook-activity.test.ts | 50 ++ src/gateway/protocol/schema/sessions.ts | 2 + src/gateway/server-methods/sessions.ts | 86 +- ...ions.gateway-server-sessions-a.e2e.test.ts | 297 +++++++ .../outbound/bound-delivery-router.test.ts | 117 +++ src/infra/outbound/bound-delivery-router.ts | 131 +++ src/infra/outbound/session-binding-service.ts | 192 +++++ src/line/accounts.ts | 12 +- src/plugin-sdk/index.ts | 10 + src/plugins/hooks.ts | 98 +++ src/plugins/types.ts | 110 +++ src/plugins/wired-hooks-subagent.test.ts | 221 +++++ src/routing/account-id.test.ts | 29 + src/routing/account-id.ts | 34 + src/routing/resolve-route.test.ts | 15 + src/routing/resolve-route.ts | 8 +- src/routing/session-key.ts | 25 +- src/utils/account-id.ts | 8 +- 114 files changed, 12214 insertions(+), 1659 deletions(-) create mode 100644 docs/experiments/plans/session-binding-channel-agnostic.md create mode 100644 docs/experiments/plans/thread-bound-subagents.md create mode 100644 extensions/discord/src/subagent-hooks.test.ts create mode 100644 extensions/discord/src/subagent-hooks.ts create mode 100644 src/agents/sessions-spawn-hooks.test.ts create mode 100644 src/agents/subagent-lifecycle-events.ts create mode 100644 src/agents/subagent-registry-cleanup.ts create mode 100644 src/agents/subagent-registry-completion.test.ts create mode 100644 src/agents/subagent-registry-completion.ts create mode 100644 src/agents/subagent-registry-queries.ts create mode 100644 src/agents/subagent-registry-state.ts create mode 100644 src/agents/subagent-registry.archive.test.ts create mode 100644 src/agents/subagent-registry.types.ts create mode 100644 src/auto-reply/reply/commands-session-ttl.test.ts create mode 100644 src/auto-reply/reply/commands-subagents-focus.test.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-agents.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-focus.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-help.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-info.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-kill.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-list.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-log.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-send.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-spawn.ts create mode 100644 src/auto-reply/reply/commands-subagents/action-unfocus.ts create mode 100644 src/auto-reply/reply/commands-subagents/shared.ts create mode 100644 src/config/sessions/session-file.ts create mode 100644 src/discord/monitor/message-handler.preflight.test.ts create mode 100644 src/discord/monitor/provider.allowlist.ts create mode 100644 src/discord/monitor/provider.lifecycle.test.ts create mode 100644 src/discord/monitor/provider.lifecycle.ts create mode 100644 src/discord/monitor/provider.test.ts create mode 100644 src/discord/monitor/thread-bindings.discord-api.test.ts create mode 100644 src/discord/monitor/thread-bindings.discord-api.ts create mode 100644 src/discord/monitor/thread-bindings.lifecycle.ts create mode 100644 src/discord/monitor/thread-bindings.manager.ts create mode 100644 src/discord/monitor/thread-bindings.messages.ts create mode 100644 src/discord/monitor/thread-bindings.shared-state.test.ts create mode 100644 src/discord/monitor/thread-bindings.state.ts create mode 100644 src/discord/monitor/thread-bindings.ts create mode 100644 src/discord/monitor/thread-bindings.ttl.test.ts create mode 100644 src/discord/monitor/thread-bindings.types.ts create mode 100644 src/discord/send.webhook-activity.test.ts create mode 100644 src/infra/outbound/bound-delivery-router.test.ts create mode 100644 src/infra/outbound/bound-delivery-router.ts create mode 100644 src/infra/outbound/session-binding-service.ts create mode 100644 src/plugins/wired-hooks-subagent.test.ts create mode 100644 src/routing/account-id.test.ts create mode 100644 src/routing/account-id.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 523bb65d903..3cdfbae73e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei. - Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201. - Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow. +- Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc. - iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky. - iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky. - iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 19f3f774fa7..6b795fd6a8a 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1191,17 +1191,21 @@ public struct SessionsResetParams: Codable, Sendable { public struct SessionsDeleteParams: Codable, Sendable { public let key: String public let deletetranscript: Bool? + public let emitlifecyclehooks: Bool? public init( key: String, - deletetranscript: Bool? + deletetranscript: Bool?, + emitlifecyclehooks: Bool? ) { self.key = key self.deletetranscript = deletetranscript + self.emitlifecyclehooks = emitlifecyclehooks } private enum CodingKeys: String, CodingKey { case key case deletetranscript = "deleteTranscript" + case emitlifecyclehooks = "emitLifecycleHooks" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 19f3f774fa7..6b795fd6a8a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1191,17 +1191,21 @@ public struct SessionsResetParams: Codable, Sendable { public struct SessionsDeleteParams: Codable, Sendable { public let key: String public let deletetranscript: Bool? + public let emitlifecyclehooks: Bool? public init( key: String, - deletetranscript: Bool? + deletetranscript: Bool?, + emitlifecyclehooks: Bool? ) { self.key = key self.deletetranscript = deletetranscript + self.emitlifecyclehooks = emitlifecyclehooks } private enum CodingKeys: String, CodingKey { case key case deletetranscript = "deleteTranscript" + case emitlifecyclehooks = "emitLifecycleHooks" } } diff --git a/docs/experiments/plans/session-binding-channel-agnostic.md b/docs/experiments/plans/session-binding-channel-agnostic.md new file mode 100644 index 00000000000..c66b6e8193e --- /dev/null +++ b/docs/experiments/plans/session-binding-channel-agnostic.md @@ -0,0 +1,223 @@ +--- +summary: "Channel agnostic session binding architecture and iteration 1 delivery scope" +owner: "onutc" +status: "in-progress" +last_updated: "2026-02-21" +title: "Session Binding Channel Agnostic Plan" +--- + +# Session Binding Channel Agnostic Plan + +## Overview + +This document defines the long term channel agnostic session binding model and the concrete scope for the next implementation iteration. + +Goal: + +- make subagent bound session routing a core capability +- keep channel specific behavior in adapters +- avoid regressions in normal Discord behavior + +## Why this exists + +Current behavior mixes: + +- completion content policy +- destination routing policy +- Discord specific details + +This caused edge cases such as: + +- duplicate main and thread delivery under concurrent runs +- stale token usage on reused binding managers +- missing activity accounting for webhook sends + +## Iteration 1 scope + +This iteration is intentionally limited. + +### 1. Add channel agnostic core interfaces + +Add core types and service interfaces for bindings and routing. + +Proposed core types: + +```ts +export type BindingTargetKind = "subagent" | "session"; +export type BindingStatus = "active" | "ending" | "ended"; + +export type ConversationRef = { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; +}; + +export type SessionBindingRecord = { + bindingId: string; + targetSessionKey: string; + targetKind: BindingTargetKind; + conversation: ConversationRef; + status: BindingStatus; + boundAt: number; + expiresAt?: number; + metadata?: Record; +}; +``` + +Core service contract: + +```ts +export interface SessionBindingService { + bind(input: { + targetSessionKey: string; + targetKind: BindingTargetKind; + conversation: ConversationRef; + metadata?: Record; + ttlMs?: number; + }): Promise; + + listBySession(targetSessionKey: string): SessionBindingRecord[]; + resolveByConversation(ref: ConversationRef): SessionBindingRecord | null; + touch(bindingId: string, at?: number): void; + unbind(input: { + bindingId?: string; + targetSessionKey?: string; + reason: string; + }): Promise; +} +``` + +### 2. Add one core delivery router for subagent completions + +Add a single destination resolution path for completion events. + +Router contract: + +```ts +export interface BoundDeliveryRouter { + resolveDestination(input: { + eventKind: "task_completion"; + targetSessionKey: string; + requester?: ConversationRef; + failClosed: boolean; + }): { + binding: SessionBindingRecord | null; + mode: "bound" | "fallback"; + reason: string; + }; +} +``` + +For this iteration: + +- only `task_completion` is routed through this new path +- existing paths for other event kinds remain as-is + +### 3. Keep Discord as adapter + +Discord remains the first adapter implementation. + +Adapter responsibilities: + +- create/reuse thread conversations +- send bound messages via webhook or channel send +- validate thread state (archived/deleted) +- map adapter metadata (webhook identity, thread ids) + +### 4. Fix currently known correctness issues + +Required in this iteration: + +- refresh token usage when reusing existing thread binding manager +- record outbound activity for webhook based Discord sends +- stop implicit main channel fallback when a bound thread destination is selected for session mode completion + +### 5. Preserve current runtime safety defaults + +No behavior change for users with thread bound spawn disabled. + +Defaults stay: + +- `channels.discord.threadBindings.spawnSubagentSessions = false` + +Result: + +- normal Discord users stay on current behavior +- new core path affects only bound session completion routing where enabled + +## Not in iteration 1 + +Explicitly deferred: + +- ACP binding targets (`targetKind: "acp"`) +- new channel adapters beyond Discord +- global replacement of all delivery paths (`spawn_ack`, future `subagent_message`) +- protocol level changes +- store migration/versioning redesign for all binding persistence + +Notes on ACP: + +- interface design keeps room for ACP +- ACP implementation is not started in this iteration + +## Routing invariants + +These invariants are mandatory for iteration 1. + +- destination selection and content generation are separate steps +- if session mode completion resolves to an active bound destination, delivery must target that destination +- no hidden reroute from bound destination to main channel +- fallback behavior must be explicit and observable + +## Compatibility and rollout + +Compatibility target: + +- no regression for users with thread bound spawning off +- no change to non-Discord channels in this iteration + +Rollout: + +1. Land interfaces and router behind current feature gates. +2. Route Discord completion mode bound deliveries through router. +3. Keep legacy path for non-bound flows. +4. Verify with targeted tests and canary runtime logs. + +## Tests required in iteration 1 + +Unit and integration coverage required: + +- manager token rotation uses latest token after manager reuse +- webhook sends update channel activity timestamps +- two active bound sessions in same requester channel do not duplicate to main channel +- completion for bound session mode run resolves to thread destination only +- disabled spawn flag keeps legacy behavior unchanged + +## Proposed implementation files + +Core: + +- `src/infra/outbound/session-binding-service.ts` (new) +- `src/infra/outbound/bound-delivery-router.ts` (new) +- `src/agents/subagent-announce.ts` (completion destination resolution integration) + +Discord adapter and runtime: + +- `src/discord/monitor/thread-bindings.manager.ts` +- `src/discord/monitor/reply-delivery.ts` +- `src/discord/send.outbound.ts` + +Tests: + +- `src/discord/monitor/provider*.test.ts` +- `src/discord/monitor/reply-delivery.test.ts` +- `src/agents/subagent-announce.format.e2e.test.ts` + +## Done criteria for iteration 1 + +- core interfaces exist and are wired for completion routing +- correctness fixes above are merged with tests +- no main and thread duplicate completion delivery in session mode bound runs +- no behavior change for disabled bound spawn deployments +- ACP remains explicitly deferred diff --git a/docs/experiments/plans/thread-bound-subagents.md b/docs/experiments/plans/thread-bound-subagents.md new file mode 100644 index 00000000000..8663ab55efc --- /dev/null +++ b/docs/experiments/plans/thread-bound-subagents.md @@ -0,0 +1,338 @@ +--- +summary: "Discord thread bound subagent sessions with plugin lifecycle hooks, routing, and config kill switches" +owner: "onutc" +status: "implemented" +last_updated: "2026-02-21" +title: "Thread Bound Subagents" +--- + +# Thread Bound Subagents + +## Overview + +This feature lets users interact with spawned subagents directly inside Discord threads. + +Instead of only waiting for a completion summary in the parent session, users can move into a dedicated thread that routes messages to the spawned subagent session. Replies are sent in-thread with a thread bound persona. + +The implementation is split between channel agnostic core lifecycle hooks and Discord specific extension behavior. + +## Goals + +- Allow direct thread conversation with a spawned subagent session. +- Keep default subagent orchestration channel agnostic. +- Support both automatic thread creation on spawn and manual focus controls. +- Provide predictable cleanup on completion, kill, timeout, and thread lifecycle changes. +- Keep behavior configurable with global defaults plus channel and account overrides. + +## Out of scope + +- New ACP protocol features. +- Non Discord thread binding implementations in this document. +- New bot accounts or app level Discord identity changes. + +## What shipped + +- `sessions_spawn` supports `thread: true` and `mode: "run" | "session"`. +- Spawn flow supports persistent thread bound sessions. +- Discord thread binding manager supports bind, unbind, TTL sweep, and persistence. +- Plugin hook lifecycle for subagents: + - `subagent_spawning` + - `subagent_spawned` + - `subagent_delivery_target` + - `subagent_ended` +- Discord extension implements thread auto bind, delivery target override, and unbind on end. +- Text commands for manual control: + - `/focus` + - `/unfocus` + - `/agents` + - `/session ttl` +- Global and Discord scoped enablement and TTL controls, including a global kill switch. + +## Core concepts + +### Spawn modes + +- `mode: "run"` + - one task lifecycle + - completion announcement flow +- `mode: "session"` + - persistent thread bound session + - supports follow up user messages in thread + +Default mode behavior: + +- if `thread: true` and mode omitted, mode defaults to `"session"` +- otherwise mode defaults to `"run"` + +Constraint: + +- `mode: "session"` requires `thread: true` + +### Thread binding target model + +Bindings are generic targets, not only subagents. + +- `targetKind: "subagent" | "acp"` +- `targetSessionKey: string` + +This allows the same routing primitive to support ACP/session bindings as well. + +### Thread binding manager + +The manager is responsible for: + +- binding or creating threads for a session target +- unbinding by thread or by target session +- managing webhook reuse and recent unbound webhook echo suppression +- TTL based unbind and stale thread cleanup +- persistence load and save + +## Architecture + +### Core and extension boundary + +Core (`src/agents/*`) does not directly depend on Discord routing internals. + +Core emits lifecycle intent through plugin hooks. + +Discord extension (`extensions/discord/src/subagent-hooks.ts`) implements Discord specific behavior: + +- pre spawn thread bind preparation +- completion delivery target override to bound thread +- unbind on subagent end + +### Plugin hook flow + +1. `subagent_spawning` + - before run starts + - can block spawn with `status: "error"` + - used to prepare thread binding when `thread: true` +2. `subagent_spawned` + - post run registration event +3. `subagent_delivery_target` + - completion routing override hook + - can redirect completion delivery to bound Discord thread origin +4. `subagent_ended` + - cleanup and unbind signal + +### Account ID normalization contract + +Thread binding and routing state must use one canonical account id abstraction. + +Specification: + +- Introduce a shared account id module (proposed: `src/routing/account-id.ts`) and stop defining local normalizers. +- Expose two explicit helpers: + - `normalizeAccountId(value): string` + - returns canonical, defaulted id (current default is `default`) + - use for map keys, manager registration and lookup, persistence keys, routing keys + - `normalizeOptionalAccountId(value): string | undefined` + - returns canonical id when present, `undefined` when absent + - use for inbound optional context fields and merge logic +- Do not implement ad hoc account normalization in feature modules. + - This includes `trim`, `toLowerCase`, or defaulting logic in local helper functions. +- Any map keyed by account id must only accept canonical ids from shared helpers. +- Hook payloads and delivery context should carry raw optional account ids, and normalize at module boundaries only. + +Migration guardrails: + +- Replace duplicate normalizers in routing, reply payload, command context, and provider helpers with shared helpers. +- Add contract tests that assert identical normalization behavior across: + - route resolution + - thread binding manager lookup + - reply delivery target filtering + - command run context merge + +### Persistence and state + +Binding state path: + +- `${stateDir}/discord/thread-bindings.json` + +Record shape contains: + +- account, channel, thread +- target kind and target session key +- agent label metadata +- webhook id/token +- boundBy, boundAt, expiresAt + +State is stored on `globalThis` to keep one shared registry across ESM and Jiti loader paths. + +## Configuration + +### Effective precedence + +For Discord thread binding options, account override wins, then channel, then global session default, then built in fallback. + +- account: `channels.discord.accounts..threadBindings.` +- channel: `channels.discord.threadBindings.` +- global: `session.threadBindings.` + +### Keys + +| Key | Scope | Default | Notes | +| ------------------------------------------------------- | --------------- | --------------- | ----------------------------------------- | +| `session.threadBindings.enabled` | global | `true` | master default kill switch | +| `session.threadBindings.ttlHours` | global | `24` | default auto unfocus TTL | +| `channels.discord.threadBindings.enabled` | channel/account | inherits global | Discord override kill switch | +| `channels.discord.threadBindings.ttlHours` | channel/account | inherits global | Discord TTL override | +| `channels.discord.threadBindings.spawnSubagentSessions` | channel/account | `false` | opt in for `thread: true` spawn auto bind | + +### Runtime effect of enable switch + +When effective `enabled` is false for a Discord account: + +- provider creates a noop thread binding manager for runtime wiring +- no real manager is registered for lookup by account id +- inbound bound thread routing is effectively disabled +- completion routing overrides do not resolve bound thread origins +- `/focus`, `/unfocus`, and thread binding specific operations report unavailable +- `thread: true` spawn path returns actionable error from Discord hook layer + +## Flow and behavior + +### Spawn with `thread: true` + +1. Spawn validates mode and permissions. +2. `subagent_spawning` hook runs. +3. Discord extension checks effective flags: + - thread bindings enabled + - `spawnSubagentSessions` enabled +4. Extension attempts auto bind and thread creation. +5. If bind fails: + - spawn returns error + - provisional child session is deleted +6. If bind succeeds: + - child run starts + - run is registered with spawn mode + +### Manual focus and unfocus + +- `/focus ` + - Discord only + - resolves subagent or session target + - binds current or created thread to target session +- `/unfocus` + - Discord thread only + - unbinds current thread + +### Inbound routing + +- Discord preflight checks current thread id against thread binding manager. +- If bound, effective session routing uses bound target session key. +- If not bound, normal routing path is used. + +### Outbound routing + +- Reply delivery checks whether current session has thread bindings. +- Bound sessions deliver to thread via webhook aware path. +- Unbound sessions use normal bot delivery. + +### Completion routing + +- Core completion flow calls `subagent_delivery_target`. +- Discord extension returns bound thread origin when it can resolve one. +- Core merges hook origin with requester origin and delivers completion. + +### Cleanup + +Cleanup occurs on: + +- completion +- error or timeout completion path +- kill and terminate paths +- TTL expiration +- archived or deleted thread probes +- manual `/unfocus` + +Cleanup behavior includes unbind and optional farewell messaging. + +## Commands and user UX + +| Command | Purpose | +| ---------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------- | --------------- | ------------------------------------------- | +| `/subagents spawn [--model] [--thinking]` | spawn subagent; may be thread bound when `thread: true` path is used | +| `/focus ` | manually bind thread to subagent or session | +| `/unfocus` | remove binding from current thread | +| `/agents` | list active agents and binding state | +| `/session ttl ` | update TTL for focused thread binding | + +Notes: + +- `/session ttl` is currently Discord thread focused behavior. +- Thread intro and farewell text are generated by thread binding message helpers. + +## Failure handling and safety + +- Spawn returns explicit errors when thread binding cannot be prepared. +- Spawn failure after provisional bind attempts best effort unbind and session delete. +- Completion logic prevents duplicate ended hook emission. +- Retry and expiry guards prevent infinite completion announce retry loops. +- Webhook echo suppression avoids unbound webhook messages being reprocessed as inbound turns. + +## Module map + +### Core orchestration + +- `src/agents/subagent-spawn.ts` +- `src/agents/subagent-announce.ts` +- `src/agents/subagent-registry.ts` +- `src/agents/subagent-registry-cleanup.ts` +- `src/agents/subagent-registry-completion.ts` + +### Discord runtime + +- `src/discord/monitor/provider.ts` +- `src/discord/monitor/thread-bindings.manager.ts` +- `src/discord/monitor/thread-bindings.state.ts` +- `src/discord/monitor/thread-bindings.lifecycle.ts` +- `src/discord/monitor/thread-bindings.messages.ts` +- `src/discord/monitor/message-handler.preflight.ts` +- `src/discord/monitor/message-handler.process.ts` +- `src/discord/monitor/reply-delivery.ts` + +### Plugin hooks and extension + +- `src/plugins/types.ts` +- `src/plugins/hooks.ts` +- `extensions/discord/src/subagent-hooks.ts` + +### Config and schema + +- `src/config/types.base.ts` +- `src/config/types.discord.ts` +- `src/config/zod-schema.session.ts` +- `src/config/zod-schema.providers-core.ts` +- `src/config/schema.help.ts` +- `src/config/schema.labels.ts` + +## Test coverage highlights + +- `extensions/discord/src/subagent-hooks.test.ts` +- `src/discord/monitor/thread-bindings.ttl.test.ts` +- `src/discord/monitor/thread-bindings.shared-state.test.ts` +- `src/discord/monitor/reply-delivery.test.ts` +- `src/discord/monitor/message-handler.preflight.test.ts` +- `src/discord/monitor/message-handler.process.test.ts` +- `src/auto-reply/reply/commands-subagents-focus.test.ts` +- `src/auto-reply/reply/commands-session-ttl.test.ts` +- `src/agents/subagent-registry.steer-restart.test.ts` +- `src/agents/subagent-registry-completion.test.ts` + +## Operational summary + +- Use `session.threadBindings.enabled` as the global kill switch default. +- Use `channels.discord.threadBindings.enabled` and account overrides for selective enablement. +- Keep `spawnSubagentSessions` opt in for thread auto spawn behavior. +- Use TTL settings for automatic unfocus policy control. + +This model keeps subagent lifecycle orchestration generic while giving Discord a full thread bound interaction path. + +## Related plan + +For channel agnostic SessionBinding architecture and scoped iteration planning, see: + +- `docs/experiments/plans/session-binding-channel-agnostic.md` + +ACP remains a next step in that plan and is intentionally not implemented in this shipped Discord thread-bound flow. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 67f7a23e199..4d58fb5a437 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -78,7 +78,11 @@ Text + native (when enabled): - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) +- `/session ttl ` (manage session-level settings, such as TTL) - `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session) +- `/agents` (list thread-bound agents for this session) +- `/focus ` (Discord: bind this thread, or a new thread, to a session/subagent target) +- `/unfocus` (Discord: remove the current thread binding) - `/kill ` (immediately abort one or all running sub-agents for this session; no confirmation message) - `/steer ` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message) - `/tell ` (alias for `/steer`) diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index ab639cbaff2..dcddde67c86 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; +import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; const plugin = { id: "discord", @@ -11,6 +12,7 @@ const plugin = { register(api: OpenClawPluginApi) { setDiscordRuntime(api.runtime); api.registerChannel({ plugin: discordPlugin }); + registerDiscordSubagentHooks(api); }, }; diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts new file mode 100644 index 00000000000..8e2514b3b77 --- /dev/null +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -0,0 +1,430 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerDiscordSubagentHooks } from "./subagent-hooks.js"; + +type ThreadBindingRecord = { + accountId: string; + threadId: string; +}; + +type MockResolvedDiscordAccount = { + accountId: string; + config: { + threadBindings?: { + enabled?: boolean; + spawnSubagentSessions?: boolean; + }; + }; +}; + +const hookMocks = vi.hoisted(() => ({ + resolveDiscordAccount: vi.fn( + (params?: { accountId?: string }): MockResolvedDiscordAccount => ({ + accountId: params?.accountId?.trim() || "default", + config: { + threadBindings: { + spawnSubagentSessions: true, + }, + }, + }), + ), + autoBindSpawnedDiscordSubagent: vi.fn( + async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }), + ), + listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []), + unbindThreadBindingsBySessionKey: vi.fn(() => []), +})); + +vi.mock("openclaw/plugin-sdk", () => ({ + resolveDiscordAccount: hookMocks.resolveDiscordAccount, + autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent, + listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey, + unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey, +})); + +function registerHandlersForTest( + config: Record = { + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: true, + }, + }, + }, + }, +) { + const handlers = new Map unknown>(); + const api = { + config, + on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => { + handlers.set(hookName, handler); + }, + } as unknown as OpenClawPluginApi; + registerDiscordSubagentHooks(api); + return handlers; +} + +describe("discord subagent hook handlers", () => { + beforeEach(() => { + hookMocks.resolveDiscordAccount.mockClear(); + hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({ + accountId: params?.accountId?.trim() || "default", + config: { + threadBindings: { + spawnSubagentSessions: true, + }, + }, + })); + hookMocks.autoBindSpawnedDiscordSubagent.mockClear(); + hookMocks.listThreadBindingsBySessionKey.mockClear(); + hookMocks.unbindThreadBindingsBySessionKey.mockClear(); + }); + + it("registers subagent hooks", () => { + const handlers = registerHandlersForTest(); + expect(handlers.has("subagent_spawning")).toBe(true); + expect(handlers.has("subagent_delivery_target")).toBe(true); + expect(handlers.has("subagent_spawned")).toBe(false); + expect(handlers.has("subagent_ended")).toBe(true); + }); + + it("binds thread routing on subagent_spawning", async () => { + const handlers = registerHandlersForTest(); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "banana", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "456", + }, + threadRequested: true, + }, + {}, + ); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); + expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({ + accountId: "work", + channel: "discord", + to: "channel:123", + threadId: "456", + childSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "banana", + boundBy: "system", + }); + expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); + }); + + it("returns error when thread-bound subagent spawn is disabled", async () => { + const handlers = registerHandlersForTest({ + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: false, + }, + }, + }, + }); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + threadRequested: true, + }, + {}, + ); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: "error" }); + const errorText = (result as { error?: string }).error ?? ""; + expect(errorText).toContain("spawnSubagentSessions=true"); + }); + + it("returns error when global thread bindings are disabled", async () => { + const handlers = registerHandlersForTest({ + session: { + threadBindings: { + enabled: false, + }, + }, + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: true, + }, + }, + }, + }); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + threadRequested: true, + }, + {}, + ); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: "error" }); + const errorText = (result as { error?: string }).error ?? ""; + expect(errorText).toContain("threadBindings.enabled=true"); + }); + + it("allows account-level threadBindings.enabled to override global disable", async () => { + const handlers = registerHandlersForTest({ + session: { + threadBindings: { + enabled: false, + }, + }, + channels: { + discord: { + accounts: { + work: { + threadBindings: { + enabled: true, + spawnSubagentSessions: true, + }, + }, + }, + }, + }, + }); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + threadRequested: true, + }, + {}, + ); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); + }); + + it("defaults thread-bound subagent spawn to disabled when unset", async () => { + const handlers = registerHandlersForTest({ + channels: { + discord: { + threadBindings: {}, + }, + }, + }); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + threadRequested: true, + }, + {}, + ); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: "error" }); + }); + + it("no-ops when thread binding is requested on non-discord channel", async () => { + const handlers = registerHandlersForTest(); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + mode: "session", + requester: { + channel: "signal", + to: "+123", + }, + threadRequested: true, + }, + {}, + ); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("returns error when thread bind fails", async () => { + hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null); + const handlers = registerHandlersForTest(); + const handler = handlers.get("subagent_spawning"); + if (!handler) { + throw new Error("expected subagent_spawning hook handler"); + } + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + threadRequested: true, + }, + {}, + ); + + expect(result).toMatchObject({ status: "error" }); + const errorText = (result as { error?: string }).error ?? ""; + expect(errorText).toMatch(/unable to create or bind/i); + }); + + it("unbinds thread routing on subagent_ended", () => { + const handlers = registerHandlersForTest(); + const handler = handlers.get("subagent_ended"); + if (!handler) { + throw new Error("expected subagent_ended hook handler"); + } + + handler( + { + targetSessionKey: "agent:main:subagent:child", + targetKind: "subagent", + reason: "subagent-complete", + sendFarewell: true, + accountId: "work", + }, + {}, + ); + + expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1); + expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "work", + targetKind: "subagent", + reason: "subagent-complete", + sendFarewell: true, + }); + }); + + it("resolves delivery target from matching bound thread", () => { + hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([ + { accountId: "work", threadId: "777" }, + ]); + const handlers = registerHandlersForTest(); + const handler = handlers.get("subagent_delivery_target"); + if (!handler) { + throw new Error("expected subagent_delivery_target hook handler"); + } + + const result = handler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "777", + }, + childRunId: "run-1", + spawnMode: "session", + expectsCompletionMessage: true, + }, + {}, + ); + + expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "work", + targetKind: "subagent", + }); + expect(result).toEqual({ + origin: { + channel: "discord", + accountId: "work", + to: "channel:777", + threadId: "777", + }, + }); + }); + + it("keeps original routing when delivery target is ambiguous", () => { + hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([ + { accountId: "work", threadId: "777" }, + { accountId: "work", threadId: "888" }, + ]); + const handlers = registerHandlersForTest(); + const handler = handlers.get("subagent_delivery_target"); + if (!handler) { + throw new Error("expected subagent_delivery_target hook handler"); + } + + const result = handler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + childRunId: "run-1", + spawnMode: "session", + expectsCompletionMessage: true, + }, + {}, + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts new file mode 100644 index 00000000000..8ecd7873d88 --- /dev/null +++ b/extensions/discord/src/subagent-hooks.ts @@ -0,0 +1,152 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { + autoBindSpawnedDiscordSubagent, + listThreadBindingsBySessionKey, + resolveDiscordAccount, + unbindThreadBindingsBySessionKey, +} from "openclaw/plugin-sdk"; + +function summarizeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + return "error"; +} + +export function registerDiscordSubagentHooks(api: OpenClawPluginApi) { + const resolveThreadBindingFlags = (accountId?: string) => { + const account = resolveDiscordAccount({ + cfg: api.config, + accountId, + }); + const baseThreadBindings = api.config.channels?.discord?.threadBindings; + const accountThreadBindings = + api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings; + return { + enabled: + accountThreadBindings?.enabled ?? + baseThreadBindings?.enabled ?? + api.config.session?.threadBindings?.enabled ?? + true, + spawnSubagentSessions: + accountThreadBindings?.spawnSubagentSessions ?? + baseThreadBindings?.spawnSubagentSessions ?? + false, + }; + }; + + api.on("subagent_spawning", async (event) => { + if (!event.threadRequested) { + return; + } + const channel = event.requester?.channel?.trim().toLowerCase(); + if (channel !== "discord") { + // Ignore non-Discord channels so channel-specific plugins can handle + // their own thread/session provisioning without Discord blocking them. + return; + } + const threadBindingFlags = resolveThreadBindingFlags(event.requester?.accountId); + if (!threadBindingFlags.enabled) { + return { + status: "error" as const, + error: + "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).", + }; + } + if (!threadBindingFlags.spawnSubagentSessions) { + return { + status: "error" as const, + error: + "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).", + }; + } + try { + const binding = await autoBindSpawnedDiscordSubagent({ + accountId: event.requester?.accountId, + channel: event.requester?.channel, + to: event.requester?.to, + threadId: event.requester?.threadId, + childSessionKey: event.childSessionKey, + agentId: event.agentId, + label: event.label, + boundBy: "system", + }); + if (!binding) { + return { + status: "error" as const, + error: + "Unable to create or bind a Discord thread for this subagent session. Session mode is unavailable for this target.", + }; + } + return { status: "ok" as const, threadBindingReady: true }; + } catch (err) { + return { + status: "error" as const, + error: `Discord thread bind failed: ${summarizeError(err)}`, + }; + } + }); + + api.on("subagent_ended", (event) => { + unbindThreadBindingsBySessionKey({ + targetSessionKey: event.targetSessionKey, + accountId: event.accountId, + targetKind: event.targetKind, + reason: event.reason, + sendFarewell: event.sendFarewell, + }); + }); + + api.on("subagent_delivery_target", (event) => { + if (!event.expectsCompletionMessage) { + return; + } + const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase(); + if (requesterChannel !== "discord") { + return; + } + const requesterAccountId = event.requesterOrigin?.accountId?.trim(); + const requesterThreadId = + event.requesterOrigin?.threadId != null && event.requesterOrigin.threadId !== "" + ? String(event.requesterOrigin.threadId).trim() + : ""; + const bindings = listThreadBindingsBySessionKey({ + targetSessionKey: event.childSessionKey, + ...(requesterAccountId ? { accountId: requesterAccountId } : {}), + targetKind: "subagent", + }); + if (bindings.length === 0) { + return; + } + + let binding: (typeof bindings)[number] | undefined; + if (requesterThreadId) { + binding = bindings.find((entry) => { + if (entry.threadId !== requesterThreadId) { + return false; + } + if (requesterAccountId && entry.accountId !== requesterAccountId) { + return false; + } + return true; + }); + } + if (!binding && bindings.length === 1) { + binding = bindings[0]; + } + if (!binding) { + return; + } + return { + origin: { + channel: "discord", + accountId: binding.accountId, + to: `channel:${binding.threadId}`, + threadId: binding.threadId, + }, + }; + }); +} diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index d02f0089bb0..d2e93702c5f 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -79,6 +79,8 @@ describe("sessions tools", () => { expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number"); expect(schemaProp("sessions_spawn", "thinking").type).toBe("string"); expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number"); + expect(schemaProp("sessions_spawn", "thread").type).toBe("boolean"); + expect(schemaProp("sessions_spawn", "mode").type).toBe("string"); expect(schemaProp("subagents", "recentMinutes").type).toBe("number"); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index b3fbdacf152..d929ff16f7e 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -133,35 +133,6 @@ const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { ); }; -function expectSingleCompletionSend( - calls: GatewayRequest[], - expected: { sessionKey: string; channel: string; to: string; message: string }, -) { - const sendCalls = calls.filter((call) => call.method === "send"); - expect(sendCalls).toHaveLength(1); - const send = sendCalls[0]?.params as - | { sessionKey?: string; channel?: string; to?: string; message?: string } - | undefined; - expect(send?.sessionKey).toBe(expected.sessionKey); - expect(send?.channel).toBe(expected.channel); - expect(send?.to).toBe(expected.to); - expect(send?.message).toBe(expected.message); -} - -function createDeleteCleanupHooks(setDeletedKey: (key: string | undefined) => void) { - return { - onAgentSubagentSpawn: (params: unknown) => { - const rec = params as { channel?: string; timeout?: number } | undefined; - expect(rec?.channel).toBe("discord"); - expect(rec?.timeout).toBe(1); - }, - onSessionsDelete: (params: unknown) => { - const rec = params as { key?: string } | undefined; - setDeletedKey(rec?.key); - }, - }; -} - describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -184,7 +155,6 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - agentTo: "+123", }); const result = await tool.execute("call2", { @@ -213,7 +183,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); await waitFor(() => patchCalls.some((call) => call.label === "my-task")); - await waitFor(() => ctx.calls.filter((c) => c.method === "send").length >= 1); + await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); @@ -222,21 +192,22 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(labelPatch?.key).toBe(child.sessionKey); expect(labelPatch?.label).toBe("my-task"); - // Subagent spawn call plus direct outbound completion send. + // Two agent calls: subagent spawn + main agent trigger const agentCalls = ctx.calls.filter((c) => c.method === "agent"); - expect(agentCalls).toHaveLength(1); + expect(agentCalls).toHaveLength(2); // First call: subagent spawn const first = agentCalls[0]?.params as { lane?: string } | undefined; expect(first?.lane).toBe("subagent"); - // Direct send should route completion to the requester channel/session. - expectSingleCompletionSend(ctx.calls, { - sessionKey: "agent:main:main", - channel: "whatsapp", - to: "+123", - message: "✅ Subagent main finished\n\ndone", - }); + // Second call: main agent trigger (not "Sub-agent announce step." anymore) + const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined; + expect(second?.sessionKey).toBe("agent:main:main"); + expect(second?.message).toContain("subagent task"); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = ctx.calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); @@ -245,15 +216,20 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { callGatewayMock.mockReset(); let deletedKey: string | undefined; const ctx = setupSessionsSpawnGatewayMock({ - ...createDeleteCleanupHooks((key) => { - deletedKey = key; - }), + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, }); const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - agentTo: "discord:dm:u123", }); const result = await tool.execute("call1", { @@ -287,11 +263,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { vi.useRealTimers(); } + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => Boolean(deletedKey)); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); const agentCalls = ctx.calls.filter((call) => call.method === "agent"); - expect(agentCalls).toHaveLength(1); + expect(agentCalls).toHaveLength(2); const first = agentCalls[0]?.params as | { @@ -307,12 +286,19 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - expectSingleCompletionSend(ctx.calls, { - sessionKey: "agent:main:discord:group:req", - channel: "discord", - to: "discord:dm:u123", - message: "✅ Subagent main finished", - }); + const second = agentCalls[1]?.params as + | { + sessionKey?: string; + message?: string; + deliver?: boolean; + } + | undefined; + expect(second?.sessionKey).toBe("agent:main:discord:group:req"); + expect(second?.deliver).toBe(true); + expect(second?.message).toContain("subagent task"); + + const sendCalls = ctx.calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); @@ -323,16 +309,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { let deletedKey: string | undefined; const ctx = setupSessionsSpawnGatewayMock({ includeChatHistory: true, - ...createDeleteCleanupHooks((key) => { - deletedKey = key; - }), + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - agentTo: "discord:dm:u123", }); const result = await tool.execute("call1b", { @@ -350,27 +341,29 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { throw new Error("missing child runId"); } await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); - await waitFor(() => ctx.calls.filter((call) => call.method === "send").length >= 1); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); await waitFor(() => Boolean(deletedKey)); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - // One agent call for spawn, then direct completion send. + // Two agent calls: subagent spawn + main agent trigger const agentCalls = ctx.calls.filter((call) => call.method === "agent"); - expect(agentCalls).toHaveLength(1); + expect(agentCalls).toHaveLength(2); // First call: subagent spawn const first = agentCalls[0]?.params as { lane?: string } | undefined; expect(first?.lane).toBe("subagent"); - expectSingleCompletionSend(ctx.calls, { - sessionKey: "agent:main:discord:group:req", - channel: "discord", - to: "discord:dm:u123", - message: "✅ Subagent main finished\n\ndone", - }); + // Second call: main agent trigger + const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; + expect(second?.sessionKey).toBe("agent:main:discord:group:req"); + expect(second?.deliver).toBe(true); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = ctx.calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); // Session should be deleted expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 14b0e2d29bb..3c363ac4172 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -1,4 +1,5 @@ import { getChannelDock } from "../channels/dock.js"; +import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; @@ -83,7 +84,8 @@ function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy { const configured = cfg?.tools?.subagents?.tools; - const maxSpawnDepth = cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + const maxSpawnDepth = + cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1; const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth); const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])]; diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts new file mode 100644 index 00000000000..6db18f609ba --- /dev/null +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./test-helpers/fast-core-tools.js"; +import { + getCallGatewayMock, + getSessionsSpawnTool, + setSessionsSpawnConfigOverride, +} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; + +const hookRunnerMocks = vi.hoisted(() => ({ + hasSubagentEndedHook: true, + runSubagentSpawning: vi.fn(async (event: unknown) => { + const input = event as { + threadRequested?: boolean; + requester?: { channel?: string }; + }; + if (!input.threadRequested) { + return undefined; + } + const channel = input.requester?.channel?.trim().toLowerCase(); + if (channel !== "discord") { + const channelLabel = input.requester?.channel?.trim() || "unknown"; + return { + status: "error" as const, + error: `thread=true is not supported for channel "${channelLabel}". Only Discord thread-bound subagent sessions are supported right now.`, + }; + } + return { + status: "ok" as const, + threadBindingReady: true, + }; + }), + runSubagentSpawned: vi.fn(async () => {}), + runSubagentEnded: vi.fn(async () => {}), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => ({ + hasHooks: (hookName: string) => + hookName === "subagent_spawning" || + hookName === "subagent_spawned" || + (hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook), + runSubagentSpawning: hookRunnerMocks.runSubagentSpawning, + runSubagentSpawned: hookRunnerMocks.runSubagentSpawned, + runSubagentEnded: hookRunnerMocks.runSubagentEnded, + })), +})); + +describe("sessions_spawn subagent lifecycle hooks", () => { + beforeEach(() => { + hookRunnerMocks.hasSubagentEndedHook = true; + hookRunnerMocks.runSubagentSpawning.mockClear(); + hookRunnerMocks.runSubagentSpawned.mockClear(); + hookRunnerMocks.runSubagentEnded.mockClear(); + const callGatewayMock = getCallGatewayMock(); + callGatewayMock.mockReset(); + setSessionsSpawnConfigOverride({ + session: { + mainKey: "main", + scope: "per-sender", + }, + }); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent") { + return { runId: "run-1", status: "accepted", acceptedAt: 1 }; + } + if (request.method === "agent.wait") { + return { runId: "run-1", status: "running" }; + } + return {}; + }); + }); + + it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentAccountId: "work", + agentTo: "channel:123", + agentThreadId: 456, + }); + + const result = await tool.execute("call", { + task: "do thing", + label: "research", + runTimeoutSeconds: 1, + thread: true, + }); + + expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" }); + expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1); + expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledWith( + { + childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + agentId: "main", + label: "research", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: 456, + }, + threadRequested: true, + }, + { + childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + requesterSessionKey: "main", + }, + ); + + expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1); + const [event, ctx] = (hookRunnerMocks.runSubagentSpawned.mock.calls[0] ?? []) as unknown as [ + Record, + Record, + ]; + expect(event).toMatchObject({ + runId: "run-1", + agentId: "main", + label: "research", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: 456, + }, + threadRequested: true, + }); + expect(event.childSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/)); + expect(ctx).toMatchObject({ + runId: "run-1", + requesterSessionKey: "main", + childSessionKey: event.childSessionKey, + }); + }); + + it("emits subagent_spawned with threadRequested=false when not requested", async () => { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentTo: "channel:123", + }); + + const result = await tool.execute("call2", { + task: "do thing", + runTimeoutSeconds: 1, + }); + + expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" }); + expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled(); + expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1); + const [event] = (hookRunnerMocks.runSubagentSpawned.mock.calls[0] ?? []) as unknown as [ + Record, + ]; + expect(event).toMatchObject({ + mode: "run", + threadRequested: false, + requester: { + channel: "discord", + to: "channel:123", + }, + }); + }); + + it("respects explicit mode=run when thread binding is requested", async () => { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentTo: "channel:123", + }); + + const result = await tool.execute("call3", { + task: "do thing", + runTimeoutSeconds: 1, + thread: true, + mode: "run", + }); + + expect(result.details).toMatchObject({ status: "accepted", runId: "run-1", mode: "run" }); + expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1); + const [event] = (hookRunnerMocks.runSubagentSpawned.mock.calls[0] ?? []) as unknown as [ + Record, + ]; + expect(event).toMatchObject({ + mode: "run", + threadRequested: true, + }); + }); + + it("returns error when thread binding cannot be created", async () => { + hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({ + status: "error", + error: "Unable to create or bind a Discord thread for this subagent session.", + }); + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentAccountId: "work", + agentTo: "channel:123", + }); + + const result = await tool.execute("call4", { + task: "do thing", + runTimeoutSeconds: 1, + thread: true, + mode: "session", + }); + + expect(result.details).toMatchObject({ status: "error" }); + const details = result.details as { error?: string; childSessionKey?: string }; + expect(details.error).toMatch(/thread/i); + expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled(); + const callGatewayMock = getCallGatewayMock(); + const calledMethods = callGatewayMock.mock.calls.map((call: [unknown]) => { + const request = call[0] as { method?: string }; + return request.method; + }); + expect(calledMethods).toContain("sessions.delete"); + expect(calledMethods).not.toContain("agent"); + const deleteCall = callGatewayMock.mock.calls + .map((call: [unknown]) => call[0] as { method?: string; params?: Record }) + .find( + (request: { method?: string; params?: Record }) => + request.method === "sessions.delete", + ); + expect(deleteCall?.params).toMatchObject({ + key: details.childSessionKey, + emitLifecycleHooks: false, + }); + }); + + it("rejects mode=session when thread=true is not requested", async () => { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentTo: "channel:123", + }); + + const result = await tool.execute("call6", { + task: "do thing", + mode: "session", + }); + + expect(result.details).toMatchObject({ status: "error" }); + const details = result.details as { error?: string }; + expect(details.error).toMatch(/requires thread=true/i); + expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled(); + expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled(); + const callGatewayMock = getCallGatewayMock(); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects thread=true on channels without thread support", async () => { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "signal", + agentTo: "+123", + }); + + const result = await tool.execute("call5", { + task: "do thing", + thread: true, + mode: "session", + }); + + expect(result.details).toMatchObject({ status: "error" }); + const details = result.details as { error?: string }; + expect(details.error).toMatch(/only discord/i); + expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1); + expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled(); + const callGatewayMock = getCallGatewayMock(); + const calledMethods = callGatewayMock.mock.calls.map((call: [unknown]) => { + const request = call[0] as { method?: string }; + return request.method; + }); + expect(calledMethods).toContain("sessions.delete"); + expect(calledMethods).not.toContain("agent"); + }); + + it("runs subagent_ended cleanup hook when agent start fails after successful bind", async () => { + const callGatewayMock = getCallGatewayMock(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent") { + throw new Error("spawn failed"); + } + return {}; + }); + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentAccountId: "work", + agentTo: "channel:123", + agentThreadId: "456", + }); + + const result = await tool.execute("call7", { + task: "do thing", + thread: true, + mode: "session", + }); + + expect(result.details).toMatchObject({ status: "error" }); + expect(hookRunnerMocks.runSubagentEnded).toHaveBeenCalledTimes(1); + const [event] = (hookRunnerMocks.runSubagentEnded.mock.calls[0] ?? []) as unknown as [ + Record, + ]; + expect(event).toMatchObject({ + targetSessionKey: expect.stringMatching(/^agent:main:subagent:/), + accountId: "work", + targetKind: "subagent", + reason: "spawn-failed", + sendFarewell: true, + outcome: "error", + error: "Session failed to start", + }); + const deleteCall = callGatewayMock.mock.calls + .map((call: [unknown]) => call[0] as { method?: string; params?: Record }) + .find( + (request: { method?: string; params?: Record }) => + request.method === "sessions.delete", + ); + expect(deleteCall?.params).toMatchObject({ + key: event.targetSessionKey, + deleteTranscript: true, + emitLifecycleHooks: false, + }); + }); + + it("falls back to sessions.delete cleanup when subagent_ended hook is unavailable", async () => { + hookRunnerMocks.hasSubagentEndedHook = false; + const callGatewayMock = getCallGatewayMock(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent") { + throw new Error("spawn failed"); + } + return {}; + }); + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "discord", + agentAccountId: "work", + agentTo: "channel:123", + agentThreadId: "456", + }); + + const result = await tool.execute("call8", { + task: "do thing", + thread: true, + mode: "session", + }); + + expect(result.details).toMatchObject({ status: "error" }); + expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled(); + const methods = callGatewayMock.mock.calls.map((call: [unknown]) => { + const request = call[0] as { method?: string }; + return request.method; + }); + expect(methods).toContain("sessions.delete"); + const deleteCall = callGatewayMock.mock.calls + .map((call: [unknown]) => call[0] as { method?: string; params?: Record }) + .find( + (request: { method?: string; params?: Record }) => + request.method === "sessions.delete", + ); + expect(deleteCall?.params).toMatchObject({ + deleteTranscript: true, + emitLifecycleHooks: true, + }); + }); +}); diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index b6e594a401b..2b775be8500 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -1,11 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { + __testing as sessionBindingServiceTesting, + registerSessionBindingAdapter, +} from "../infra/outbound/session-binding-service.js"; type AgentCallRequest = { method?: string; params?: Record }; type RequesterResolution = { requesterSessionKey: string; requesterOrigin?: Record; } | null; +type SubagentDeliveryTargetResult = { + origin?: { + channel?: string; + accountId?: string; + to?: string; + threadId?: string | number; + }; +}; const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" })); const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" })); @@ -24,6 +36,19 @@ const subagentRegistryMock = { countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0), resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null), }; +const subagentDeliveryTargetHookMock = vi.fn( + async (_event?: unknown, _ctx?: unknown): Promise => + undefined, +); +let hasSubagentDeliveryTargetHook = false; +const hookRunnerMock = { + hasHooks: vi.fn( + (hookName: string) => hookName === "subagent_delivery_target" && hasSubagentDeliveryTargetHook, + ), + runSubagentDeliveryTarget: vi.fn((event: unknown, ctx: unknown) => + subagentDeliveryTargetHookMock(event, ctx), + ), +}; const chatHistoryMock = vi.fn(async (_sessionKey?: string) => ({ messages: [] as Array, })); @@ -103,6 +128,9 @@ vi.mock("../config/sessions.js", () => ({ vi.mock("./pi-embedded.js", () => embeddedRunMock); vi.mock("./subagent-registry.js", () => subagentRegistryMock); +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookRunnerMock, +})); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -114,9 +142,13 @@ vi.mock("../config/config.js", async (importOriginal) => { describe("subagent announce formatting", () => { beforeEach(() => { - agentSpy.mockClear(); - sendSpy.mockClear(); - sessionsDeleteSpy.mockClear(); + agentSpy + .mockReset() + .mockImplementation(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" })); + sendSpy + .mockReset() + .mockImplementation(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" })); + sessionsDeleteSpy.mockReset().mockImplementation((_req: AgentCallRequest) => undefined); embeddedRunMock.isEmbeddedPiRunActive.mockReset().mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false); embeddedRunMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false); @@ -124,9 +156,14 @@ describe("subagent announce formatting", () => { subagentRegistryMock.isSubagentSessionRunActive.mockReset().mockReturnValue(true); subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0); subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null); + hasSubagentDeliveryTargetHook = false; + hookRunnerMock.hasHooks.mockClear(); + hookRunnerMock.runSubagentDeliveryTarget.mockClear(); + subagentDeliveryTargetHookMock.mockReset().mockResolvedValue(undefined); readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply"); chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); sessionStore = {}; + sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); configOverride = { session: { mainKey: "main", @@ -328,6 +365,7 @@ describe("subagent announce formatting", () => { chatHistoryMock.mockResolvedValueOnce({ messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: 2" }] }], }); + readLatestAssistantReplyMock.mockResolvedValue(""); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", @@ -353,6 +391,283 @@ describe("subagent announce formatting", () => { expect(msg).not.toContain("Convert the result above into your normal assistant voice"); }); + it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-coordinated", + }, + "agent:main:main": { + sessionId: "requester-session-coordinated", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: 2" }] }], + }); + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:main" ? 1 : 0, + ); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-coordinated", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + const rawMessage = call?.params?.message; + const msg = typeof rawMessage === "string" ? rawMessage : ""; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:12345"); + expect(msg).toContain("There are still 1 active subagent run for this session."); + expect(msg).toContain( + "If they are part of the same workflow, wait for the remaining results before sending a user update.", + ); + }); + + it("keeps session-mode completion delivery on the bound destination when sibling runs are active", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-bound", + }, + "agent:main:main": { + sessionId: "requester-session-bound", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "bound answer: 2" }] }], + }); + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:main" ? 1 : 0, + ); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "acct-1", + listBySession: (targetSessionKey: string) => + targetSessionKey === "agent:main:subagent:test" + ? [ + { + bindingId: "discord:acct-1:thread-bound-1", + targetSessionKey, + targetKind: "subagent", + conversation: { + channel: "discord", + accountId: "acct-1", + conversationId: "thread-bound-1", + parentConversationId: "parent-main", + }, + status: "active", + boundAt: Date.now(), + }, + ] + : [], + resolveByConversation: () => null, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-session-bound-direct", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:thread-bound-1"); + }); + + it("does not duplicate to main channel when two active bound sessions complete from the same requester channel", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:child-a": { + sessionId: "child-session-a", + }, + "agent:main:subagent:child-b": { + sessionId: "child-session-b", + }, + "agent:main:main": { + sessionId: "requester-session-main", + }, + }; + + // Simulate active sibling runs so non-bound paths would normally coordinate via agent(). + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:main" ? 2 : 0, + ); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "acct-1", + listBySession: (targetSessionKey: string) => { + if (targetSessionKey === "agent:main:subagent:child-a") { + return [ + { + bindingId: "discord:acct-1:thread-child-a", + targetSessionKey, + targetKind: "subagent", + conversation: { + channel: "discord", + accountId: "acct-1", + conversationId: "thread-child-a", + parentConversationId: "main-parent-channel", + }, + status: "active", + boundAt: Date.now(), + }, + ]; + } + if (targetSessionKey === "agent:main:subagent:child-b") { + return [ + { + bindingId: "discord:acct-1:thread-child-b", + targetSessionKey, + targetKind: "subagent", + conversation: { + channel: "discord", + accountId: "acct-1", + conversationId: "thread-child-b", + parentConversationId: "main-parent-channel", + }, + status: "active", + boundAt: Date.now(), + }, + ]; + } + return []; + }, + resolveByConversation: () => null, + }); + + await Promise.all([ + runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:child-a", + childRunId: "run-child-a", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:main-parent-channel", + accountId: "acct-1", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }), + runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:child-b", + childRunId: "run-child-b", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:main-parent-channel", + accountId: "acct-1", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }), + ]); + + await expect.poll(() => sendSpy.mock.calls.length).toBe(2); + expect(agentSpy).not.toHaveBeenCalled(); + + const directTargets = sendSpy.mock.calls.map( + (call) => (call?.[0] as { params?: { to?: string } })?.params?.to, + ); + expect(directTargets).toEqual( + expect.arrayContaining(["channel:thread-child-a", "channel:thread-child-b"]), + ); + expect(directTargets).not.toContain("channel:main-parent-channel"); + }); + + it("uses failure header for completion direct-send when subagent outcome is error", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-direct-error", + }, + "agent:main:main": { + sessionId: "requester-session-error", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "boom details" }] }], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-error", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + outcome: { status: "error", error: "boom" }, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + const rawMessage = call?.params?.message; + const msg = typeof rawMessage === "string" ? rawMessage : ""; + expect(msg).toContain("❌ Subagent main failed this task (session remains active)"); + expect(msg).toContain("boom details"); + expect(msg).not.toContain("✅ Subagent main"); + }); + + it("uses timeout header for completion direct-send when subagent outcome timed out", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-direct-timeout", + }, + "agent:main:main": { + sessionId: "requester-session-timeout", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "partial output" }] }], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-timeout", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + outcome: { status: "timeout" }, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + const rawMessage = call?.params?.message; + const msg = typeof rawMessage === "string" ? rawMessage : ""; + expect(msg).toContain("⏱️ Subagent main timed out"); + expect(msg).toContain("partial output"); + expect(msg).not.toContain("✅ Subagent main finished"); + }); + it("ignores stale session thread hints for manual completion direct-send", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); sessionStore = { @@ -427,6 +742,197 @@ describe("subagent announce formatting", () => { expect(call?.params?.threadId).toBe("99"); }); + it("uses hook-provided thread target for completion direct-send", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + hasSubagentDeliveryTargetHook = true; + subagentDeliveryTargetHookMock.mockResolvedValueOnce({ + origin: { + channel: "discord", + accountId: "acct-1", + to: "channel:777", + threadId: "777", + }, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-thread-bound", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + threadId: "777", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(subagentDeliveryTargetHookMock).toHaveBeenCalledWith( + { + childSessionKey: "agent:main:subagent:test", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + threadId: "777", + }, + childRunId: "run-direct-thread-bound", + spawnMode: "session", + expectsCompletionMessage: true, + }, + { + runId: "run-direct-thread-bound", + childSessionKey: "agent:main:subagent:test", + requesterSessionKey: "agent:main:main", + }, + ); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:777"); + expect(call?.params?.threadId).toBe("777"); + const message = typeof call?.params?.message === "string" ? call.params.message : ""; + expect(message).toContain("completed this task (session remains active)"); + expect(message).not.toContain("finished"); + }); + + it("uses hook-provided thread target when requester origin has no threadId", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + hasSubagentDeliveryTargetHook = true; + subagentDeliveryTargetHookMock.mockResolvedValueOnce({ + origin: { + channel: "discord", + accountId: "acct-1", + to: "channel:777", + threadId: "777", + }, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-thread-bound-single", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:777"); + expect(call?.params?.threadId).toBe("777"); + }); + + it("keeps requester origin when delivery-target hook returns no override", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + hasSubagentDeliveryTargetHook = true; + subagentDeliveryTargetHookMock.mockResolvedValueOnce(undefined); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-thread-persisted", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:12345"); + expect(call?.params?.threadId).toBeUndefined(); + }); + + it("keeps requester origin when delivery-target hook returns non-deliverable channel", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + hasSubagentDeliveryTargetHook = true; + subagentDeliveryTargetHookMock.mockResolvedValueOnce({ + origin: { + channel: "webchat", + to: "conversation:123", + }, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-thread-multi-no-origin", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:12345"); + expect(call?.params?.threadId).toBeUndefined(); + }); + + it("uses hook-provided thread target when requester threadId does not match", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + hasSubagentDeliveryTargetHook = true; + subagentDeliveryTargetHookMock.mockResolvedValueOnce({ + origin: { + channel: "discord", + accountId: "acct-1", + to: "channel:777", + threadId: "777", + }, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-thread-no-match", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + threadId: "999", + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:777"); + expect(call?.params?.threadId).toBe("777"); + }); + it("steers announcements into an active run when queue mode is steer", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -623,13 +1129,14 @@ describe("subagent announce formatting", () => { }, ], }); - readLatestAssistantReplyMock.mockResolvedValue("assistant ignored fallback"); + readLatestAssistantReplyMock.mockResolvedValue(""); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:worker", childRunId: "run-completion-assistant-output", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, expectsCompletionMessage: true, ...defaultOutcomeAnnounce, }); @@ -663,6 +1170,7 @@ describe("subagent announce formatting", () => { childRunId: "run-completion-tool-output", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, expectsCompletionMessage: true, ...defaultOutcomeAnnounce, }); @@ -674,6 +1182,36 @@ describe("subagent announce formatting", () => { expect(msg).toContain("tool output only"); }); + it("ignores user text when deriving fallback completion output", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "user", + content: [{ type: "text", text: "user prompt should not be announced" }], + }, + ], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-ignore-user", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + await expect.poll(() => sendSpy.mock.calls.length).toBe(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("✅ Subagent main finished"); + expect(msg).not.toContain("user prompt should not be announced"); + }); + it("queues announce delivery back into requester subagent session", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -856,6 +1394,34 @@ describe("subagent announce formatting", () => { expect(call?.params?.to).toBeUndefined(); }); + it("keeps completion-mode announce internal for nested requester subagent sessions", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:orchestrator:subagent:worker", + childRunId: "run-worker-nested-completion", + requesterSessionKey: "agent:main:subagent:orchestrator", + requesterOrigin: { channel: "whatsapp", accountId: "acct-123", to: "+1555" }, + requesterDisplayKey: "agent:main:subagent:orchestrator", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator"); + expect(call?.params?.deliver).toBe(false); + expect(call?.params?.channel).toBeUndefined(); + expect(call?.params?.to).toBeUndefined(); + const message = typeof call?.params?.message === "string" ? call.params.message : ""; + expect(message).toContain( + "Convert this completion into a concise internal orchestration update for your parent agent", + ); + }); + it("retries reading subagent output when early lifecycle completion had no text", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValueOnce(true).mockReturnValue(false); @@ -933,6 +1499,57 @@ describe("subagent announce formatting", () => { expect(agentSpy).not.toHaveBeenCalled(); }); + it("defers completion-mode announce while the finished run still has active descendants", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:subagent:parent" ? 1 : 0, + ); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:parent", + childRunId: "run-parent-completion", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("waits for updated synthesized output before announcing nested subagent completion", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + let historyReads = 0; + chatHistoryMock.mockImplementation(async () => { + historyReads += 1; + if (historyReads < 3) { + return { + messages: [{ role: "assistant", content: "Waiting for child output..." }], + }; + } + return { + messages: [{ role: "assistant", content: "Final synthesized answer." }], + }; + }); + readLatestAssistantReplyMock.mockResolvedValue(undefined); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:parent", + childRunId: "run-parent-synth", + requesterSessionKey: "agent:main:subagent:orchestrator", + requesterDisplayKey: "agent:main:subagent:orchestrator", + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message ?? ""; + expect(msg).toContain("Final synthesized answer."); + expect(msg).not.toContain("Waiting for child output..."); + }); + it("bubbles child announce to parent requester when requester subagent already ended", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false); @@ -1013,6 +1630,35 @@ describe("subagent announce formatting", () => { expect(agentSpy).not.toHaveBeenCalled(); }); + it("defers completion-mode announce when child run is still active after settle timeout", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); + embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-active", + }, + }; + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-child-active-completion", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "completion-context-stress-test", + timeoutMs: 1000, + cleanup: "keep", + waitForCompletion: false, + startedAt: 10, + endedAt: 20, + outcome: { status: "ok" }, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(false); + expect(agentSpy).not.toHaveBeenCalled(); + }); + it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -1031,7 +1677,7 @@ describe("subagent announce formatting", () => { childSessionKey: "agent:main:subagent:test", childRunId: "run-stale-channel", requesterSessionKey: "main", - requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" }, + requesterOrigin: { channel: "telegram", to: "telegram:123" }, requesterDisplayKey: "main", ...defaultOutcomeAnnounce, }); @@ -1041,8 +1687,8 @@ describe("subagent announce formatting", () => { const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; // The channel should match requesterOrigin, NOT the stale session entry. - expect(call?.params?.channel).toBe("bluebubbles"); - expect(call?.params?.to).toBe("bluebubbles:chat_guid:123"); + expect(call?.params?.channel).toBe("telegram"); + expect(call?.params?.to).toBe("telegram:123"); }); it("routes to parent subagent when parent run ended but session still exists (#18037)", async () => { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 389ee114913..f38a79cf93f 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,5 +1,6 @@ import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, @@ -8,7 +9,10 @@ import { resolveStorePath, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; -import { normalizeMainKey } from "../routing/session-key.js"; +import { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js"; +import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; +import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import { @@ -30,6 +34,8 @@ import { } from "./pi-embedded.js"; import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; +import type { SpawnSubagentMode } from "./subagent-spawn.js"; +import { readLatestAssistantReply } from "./tools/agent-step.js"; import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js"; type ToolResultMessage = { @@ -48,10 +54,26 @@ type SubagentAnnounceDeliveryResult = { function buildCompletionDeliveryMessage(params: { findings: string; subagentName: string; + spawnMode?: SpawnSubagentMode; + outcome?: SubagentRunOutcome; }): string { const findingsText = params.findings.trim(); const hasFindings = findingsText.length > 0 && findingsText !== "(no output)"; - const header = `✅ Subagent ${params.subagentName} finished`; + const header = (() => { + if (params.outcome?.status === "error") { + return params.spawnMode === "session" + ? `❌ Subagent ${params.subagentName} failed this task (session remains active)` + : `❌ Subagent ${params.subagentName} failed`; + } + if (params.outcome?.status === "timeout") { + return params.spawnMode === "session" + ? `⏱️ Subagent ${params.subagentName} timed out on this task (session remains active)` + : `⏱️ Subagent ${params.subagentName} timed out`; + } + return params.spawnMode === "session" + ? `✅ Subagent ${params.subagentName} completed this task (session remains active)` + : `✅ Subagent ${params.subagentName} finished`; + })(); if (!hasFindings) { return header; } @@ -153,16 +175,29 @@ function extractSubagentOutputText(message: unknown): string { if (role === "toolResult" || role === "tool") { return extractToolResultText((message as ToolResultMessage).content); } - if (typeof content === "string") { - return sanitizeTextContent(content); - } - if (Array.isArray(content)) { - return extractInlineTextContent(content); + if (role == null) { + if (typeof content === "string") { + return sanitizeTextContent(content); + } + if (Array.isArray(content)) { + return extractInlineTextContent(content); + } } return ""; } async function readLatestSubagentOutput(sessionKey: string): Promise { + try { + const latestAssistant = await readLatestAssistantReply({ + sessionKey, + limit: 50, + }); + if (latestAssistant?.trim()) { + return latestAssistant; + } + } catch { + // Best-effort: fall back to richer history parsing below. + } const history = await callGateway<{ messages?: Array }>({ method: "chat.history", params: { sessionKey, limit: 50 }, @@ -195,6 +230,31 @@ async function readLatestSubagentOutputWithRetry(params: { return result; } +async function waitForSubagentOutputChange(params: { + sessionKey: string; + baselineReply: string; + maxWaitMs: number; +}): Promise { + const baseline = params.baselineReply.trim(); + if (!baseline) { + return params.baselineReply; + } + const RETRY_INTERVAL_MS = 100; + const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 5_000)); + let latest = params.baselineReply; + while (Date.now() < deadline) { + const next = await readLatestSubagentOutput(params.sessionKey); + if (next?.trim()) { + latest = next; + if (next.trim() !== baseline) { + return next; + } + } + await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)); + } + return latest; +} + function formatDurationShort(valueMs?: number) { if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { return "n/a"; @@ -287,7 +347,117 @@ function resolveAnnounceOrigin( // requesterOrigin (captured at spawn time) reflects the channel the user is // actually on and must take priority over the session entry, which may carry // stale lastChannel / lastTo values from a previous channel interaction. - return mergeDeliveryContext(normalizedRequester, normalizedEntry); + const entryForMerge = + normalizedRequester?.to && + normalizedRequester.threadId == null && + normalizedEntry?.threadId != null + ? (() => { + const { threadId: _ignore, ...rest } = normalizedEntry; + return rest; + })() + : normalizedEntry; + return mergeDeliveryContext(normalizedRequester, entryForMerge); +} + +async function resolveSubagentCompletionOrigin(params: { + childSessionKey: string; + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; + childRunId?: string; + spawnMode?: SpawnSubagentMode; + expectsCompletionMessage: boolean; +}): Promise<{ + origin?: DeliveryContext; + routeMode: "bound" | "fallback" | "hook"; +}> { + const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin); + const requesterConversation = (() => { + const channel = requesterOrigin?.channel?.trim().toLowerCase(); + const to = requesterOrigin?.to?.trim(); + const accountId = normalizeAccountId(requesterOrigin?.accountId); + const threadId = + requesterOrigin?.threadId != null && requesterOrigin.threadId !== "" + ? String(requesterOrigin.threadId).trim() + : undefined; + const conversationId = + threadId || (to?.startsWith("channel:") ? to.slice("channel:".length) : ""); + if (!channel || !conversationId) { + return undefined; + } + const ref: ConversationRef = { + channel, + accountId, + conversationId, + }; + return ref; + })(); + const route = createBoundDeliveryRouter().resolveDestination({ + eventKind: "task_completion", + targetSessionKey: params.childSessionKey, + requester: requesterConversation, + failClosed: false, + }); + if (route.mode === "bound" && route.binding) { + const boundOrigin: DeliveryContext = { + channel: route.binding.conversation.channel, + accountId: route.binding.conversation.accountId, + to: `channel:${route.binding.conversation.conversationId}`, + threadId: route.binding.conversation.conversationId, + }; + return { + // Bound target is authoritative; requester hints fill only missing fields. + origin: mergeDeliveryContext(boundOrigin, requesterOrigin), + routeMode: "bound", + }; + } + + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("subagent_delivery_target")) { + return { + origin: requesterOrigin, + routeMode: "fallback", + }; + } + try { + const result = await hookRunner.runSubagentDeliveryTarget( + { + childSessionKey: params.childSessionKey, + requesterSessionKey: params.requesterSessionKey, + requesterOrigin, + childRunId: params.childRunId, + spawnMode: params.spawnMode, + expectsCompletionMessage: params.expectsCompletionMessage, + }, + { + runId: params.childRunId, + childSessionKey: params.childSessionKey, + requesterSessionKey: params.requesterSessionKey, + }, + ); + const hookOrigin = normalizeDeliveryContext(result?.origin); + if (!hookOrigin) { + return { + origin: requesterOrigin, + routeMode: "fallback", + }; + } + if (hookOrigin.channel && !isDeliverableMessageChannel(hookOrigin.channel)) { + return { + origin: requesterOrigin, + routeMode: "fallback", + }; + } + // Hook-provided origin should override requester defaults when present. + return { + origin: mergeDeliveryContext(hookOrigin, requesterOrigin), + routeMode: "hook", + }; + } catch { + return { + origin: requesterOrigin, + routeMode: "fallback", + }; + } } async function sendAnnounce(item: AnnounceQueueItem) { @@ -434,6 +604,8 @@ async function sendSubagentAnnounceDirectly(params: { triggerMessage: string; completionMessage?: string; expectsCompletionMessage: boolean; + completionRouteMode?: "bound" | "fallback" | "hook"; + spawnMode?: SpawnSubagentMode; directIdempotencyKey: string; completionDirectOrigin?: DeliveryContext; directOrigin?: DeliveryContext; @@ -464,28 +636,52 @@ async function sendSubagentAnnounceDirectly(params: { hasCompletionDirectTarget && params.completionMessage?.trim() ) { - const completionThreadId = - completionDirectOrigin?.threadId != null && completionDirectOrigin.threadId !== "" - ? String(completionDirectOrigin.threadId) - : undefined; - await callGateway({ - method: "send", - params: { - channel: completionChannel, - to: completionTo, - accountId: completionDirectOrigin?.accountId, - threadId: completionThreadId, - sessionKey: canonicalRequesterSessionKey, - message: params.completionMessage, - idempotencyKey: params.directIdempotencyKey, - }, - timeoutMs: 15_000, - }); + const forceBoundSessionDirectDelivery = + params.spawnMode === "session" && + (params.completionRouteMode === "bound" || params.completionRouteMode === "hook"); + let shouldSendCompletionDirectly = true; + if (!forceBoundSessionDirectDelivery) { + let activeDescendantRuns = 0; + try { + const { countActiveDescendantRuns } = await import("./subagent-registry.js"); + activeDescendantRuns = Math.max( + 0, + countActiveDescendantRuns(canonicalRequesterSessionKey), + ); + } catch { + // Best-effort only; when unavailable keep historical direct-send behavior. + } + // Keep non-bound completion announcements coordinated via requester + // session routing while sibling/descendant runs are still active. + if (activeDescendantRuns > 0) { + shouldSendCompletionDirectly = false; + } + } - return { - delivered: true, - path: "direct", - }; + if (shouldSendCompletionDirectly) { + const completionThreadId = + completionDirectOrigin?.threadId != null && completionDirectOrigin.threadId !== "" + ? String(completionDirectOrigin.threadId) + : undefined; + await callGateway({ + method: "send", + params: { + channel: completionChannel, + to: completionTo, + accountId: completionDirectOrigin?.accountId, + threadId: completionThreadId, + sessionKey: canonicalRequesterSessionKey, + message: params.completionMessage, + idempotencyKey: params.directIdempotencyKey, + }, + timeoutMs: 15_000, + }); + + return { + delivered: true, + path: "direct", + }; + } } const directOrigin = normalizeDeliveryContext(params.directOrigin); @@ -534,6 +730,8 @@ async function deliverSubagentAnnouncement(params: { targetRequesterSessionKey: string; requesterIsSubagent: boolean; expectsCompletionMessage: boolean; + completionRouteMode?: "bound" | "fallback" | "hook"; + spawnMode?: SpawnSubagentMode; directIdempotencyKey: string; }): Promise { // Non-completion mode mirrors historical behavior: try queued/steered delivery first, @@ -560,6 +758,8 @@ async function deliverSubagentAnnouncement(params: { completionMessage: params.completionMessage, directIdempotencyKey: params.directIdempotencyKey, completionDirectOrigin: params.completionDirectOrigin, + completionRouteMode: params.completionRouteMode, + spawnMode: params.spawnMode, directOrigin: params.directOrigin, requesterIsSubagent: params.requesterIsSubagent, expectsCompletionMessage: params.expectsCompletionMessage, @@ -608,7 +808,10 @@ export function buildSubagentSystemPrompt(params: { ? params.task.replace(/\s+/g, " ").trim() : "{{TASK_DESCRIPTION}}"; const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1; - const maxSpawnDepth = typeof params.maxSpawnDepth === "number" ? params.maxSpawnDepth : 1; + const maxSpawnDepth = + typeof params.maxSpawnDepth === "number" + ? params.maxSpawnDepth + : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; const canSpawn = childDepth < maxSpawnDepth; const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; @@ -694,9 +897,6 @@ function buildAnnounceReplyInstruction(params: { announceType: SubagentAnnounceType; expectsCompletionMessage?: boolean; }): string { - if (params.expectsCompletionMessage) { - return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type).`; - } if (params.remainingActiveSubagentRuns > 0) { const activeRunsLabel = params.remainingActiveSubagentRuns === 1 ? "run" : "runs"; return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`; @@ -704,6 +904,9 @@ function buildAnnounceReplyInstruction(params: { if (params.requesterIsSubagent) { return `Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: ${SILENT_REPLY_TOKEN}.`; } + if (params.expectsCompletionMessage) { + return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type).`; + } return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`; } @@ -724,6 +927,7 @@ export async function runSubagentAnnounceFlow(params: { outcome?: SubagentRunOutcome; announceType?: SubagentAnnounceType; expectsCompletionMessage?: boolean; + spawnMode?: SpawnSubagentMode; }): Promise { let didAnnounce = false; const expectsCompletionMessage = params.expectsCompletionMessage === true; @@ -742,7 +946,7 @@ export async function runSubagentAnnounceFlow(params: { let outcome: SubagentRunOutcome | undefined = params.outcome; // Lifecycle "end" can arrive before auto-compaction retries finish. If the // subagent is still active, wait for the embedded run to fully settle. - if (!expectsCompletionMessage && childSessionId && isEmbeddedPiRunActive(childSessionId)) { + if (childSessionId && isEmbeddedPiRunActive(childSessionId)) { const settled = await waitForEmbeddedPiRunEnd(childSessionId, settleTimeoutMs); if (!settled && isEmbeddedPiRunActive(childSessionId)) { // The child run is still active (e.g., compaction retry still in progress). @@ -816,6 +1020,8 @@ export async function runSubagentAnnounceFlow(params: { outcome = { status: "unknown" }; } + let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + let activeChildDescendantRuns = 0; try { const { countActiveDescendantRuns } = await import("./subagent-registry.js"); @@ -823,13 +1029,21 @@ export async function runSubagentAnnounceFlow(params: { } catch { // Best-effort only; fall back to direct announce behavior when unavailable. } - if (!expectsCompletionMessage && activeChildDescendantRuns > 0) { + if (activeChildDescendantRuns > 0) { // The finished run still has active descendant subagents. Defer announcing // this run until descendants settle so we avoid posting in-progress updates. shouldDeleteChildSession = false; return false; } + if (requesterDepth >= 1 && reply?.trim()) { + reply = await waitForSubagentOutputChange({ + sessionKey: params.childSessionKey, + baselineReply: reply, + maxWaitMs: Math.max(250, Math.min(params.timeoutMs, 2_000)), + }); + } + // Build status label const statusLabel = outcome.status === "ok" @@ -849,8 +1063,7 @@ export async function runSubagentAnnounceFlow(params: { let completionMessage = ""; let triggerMessage = ""; - let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); - let requesterIsSubagent = !expectsCompletionMessage && requesterDepth >= 1; + let requesterIsSubagent = requesterDepth >= 1; // If the requester subagent has already finished, bubble the announce to its // requester (typically main) so descendant completion is not silently lost. // BUT: only fallback if the parent SESSION is deleted, not just if the current @@ -913,6 +1126,8 @@ export async function runSubagentAnnounceFlow(params: { completionMessage = buildCompletionDeliveryMessage({ findings, subagentName, + spawnMode: params.spawnMode, + outcome, }); const internalSummaryMessage = [ `[System Message] [sessionId: ${announceSessionId}] A ${announceType} "${taskLabel}" just ${statusLabel}.`, @@ -935,6 +1150,21 @@ export async function runSubagentAnnounceFlow(params: { const { entry } = loadRequesterSessionEntry(targetRequesterSessionKey); directOrigin = resolveAnnounceOrigin(entry, targetRequesterOrigin); } + const completionResolution = + expectsCompletionMessage && !requesterIsSubagent + ? await resolveSubagentCompletionOrigin({ + childSessionKey: params.childSessionKey, + requesterSessionKey: targetRequesterSessionKey, + requesterOrigin: directOrigin, + childRunId: params.childRunId, + spawnMode: params.spawnMode, + expectsCompletionMessage, + }) + : { + origin: targetRequesterOrigin, + routeMode: "fallback" as const, + }; + const completionDirectOrigin = completionResolution.origin; // Use a deterministic idempotency key so the gateway dedup cache // catches duplicates if this announce is also queued by the gateway- // level message queue while the main session is busy (#17122). @@ -945,12 +1175,17 @@ export async function runSubagentAnnounceFlow(params: { triggerMessage, completionMessage, summaryLine: taskLabel, - requesterOrigin: targetRequesterOrigin, - completionDirectOrigin: targetRequesterOrigin, + requesterOrigin: + expectsCompletionMessage && !requesterIsSubagent + ? completionDirectOrigin + : targetRequesterOrigin, + completionDirectOrigin, directOrigin, targetRequesterSessionKey, requesterIsSubagent, expectsCompletionMessage: expectsCompletionMessage, + completionRouteMode: completionResolution.routeMode, + spawnMode: params.spawnMode, directIdempotencyKey, }); didAnnounce = delivery.delivered; @@ -979,7 +1214,11 @@ export async function runSubagentAnnounceFlow(params: { try { await callGateway({ method: "sessions.delete", - params: { key: params.childSessionKey, deleteTranscript: true }, + params: { + key: params.childSessionKey, + deleteTranscript: true, + emitLifecycleHooks: false, + }, timeoutMs: 10_000, }); } catch { diff --git a/src/agents/subagent-lifecycle-events.ts b/src/agents/subagent-lifecycle-events.ts new file mode 100644 index 00000000000..ae4c4c2fa89 --- /dev/null +++ b/src/agents/subagent-lifecycle-events.ts @@ -0,0 +1,47 @@ +export const SUBAGENT_TARGET_KIND_SUBAGENT = "subagent" as const; +export const SUBAGENT_TARGET_KIND_ACP = "acp" as const; + +export type SubagentLifecycleTargetKind = + | typeof SUBAGENT_TARGET_KIND_SUBAGENT + | typeof SUBAGENT_TARGET_KIND_ACP; + +export const SUBAGENT_ENDED_REASON_COMPLETE = "subagent-complete" as const; +export const SUBAGENT_ENDED_REASON_ERROR = "subagent-error" as const; +export const SUBAGENT_ENDED_REASON_KILLED = "subagent-killed" as const; +export const SUBAGENT_ENDED_REASON_SESSION_RESET = "session-reset" as const; +export const SUBAGENT_ENDED_REASON_SESSION_DELETE = "session-delete" as const; + +export type SubagentLifecycleEndedReason = + | typeof SUBAGENT_ENDED_REASON_COMPLETE + | typeof SUBAGENT_ENDED_REASON_ERROR + | typeof SUBAGENT_ENDED_REASON_KILLED + | typeof SUBAGENT_ENDED_REASON_SESSION_RESET + | typeof SUBAGENT_ENDED_REASON_SESSION_DELETE; + +export type SubagentSessionLifecycleEndedReason = + | typeof SUBAGENT_ENDED_REASON_SESSION_RESET + | typeof SUBAGENT_ENDED_REASON_SESSION_DELETE; + +export const SUBAGENT_ENDED_OUTCOME_OK = "ok" as const; +export const SUBAGENT_ENDED_OUTCOME_ERROR = "error" as const; +export const SUBAGENT_ENDED_OUTCOME_TIMEOUT = "timeout" as const; +export const SUBAGENT_ENDED_OUTCOME_KILLED = "killed" as const; +export const SUBAGENT_ENDED_OUTCOME_RESET = "reset" as const; +export const SUBAGENT_ENDED_OUTCOME_DELETED = "deleted" as const; + +export type SubagentLifecycleEndedOutcome = + | typeof SUBAGENT_ENDED_OUTCOME_OK + | typeof SUBAGENT_ENDED_OUTCOME_ERROR + | typeof SUBAGENT_ENDED_OUTCOME_TIMEOUT + | typeof SUBAGENT_ENDED_OUTCOME_KILLED + | typeof SUBAGENT_ENDED_OUTCOME_RESET + | typeof SUBAGENT_ENDED_OUTCOME_DELETED; + +export function resolveSubagentSessionEndedOutcome( + reason: SubagentSessionLifecycleEndedReason, +): SubagentLifecycleEndedOutcome { + if (reason === SUBAGENT_ENDED_REASON_SESSION_RESET) { + return SUBAGENT_ENDED_OUTCOME_RESET; + } + return SUBAGENT_ENDED_OUTCOME_DELETED; +} diff --git a/src/agents/subagent-registry-cleanup.ts b/src/agents/subagent-registry-cleanup.ts new file mode 100644 index 00000000000..4e3f8f83300 --- /dev/null +++ b/src/agents/subagent-registry-cleanup.ts @@ -0,0 +1,67 @@ +import { + SUBAGENT_ENDED_REASON_COMPLETE, + type SubagentLifecycleEndedReason, +} from "./subagent-lifecycle-events.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +export type DeferredCleanupDecision = + | { + kind: "defer-descendants"; + delayMs: number; + } + | { + kind: "give-up"; + reason: "retry-limit" | "expiry"; + retryCount?: number; + } + | { + kind: "retry"; + retryCount: number; + resumeDelayMs?: number; + }; + +export function resolveCleanupCompletionReason( + entry: SubagentRunRecord, +): SubagentLifecycleEndedReason { + return entry.endedReason ?? SUBAGENT_ENDED_REASON_COMPLETE; +} + +function resolveEndedAgoMs(entry: SubagentRunRecord, now: number): number { + return typeof entry.endedAt === "number" ? now - entry.endedAt : 0; +} + +export function resolveDeferredCleanupDecision(params: { + entry: SubagentRunRecord; + now: number; + activeDescendantRuns: number; + announceExpiryMs: number; + maxAnnounceRetryCount: number; + deferDescendantDelayMs: number; + resolveAnnounceRetryDelayMs: (retryCount: number) => number; +}): DeferredCleanupDecision { + const endedAgo = resolveEndedAgoMs(params.entry, params.now); + if (params.entry.expectsCompletionMessage === true && params.activeDescendantRuns > 0) { + if (endedAgo > params.announceExpiryMs) { + return { kind: "give-up", reason: "expiry" }; + } + return { kind: "defer-descendants", delayMs: params.deferDescendantDelayMs }; + } + + const retryCount = (params.entry.announceRetryCount ?? 0) + 1; + if (retryCount >= params.maxAnnounceRetryCount || endedAgo > params.announceExpiryMs) { + return { + kind: "give-up", + reason: retryCount >= params.maxAnnounceRetryCount ? "retry-limit" : "expiry", + retryCount, + }; + } + + return { + kind: "retry", + retryCount, + resumeDelayMs: + params.entry.expectsCompletionMessage === true + ? params.resolveAnnounceRetryDelayMs(retryCount) + : undefined, + }; +} diff --git a/src/agents/subagent-registry-completion.test.ts b/src/agents/subagent-registry-completion.test.ts new file mode 100644 index 00000000000..d885d99df89 --- /dev/null +++ b/src/agents/subagent-registry-completion.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SUBAGENT_ENDED_REASON_COMPLETE } from "./subagent-lifecycle-events.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +const lifecycleMocks = vi.hoisted(() => ({ + getGlobalHookRunner: vi.fn(), + runSubagentEnded: vi.fn(async () => {}), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => lifecycleMocks.getGlobalHookRunner(), +})); + +import { emitSubagentEndedHookOnce } from "./subagent-registry-completion.js"; + +function createRunEntry(): SubagentRunRecord { + return { + runId: "run-1", + childSessionKey: "agent:main:subagent:child-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "task", + cleanup: "keep", + createdAt: Date.now(), + }; +} + +describe("emitSubagentEndedHookOnce", () => { + beforeEach(() => { + lifecycleMocks.getGlobalHookRunner.mockReset(); + lifecycleMocks.runSubagentEnded.mockClear(); + }); + + it("records ended hook marker even when no subagent_ended hooks are registered", async () => { + lifecycleMocks.getGlobalHookRunner.mockReturnValue({ + hasHooks: () => false, + runSubagentEnded: lifecycleMocks.runSubagentEnded, + }); + + const entry = createRunEntry(); + const persist = vi.fn(); + const emitted = await emitSubagentEndedHookOnce({ + entry, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + sendFarewell: true, + accountId: "acct-1", + inFlightRunIds: new Set(), + persist, + }); + + expect(emitted).toBe(true); + expect(lifecycleMocks.runSubagentEnded).not.toHaveBeenCalled(); + expect(typeof entry.endedHookEmittedAt).toBe("number"); + expect(persist).toHaveBeenCalledTimes(1); + }); + + it("runs subagent_ended hooks when available", async () => { + lifecycleMocks.getGlobalHookRunner.mockReturnValue({ + hasHooks: () => true, + runSubagentEnded: lifecycleMocks.runSubagentEnded, + }); + + const entry = createRunEntry(); + const persist = vi.fn(); + const emitted = await emitSubagentEndedHookOnce({ + entry, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + sendFarewell: true, + accountId: "acct-1", + inFlightRunIds: new Set(), + persist, + }); + + expect(emitted).toBe(true); + expect(lifecycleMocks.runSubagentEnded).toHaveBeenCalledTimes(1); + expect(typeof entry.endedHookEmittedAt).toBe("number"); + expect(persist).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/subagent-registry-completion.ts b/src/agents/subagent-registry-completion.ts new file mode 100644 index 00000000000..fae14fc73ce --- /dev/null +++ b/src/agents/subagent-registry-completion.ts @@ -0,0 +1,96 @@ +import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import type { SubagentRunOutcome } from "./subagent-announce.js"; +import { + SUBAGENT_ENDED_OUTCOME_ERROR, + SUBAGENT_ENDED_OUTCOME_OK, + SUBAGENT_ENDED_OUTCOME_TIMEOUT, + SUBAGENT_TARGET_KIND_SUBAGENT, + type SubagentLifecycleEndedOutcome, + type SubagentLifecycleEndedReason, +} from "./subagent-lifecycle-events.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +export function runOutcomesEqual( + a: SubagentRunOutcome | undefined, + b: SubagentRunOutcome | undefined, +): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + if (a.status !== b.status) { + return false; + } + if (a.status === "error" && b.status === "error") { + return (a.error ?? "") === (b.error ?? ""); + } + return true; +} + +export function resolveLifecycleOutcomeFromRunOutcome( + outcome: SubagentRunOutcome | undefined, +): SubagentLifecycleEndedOutcome { + if (outcome?.status === "error") { + return SUBAGENT_ENDED_OUTCOME_ERROR; + } + if (outcome?.status === "timeout") { + return SUBAGENT_ENDED_OUTCOME_TIMEOUT; + } + return SUBAGENT_ENDED_OUTCOME_OK; +} + +export async function emitSubagentEndedHookOnce(params: { + entry: SubagentRunRecord; + reason: SubagentLifecycleEndedReason; + sendFarewell?: boolean; + accountId?: string; + outcome?: SubagentLifecycleEndedOutcome; + error?: string; + inFlightRunIds: Set; + persist: () => void; +}) { + const runId = params.entry.runId.trim(); + if (!runId) { + return false; + } + if (params.entry.endedHookEmittedAt) { + return false; + } + if (params.inFlightRunIds.has(runId)) { + return false; + } + + params.inFlightRunIds.add(runId); + try { + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("subagent_ended")) { + await hookRunner.runSubagentEnded( + { + targetSessionKey: params.entry.childSessionKey, + targetKind: SUBAGENT_TARGET_KIND_SUBAGENT, + reason: params.reason, + sendFarewell: params.sendFarewell, + accountId: params.accountId, + runId: params.entry.runId, + endedAt: params.entry.endedAt, + outcome: params.outcome, + error: params.error, + }, + { + runId: params.entry.runId, + childSessionKey: params.entry.childSessionKey, + requesterSessionKey: params.entry.requesterSessionKey, + }, + ); + } + params.entry.endedHookEmittedAt = Date.now(); + params.persist(); + return true; + } catch { + return false; + } finally { + params.inFlightRunIds.delete(runId); + } +} diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts new file mode 100644 index 00000000000..21727e8f01e --- /dev/null +++ b/src/agents/subagent-registry-queries.ts @@ -0,0 +1,146 @@ +import type { DeliveryContext } from "../utils/delivery-context.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +export function findRunIdsByChildSessionKeyFromRuns( + runs: Map, + childSessionKey: string, +): string[] { + const key = childSessionKey.trim(); + if (!key) { + return []; + } + const runIds: string[] = []; + for (const [runId, entry] of runs.entries()) { + if (entry.childSessionKey === key) { + runIds.push(runId); + } + } + return runIds; +} + +export function listRunsForRequesterFromRuns( + runs: Map, + requesterSessionKey: string, +): SubagentRunRecord[] { + const key = requesterSessionKey.trim(); + if (!key) { + return []; + } + return [...runs.values()].filter((entry) => entry.requesterSessionKey === key); +} + +export function resolveRequesterForChildSessionFromRuns( + runs: Map, + childSessionKey: string, +): { + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; +} | null { + const key = childSessionKey.trim(); + if (!key) { + return null; + } + let best: SubagentRunRecord | undefined; + for (const entry of runs.values()) { + if (entry.childSessionKey !== key) { + continue; + } + if (!best || entry.createdAt > best.createdAt) { + best = entry; + } + } + if (!best) { + return null; + } + return { + requesterSessionKey: best.requesterSessionKey, + requesterOrigin: best.requesterOrigin, + }; +} + +export function countActiveRunsForSessionFromRuns( + runs: Map, + requesterSessionKey: string, +): number { + const key = requesterSessionKey.trim(); + if (!key) { + return 0; + } + let count = 0; + for (const entry of runs.values()) { + if (entry.requesterSessionKey !== key) { + continue; + } + if (typeof entry.endedAt === "number") { + continue; + } + count += 1; + } + return count; +} + +export function countActiveDescendantRunsFromRuns( + runs: Map, + rootSessionKey: string, +): number { + const root = rootSessionKey.trim(); + if (!root) { + return 0; + } + const pending = [root]; + const visited = new Set([root]); + let count = 0; + while (pending.length > 0) { + const requester = pending.shift(); + if (!requester) { + continue; + } + for (const entry of runs.values()) { + if (entry.requesterSessionKey !== requester) { + continue; + } + if (typeof entry.endedAt !== "number") { + count += 1; + } + const childKey = entry.childSessionKey.trim(); + if (!childKey || visited.has(childKey)) { + continue; + } + visited.add(childKey); + pending.push(childKey); + } + } + return count; +} + +export function listDescendantRunsForRequesterFromRuns( + runs: Map, + rootSessionKey: string, +): SubagentRunRecord[] { + const root = rootSessionKey.trim(); + if (!root) { + return []; + } + const pending = [root]; + const visited = new Set([root]); + const descendants: SubagentRunRecord[] = []; + while (pending.length > 0) { + const requester = pending.shift(); + if (!requester) { + continue; + } + for (const entry of runs.values()) { + if (entry.requesterSessionKey !== requester) { + continue; + } + descendants.push(entry); + const childKey = entry.childSessionKey.trim(); + if (!childKey || visited.has(childKey)) { + continue; + } + visited.add(childKey); + pending.push(childKey); + } + } + return descendants; +} diff --git a/src/agents/subagent-registry-state.ts b/src/agents/subagent-registry-state.ts new file mode 100644 index 00000000000..6639de5dcc0 --- /dev/null +++ b/src/agents/subagent-registry-state.ts @@ -0,0 +1,56 @@ +import { + loadSubagentRegistryFromDisk, + saveSubagentRegistryToDisk, +} from "./subagent-registry.store.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +export function persistSubagentRunsToDisk(runs: Map) { + try { + saveSubagentRegistryToDisk(runs); + } catch { + // ignore persistence failures + } +} + +export function restoreSubagentRunsFromDisk(params: { + runs: Map; + mergeOnly?: boolean; +}) { + const restored = loadSubagentRegistryFromDisk(); + if (restored.size === 0) { + return 0; + } + let added = 0; + for (const [runId, entry] of restored.entries()) { + if (!runId || !entry) { + continue; + } + if (params.mergeOnly && params.runs.has(runId)) { + continue; + } + params.runs.set(runId, entry); + added += 1; + } + return added; +} + +export function getSubagentRunsSnapshotForRead( + inMemoryRuns: Map, +): Map { + const merged = new Map(); + const shouldReadDisk = !(process.env.VITEST || process.env.NODE_ENV === "test"); + if (shouldReadDisk) { + try { + // Persisted state lets other worker processes observe active runs. + for (const [runId, entry] of loadSubagentRegistryFromDisk().entries()) { + merged.set(runId, entry); + } + } catch { + // Ignore disk read failures and fall back to local memory. + } + } + for (const [runId, entry] of inMemoryRuns.entries()) { + merged.set(runId, entry); + } + return merged; +} diff --git a/src/agents/subagent-registry.archive.test.ts b/src/agents/subagent-registry.archive.test.ts new file mode 100644 index 00000000000..20148db527a --- /dev/null +++ b/src/agents/subagent-registry.archive.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +const noop = () => {}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(async (request: unknown) => { + const method = (request as { method?: string }).method; + if (method === "agent.wait") { + // Keep lifecycle unsettled so register/replace assertions can inspect stored state. + return { status: "pending" }; + } + return {}; + }), +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: vi.fn((_handler: unknown) => noop), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 60 } } }, + })), +})); + +vi.mock("./subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn(async () => true), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => null), +})); + +vi.mock("./subagent-registry.store.js", () => ({ + loadSubagentRegistryFromDisk: vi.fn(() => new Map()), + saveSubagentRegistryToDisk: vi.fn(() => {}), +})); + +describe("subagent registry archive behavior", () => { + let mod: typeof import("./subagent-registry.js"); + + beforeAll(async () => { + mod = await import("./subagent-registry.js"); + }); + + afterEach(() => { + mod.resetSubagentRegistryForTests({ persist: false }); + }); + + it("does not set archiveAtMs for persistent session-mode runs", () => { + mod.registerSubagentRun({ + runId: "run-session-1", + childSessionKey: "agent:main:subagent:session-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "persistent-session", + cleanup: "keep", + spawnMode: "session", + }); + + const run = mod.listSubagentRunsForRequester("agent:main:main")[0]; + expect(run?.runId).toBe("run-session-1"); + expect(run?.spawnMode).toBe("session"); + expect(run?.archiveAtMs).toBeUndefined(); + }); + + it("keeps archiveAtMs unset when replacing a session-mode run after steer restart", () => { + mod.registerSubagentRun({ + runId: "run-old", + childSessionKey: "agent:main:subagent:session-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "persistent-session", + cleanup: "keep", + spawnMode: "session", + }); + + const replaced = mod.replaceSubagentRunAfterSteer({ + previousRunId: "run-old", + nextRunId: "run-new", + }); + + expect(replaced).toBe(true); + const run = mod + .listSubagentRunsForRequester("agent:main:main") + .find((entry) => entry.runId === "run-new"); + expect(run?.spawnMode).toBe("session"); + expect(run?.archiveAtMs).toBeUndefined(); + }); +}); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index be2f3ac60ec..67bd577ceb6 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -2,7 +2,17 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const noop = () => {}; let lifecycleHandler: - | ((evt: { stream?: string; runId: string; data?: { phase?: string } }) => void) + | ((evt: { + stream?: string; + runId: string; + data?: { + phase?: string; + startedAt?: number; + endedAt?: number; + aborted?: boolean; + error?: string; + }; + }) => void) | undefined; vi.mock("../gateway/call.js", () => ({ @@ -29,10 +39,18 @@ vi.mock("../config/config.js", () => ({ })); const announceSpy = vi.fn(async (_params: unknown) => true); +const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: announceSpy, })); +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => ({ + hasHooks: (hookName: string) => hookName === "subagent_ended", + runSubagentEnded: runSubagentEndedHookMock, + })), +})); + vi.mock("./subagent-registry.store.js", () => ({ loadSubagentRegistryFromDisk: vi.fn(() => new Map()), saveSubagentRegistryToDisk: vi.fn(() => {}), @@ -52,6 +70,7 @@ describe("subagent registry steer restarts", () => { afterEach(async () => { announceSpy.mockReset(); announceSpy.mockResolvedValue(true); + runSubagentEndedHookMock.mockClear(); lifecycleHandler = undefined; mod.resetSubagentRegistryForTests({ persist: false }); }); @@ -80,6 +99,7 @@ describe("subagent registry steer restarts", () => { await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); + expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); const replaced = mod.replaceSubagentRunAfterSteer({ previousRunId: "run-old", @@ -100,11 +120,152 @@ describe("subagent registry steer restarts", () => { await flushAnnounce(); expect(announceSpy).toHaveBeenCalledTimes(1); + expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); + expect(runSubagentEndedHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-new", + }), + expect.objectContaining({ + runId: "run-new", + }), + ); const announce = (announceSpy.mock.calls[0]?.[0] ?? {}) as { childRunId?: string }; expect(announce.childRunId).toBe("run-new"); }); + it("defers subagent_ended hook for completion-mode runs until announce delivery resolves", async () => { + const callGateway = vi.mocked((await import("../gateway/call.js")).callGateway); + const originalCallGateway = callGateway.getMockImplementation(); + callGateway.mockImplementation(async (request: unknown) => { + const typed = request as { method?: string }; + if (typed.method === "agent.wait") { + return new Promise(() => undefined); + } + if (originalCallGateway) { + return originalCallGateway(request as Parameters[0]); + } + return {}; + }); + + try { + let resolveAnnounce!: (value: boolean) => void; + announceSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAnnounce = resolve; + }), + ); + + mod.registerSubagentRun({ + runId: "run-completion-delayed", + childSessionKey: "agent:main:subagent:completion-delayed", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:123", + accountId: "work", + }, + task: "completion-mode task", + cleanup: "keep", + expectsCompletionMessage: true, + }); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-completion-delayed", + data: { phase: "end" }, + }); + + await flushAnnounce(); + expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); + + resolveAnnounce(true); + await flushAnnounce(); + + expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); + expect(runSubagentEndedHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + targetSessionKey: "agent:main:subagent:completion-delayed", + reason: "subagent-complete", + sendFarewell: true, + }), + expect.objectContaining({ + runId: "run-completion-delayed", + requesterSessionKey: "agent:main:main", + }), + ); + } finally { + if (originalCallGateway) { + callGateway.mockImplementation(originalCallGateway); + } + } + }); + + it("does not emit subagent_ended on completion for persistent session-mode runs", async () => { + const callGateway = vi.mocked((await import("../gateway/call.js")).callGateway); + const originalCallGateway = callGateway.getMockImplementation(); + callGateway.mockImplementation(async (request: unknown) => { + const typed = request as { method?: string }; + if (typed.method === "agent.wait") { + return new Promise(() => undefined); + } + if (originalCallGateway) { + return originalCallGateway(request as Parameters[0]); + } + return {}; + }); + + try { + let resolveAnnounce!: (value: boolean) => void; + announceSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAnnounce = resolve; + }), + ); + + mod.registerSubagentRun({ + runId: "run-persistent-session", + childSessionKey: "agent:main:subagent:persistent-session", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:123", + accountId: "work", + }, + task: "persistent session task", + cleanup: "keep", + expectsCompletionMessage: true, + spawnMode: "session", + }); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-persistent-session", + data: { phase: "end" }, + }); + + await flushAnnounce(); + expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); + + resolveAnnounce(true); + await flushAnnounce(); + + expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); + const run = mod.listSubagentRunsForRequester("agent:main:main")[0]; + expect(run?.runId).toBe("run-persistent-session"); + expect(run?.cleanupCompletedAt).toBeTypeOf("number"); + expect(run?.endedHookEmittedAt).toBeUndefined(); + } finally { + if (originalCallGateway) { + callGateway.mockImplementation(originalCallGateway); + } + } + }); + it("clears announce retry state when replacing after steer restart", () => { mod.registerSubagentRun({ runId: "run-retry-reset-old", @@ -136,6 +297,56 @@ describe("subagent registry steer restarts", () => { expect(runs[0].lastAnnounceRetryAt).toBeUndefined(); }); + it("clears terminal lifecycle state when replacing after steer restart", async () => { + mod.registerSubagentRun({ + runId: "run-terminal-state-old", + childSessionKey: "agent:main:subagent:terminal-state", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "terminal state", + cleanup: "keep", + }); + + const previous = mod.listSubagentRunsForRequester("agent:main:main")[0]; + expect(previous?.runId).toBe("run-terminal-state-old"); + if (previous) { + previous.endedHookEmittedAt = Date.now(); + previous.endedReason = "subagent-complete"; + previous.endedAt = Date.now(); + previous.outcome = { status: "ok" }; + } + + const replaced = mod.replaceSubagentRunAfterSteer({ + previousRunId: "run-terminal-state-old", + nextRunId: "run-terminal-state-new", + fallback: previous, + }); + expect(replaced).toBe(true); + + const runs = mod.listSubagentRunsForRequester("agent:main:main"); + expect(runs).toHaveLength(1); + expect(runs[0].runId).toBe("run-terminal-state-new"); + expect(runs[0].endedHookEmittedAt).toBeUndefined(); + expect(runs[0].endedReason).toBeUndefined(); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-terminal-state-new", + data: { phase: "end" }, + }); + + await flushAnnounce(); + expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); + expect(runSubagentEndedHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-terminal-state-new", + }), + expect.objectContaining({ + runId: "run-terminal-state-new", + }), + ); + }); + it("restores announce for a finished run when steer replacement dispatch fails", async () => { mod.registerSubagentRun({ runId: "run-failed-restart", @@ -189,6 +400,24 @@ describe("subagent registry steer restarts", () => { expect(run?.outcome).toEqual({ status: "error", error: "manual kill" }); expect(run?.cleanupHandled).toBe(true); expect(typeof run?.cleanupCompletedAt).toBe("number"); + expect(runSubagentEndedHookMock).toHaveBeenCalledWith( + { + targetSessionKey: childSessionKey, + targetKind: "subagent", + reason: "subagent-killed", + sendFarewell: true, + accountId: undefined, + runId: "run-killed", + endedAt: expect.any(Number), + outcome: "killed", + error: "manual kill", + }, + { + runId: "run-killed", + childSessionKey, + requesterSessionKey: "agent:main:main", + }, + ); }); it("retries deferred parent cleanup after a descendant announces", async () => { @@ -302,4 +531,48 @@ describe("subagent registry steer restarts", () => { vi.useRealTimers(); } }); + + it("emits subagent_ended when completion cleanup expires with active descendants", async () => { + announceSpy.mockResolvedValue(false); + + mod.registerSubagentRun({ + runId: "run-parent-expiry", + childSessionKey: "agent:main:subagent:parent-expiry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "parent completion expiry", + cleanup: "keep", + expectsCompletionMessage: true, + }); + mod.registerSubagentRun({ + runId: "run-child-active", + childSessionKey: "agent:main:subagent:parent-expiry:subagent:child-active", + requesterSessionKey: "agent:main:subagent:parent-expiry", + requesterDisplayKey: "parent-expiry", + task: "child still running", + cleanup: "keep", + }); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-parent-expiry", + data: { + phase: "end", + startedAt: Date.now() - 7 * 60_000, + endedAt: Date.now() - 6 * 60_000, + }, + }); + + await flushAnnounce(); + + const parentHookCall = runSubagentEndedHookMock.mock.calls.find((call) => { + const event = call[0] as { runId?: string; reason?: string }; + return event.runId === "run-parent-expiry" && event.reason === "subagent-complete"; + }); + expect(parentHookCall).toBeDefined(); + const parent = mod + .listSubagentRunsForRequester("agent:main:main") + .find((entry) => entry.runId === "run-parent-expiry"); + expect(parent?.cleanupCompletedAt).toBeTypeOf("number"); + }); }); diff --git a/src/agents/subagent-registry.store.ts b/src/agents/subagent-registry.store.ts index 2709a6a1fd8..b41811aef97 100644 --- a/src/agents/subagent-registry.store.ts +++ b/src/agents/subagent-registry.store.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; -import type { SubagentRunRecord } from "./subagent-registry.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; export type PersistedSubagentRegistryVersion = 1 | 2; @@ -101,6 +101,7 @@ export function loadSubagentRegistryFromDisk(): Map { requesterOrigin, cleanupCompletedAt, cleanupHandled, + spawnMode: typed.spawnMode === "session" ? "session" : "run", }); if (isLegacy) { migrated = true; diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 0e14a2aaa60..8506b77d53e 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -6,36 +6,38 @@ import { type DeliveryContext, normalizeDeliveryContext } from "../utils/deliver import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js"; import { - loadSubagentRegistryFromDisk, - saveSubagentRegistryToDisk, -} from "./subagent-registry.store.js"; + SUBAGENT_ENDED_OUTCOME_KILLED, + SUBAGENT_ENDED_REASON_COMPLETE, + SUBAGENT_ENDED_REASON_ERROR, + SUBAGENT_ENDED_REASON_KILLED, + type SubagentLifecycleEndedReason, +} from "./subagent-lifecycle-events.js"; +import { + resolveCleanupCompletionReason, + resolveDeferredCleanupDecision, +} from "./subagent-registry-cleanup.js"; +import { + emitSubagentEndedHookOnce, + resolveLifecycleOutcomeFromRunOutcome, + runOutcomesEqual, +} from "./subagent-registry-completion.js"; +import { + countActiveDescendantRunsFromRuns, + countActiveRunsForSessionFromRuns, + findRunIdsByChildSessionKeyFromRuns, + listDescendantRunsForRequesterFromRuns, + listRunsForRequesterFromRuns, + resolveRequesterForChildSessionFromRuns, +} from "./subagent-registry-queries.js"; +import { + getSubagentRunsSnapshotForRead, + persistSubagentRunsToDisk, + restoreSubagentRunsFromDisk, +} from "./subagent-registry-state.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; -export type SubagentRunRecord = { - runId: string; - childSessionKey: string; - requesterSessionKey: string; - requesterOrigin?: DeliveryContext; - requesterDisplayKey: string; - task: string; - cleanup: "delete" | "keep"; - label?: string; - model?: string; - runTimeoutSeconds?: number; - createdAt: number; - startedAt?: number; - endedAt?: number; - outcome?: SubagentRunOutcome; - archiveAtMs?: number; - cleanupCompletedAt?: number; - cleanupHandled?: boolean; - suppressAnnounceReason?: "steer-restart" | "killed"; - expectsCompletionMessage?: boolean; - /** Number of times announce delivery has been attempted and returned false (deferred). */ - announceRetryCount?: number; - /** Timestamp of the last announce retry attempt (for backoff). */ - lastAnnounceRetryAt?: number; -}; +export type { SubagentRunRecord } from "./subagent-registry.types.js"; const subagentRuns = new Map(); let sweeper: NodeJS.Timeout | null = null; @@ -77,19 +79,117 @@ function logAnnounceGiveUp(entry: SubagentRunRecord, reason: "retry-limit" | "ex } function persistSubagentRuns() { - try { - saveSubagentRegistryToDisk(subagentRuns); - } catch { - // ignore persistence failures - } + persistSubagentRunsToDisk(subagentRuns); } const resumedRuns = new Set(); +const endedHookInFlightRunIds = new Set(); function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) { return entry?.suppressAnnounceReason === "steer-restart"; } +function shouldKeepThreadBindingAfterRun(params: { + entry: SubagentRunRecord; + reason: SubagentLifecycleEndedReason; +}) { + if (params.reason === SUBAGENT_ENDED_REASON_KILLED) { + return false; + } + return params.entry.spawnMode === "session"; +} + +function shouldEmitEndedHookForRun(params: { + entry: SubagentRunRecord; + reason: SubagentLifecycleEndedReason; +}) { + return !shouldKeepThreadBindingAfterRun(params); +} + +async function emitSubagentEndedHookForRun(params: { + entry: SubagentRunRecord; + reason?: SubagentLifecycleEndedReason; + sendFarewell?: boolean; + accountId?: string; +}) { + const reason = params.reason ?? params.entry.endedReason ?? SUBAGENT_ENDED_REASON_COMPLETE; + const outcome = resolveLifecycleOutcomeFromRunOutcome(params.entry.outcome); + const error = params.entry.outcome?.status === "error" ? params.entry.outcome.error : undefined; + await emitSubagentEndedHookOnce({ + entry: params.entry, + reason, + sendFarewell: params.sendFarewell, + accountId: params.accountId ?? params.entry.requesterOrigin?.accountId, + outcome, + error, + inFlightRunIds: endedHookInFlightRunIds, + persist: persistSubagentRuns, + }); +} + +async function completeSubagentRun(params: { + runId: string; + endedAt?: number; + outcome: SubagentRunOutcome; + reason: SubagentLifecycleEndedReason; + sendFarewell?: boolean; + accountId?: string; + triggerCleanup: boolean; +}) { + const entry = subagentRuns.get(params.runId); + if (!entry) { + return; + } + + let mutated = false; + const endedAt = typeof params.endedAt === "number" ? params.endedAt : Date.now(); + if (entry.endedAt !== endedAt) { + entry.endedAt = endedAt; + mutated = true; + } + if (!runOutcomesEqual(entry.outcome, params.outcome)) { + entry.outcome = params.outcome; + mutated = true; + } + if (entry.endedReason !== params.reason) { + entry.endedReason = params.reason; + mutated = true; + } + + if (mutated) { + persistSubagentRuns(); + } + + const suppressedForSteerRestart = suppressAnnounceForSteerRestart(entry); + const shouldEmitEndedHook = + !suppressedForSteerRestart && + shouldEmitEndedHookForRun({ + entry, + reason: params.reason, + }); + const shouldDeferEndedHook = + shouldEmitEndedHook && + params.triggerCleanup && + entry.expectsCompletionMessage === true && + !suppressedForSteerRestart; + if (!shouldDeferEndedHook && shouldEmitEndedHook) { + await emitSubagentEndedHookForRun({ + entry, + reason: params.reason, + sendFarewell: params.sendFarewell, + accountId: params.accountId, + }); + } + + if (!params.triggerCleanup) { + return; + } + if (suppressedForSteerRestart) { + return; + } + startSubagentAnnounceCleanupFlow(params.runId, entry); +} + function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecord): boolean { if (!beginSubagentCleanup(runId)) { return false; @@ -102,7 +202,6 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor requesterOrigin, requesterDisplayKey: entry.requesterDisplayKey, task: entry.task, - expectsCompletionMessage: entry.expectsCompletionMessage, timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, cleanup: entry.cleanup, waitForCompletion: false, @@ -110,8 +209,10 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor endedAt: entry.endedAt, label: entry.label, outcome: entry.outcome, + spawnMode: entry.spawnMode, + expectsCompletionMessage: entry.expectsCompletionMessage, }).then((didAnnounce) => { - finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); }); return true; } @@ -182,20 +283,13 @@ function restoreSubagentRunsOnce() { } restoreAttempted = true; try { - const restored = loadSubagentRegistryFromDisk(); - if (restored.size === 0) { + const restoredCount = restoreSubagentRunsFromDisk({ + runs: subagentRuns, + mergeOnly: true, + }); + if (restoredCount === 0) { return; } - for (const [runId, entry] of restored.entries()) { - if (!runId || !entry) { - continue; - } - // Keep any newer in-memory entries. - if (!subagentRuns.has(runId)) { - subagentRuns.set(runId, entry); - } - } - // Resume pending work. ensureListener(); if ([...subagentRuns.values()].some((entry) => entry.archiveAtMs)) { @@ -255,7 +349,11 @@ async function sweepSubagentRuns() { try { await callGateway({ method: "sessions.delete", - params: { key: entry.childSessionKey, deleteTranscript: true }, + params: { + key: entry.childSessionKey, + deleteTranscript: true, + emitLifecycleHooks: false, + }, timeoutMs: 10_000, }); } catch { @@ -276,93 +374,154 @@ function ensureListener() { } listenerStarted = true; listenerStop = onAgentEvent((evt) => { - if (!evt || evt.stream !== "lifecycle") { - return; - } - const entry = subagentRuns.get(evt.runId); - if (!entry) { - return; - } - const phase = evt.data?.phase; - if (phase === "start") { - const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined; - if (startedAt) { - entry.startedAt = startedAt; - persistSubagentRuns(); + void (async () => { + if (!evt || evt.stream !== "lifecycle") { + return; } - return; - } - if (phase !== "end" && phase !== "error") { - return; - } - const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : Date.now(); - entry.endedAt = endedAt; - if (phase === "error") { + const entry = subagentRuns.get(evt.runId); + if (!entry) { + return; + } + const phase = evt.data?.phase; + if (phase === "start") { + const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined; + if (startedAt) { + entry.startedAt = startedAt; + persistSubagentRuns(); + } + return; + } + if (phase !== "end" && phase !== "error") { + return; + } + const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : Date.now(); const error = typeof evt.data?.error === "string" ? evt.data.error : undefined; - entry.outcome = { status: "error", error }; - } else if (evt.data?.aborted) { - entry.outcome = { status: "timeout" }; - } else { - entry.outcome = { status: "ok" }; - } - persistSubagentRuns(); - - if (suppressAnnounceForSteerRestart(entry)) { - return; - } - - if (!startSubagentAnnounceCleanupFlow(evt.runId, entry)) { - return; - } + const outcome: SubagentRunOutcome = + phase === "error" + ? { status: "error", error } + : evt.data?.aborted + ? { status: "timeout" } + : { status: "ok" }; + await completeSubagentRun({ + runId: evt.runId, + endedAt, + outcome, + reason: phase === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); + })(); }); } -function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didAnnounce: boolean) { +async function finalizeSubagentCleanup( + runId: string, + cleanup: "delete" | "keep", + didAnnounce: boolean, +) { const entry = subagentRuns.get(runId); if (!entry) { return; } - if (!didAnnounce) { - const now = Date.now(); - const retryCount = (entry.announceRetryCount ?? 0) + 1; - entry.announceRetryCount = retryCount; + if (didAnnounce) { + const completionReason = resolveCleanupCompletionReason(entry); + await emitCompletionEndedHookIfNeeded(entry, completionReason); + completeCleanupBookkeeping({ + runId, + entry, + cleanup, + completedAt: Date.now(), + }); + return; + } + + const now = Date.now(); + const deferredDecision = resolveDeferredCleanupDecision({ + entry, + now, + activeDescendantRuns: Math.max(0, countActiveDescendantRuns(entry.childSessionKey)), + announceExpiryMs: ANNOUNCE_EXPIRY_MS, + maxAnnounceRetryCount: MAX_ANNOUNCE_RETRY_COUNT, + deferDescendantDelayMs: MIN_ANNOUNCE_RETRY_DELAY_MS, + resolveAnnounceRetryDelayMs, + }); + + if (deferredDecision.kind === "defer-descendants") { entry.lastAnnounceRetryAt = now; - - // Check if the announce has exceeded retry limits or expired (#18264). - const endedAgo = typeof entry.endedAt === "number" ? now - entry.endedAt : 0; - if (retryCount >= MAX_ANNOUNCE_RETRY_COUNT || endedAgo > ANNOUNCE_EXPIRY_MS) { - // Give up: mark as completed to break the infinite retry loop. - logAnnounceGiveUp(entry, retryCount >= MAX_ANNOUNCE_RETRY_COUNT ? "retry-limit" : "expiry"); - entry.cleanupCompletedAt = now; - persistSubagentRuns(); - retryDeferredCompletedAnnounces(runId); - return; - } - - // Allow retry on the next wake if announce was deferred or failed. entry.cleanupHandled = false; resumedRuns.delete(runId); persistSubagentRuns(); - if (entry.expectsCompletionMessage !== true) { - return; - } - setTimeout( - () => { - resumeSubagentRun(runId); - }, - resolveAnnounceRetryDelayMs(entry.announceRetryCount ?? 0), - ).unref?.(); + setTimeout(() => { + resumeSubagentRun(runId); + }, deferredDecision.delayMs).unref?.(); return; } - if (cleanup === "delete") { - subagentRuns.delete(runId); - persistSubagentRuns(); - retryDeferredCompletedAnnounces(runId); + + if (deferredDecision.retryCount != null) { + entry.announceRetryCount = deferredDecision.retryCount; + entry.lastAnnounceRetryAt = now; + } + + if (deferredDecision.kind === "give-up") { + const completionReason = resolveCleanupCompletionReason(entry); + await emitCompletionEndedHookIfNeeded(entry, completionReason); + logAnnounceGiveUp(entry, deferredDecision.reason); + completeCleanupBookkeeping({ + runId, + entry, + cleanup: "keep", + completedAt: now, + }); return; } - entry.cleanupCompletedAt = Date.now(); + + // Allow retry on the next wake if announce was deferred or failed. + entry.cleanupHandled = false; + resumedRuns.delete(runId); persistSubagentRuns(); - retryDeferredCompletedAnnounces(runId); + if (deferredDecision.resumeDelayMs == null) { + return; + } + setTimeout(() => { + resumeSubagentRun(runId); + }, deferredDecision.resumeDelayMs).unref?.(); +} + +async function emitCompletionEndedHookIfNeeded( + entry: SubagentRunRecord, + reason: SubagentLifecycleEndedReason, +) { + if ( + entry.expectsCompletionMessage === true && + shouldEmitEndedHookForRun({ + entry, + reason, + }) + ) { + await emitSubagentEndedHookForRun({ + entry, + reason, + sendFarewell: true, + }); + } +} + +function completeCleanupBookkeeping(params: { + runId: string; + entry: SubagentRunRecord; + cleanup: "delete" | "keep"; + completedAt: number; +}) { + if (params.cleanup === "delete") { + subagentRuns.delete(params.runId); + persistSubagentRuns(); + retryDeferredCompletedAnnounces(params.runId); + return; + } + params.entry.cleanupCompletedAt = params.completedAt; + persistSubagentRuns(); + retryDeferredCompletedAnnounces(params.runId); } function retryDeferredCompletedAnnounces(excludeRunId?: string) { @@ -475,7 +634,9 @@ export function replaceSubagentRunAfterSteer(params: { const now = Date.now(); const cfg = loadConfig(); const archiveAfterMs = resolveArchiveAfterMs(cfg); - const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined; + const spawnMode = source.spawnMode === "session" ? "session" : "run"; + const archiveAtMs = + spawnMode === "session" ? undefined : archiveAfterMs ? now + archiveAfterMs : undefined; const runTimeoutSeconds = params.runTimeoutSeconds ?? source.runTimeoutSeconds ?? 0; const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds); @@ -484,12 +645,15 @@ export function replaceSubagentRunAfterSteer(params: { runId: nextRunId, startedAt: now, endedAt: undefined, + endedReason: undefined, + endedHookEmittedAt: undefined, outcome: undefined, cleanupCompletedAt: undefined, cleanupHandled: false, suppressAnnounceReason: undefined, announceRetryCount: undefined, lastAnnounceRetryAt: undefined, + spawnMode, archiveAtMs, runTimeoutSeconds, }; @@ -516,11 +680,14 @@ export function registerSubagentRun(params: { model?: string; runTimeoutSeconds?: number; expectsCompletionMessage?: boolean; + spawnMode?: "run" | "session"; }) { const now = Date.now(); const cfg = loadConfig(); const archiveAfterMs = resolveArchiveAfterMs(cfg); - const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined; + const spawnMode = params.spawnMode === "session" ? "session" : "run"; + const archiveAtMs = + spawnMode === "session" ? undefined : archiveAfterMs ? now + archiveAfterMs : undefined; const runTimeoutSeconds = params.runTimeoutSeconds ?? 0; const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, runTimeoutSeconds); const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin); @@ -533,6 +700,7 @@ export function registerSubagentRun(params: { task: params.task, cleanup: params.cleanup, expectsCompletionMessage: params.expectsCompletionMessage, + spawnMode, label: params.label, model: params.model, runTimeoutSeconds, @@ -543,7 +711,7 @@ export function registerSubagentRun(params: { }); ensureListener(); persistSubagentRuns(); - if (archiveAfterMs) { + if (archiveAtMs) { startSweeper(); } // Wait for subagent completion via gateway RPC (cross-process). @@ -588,22 +756,29 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) { mutated = true; } const waitError = typeof wait.error === "string" ? wait.error : undefined; - entry.outcome = + const outcome: SubagentRunOutcome = wait.status === "error" ? { status: "error", error: waitError } : wait.status === "timeout" ? { status: "timeout" } : { status: "ok" }; - mutated = true; + if (!runOutcomesEqual(entry.outcome, outcome)) { + entry.outcome = outcome; + mutated = true; + } if (mutated) { persistSubagentRuns(); } - if (suppressAnnounceForSteerRestart(entry)) { - return; - } - if (!startSubagentAnnounceCleanupFlow(runId, entry)) { - return; - } + await completeSubagentRun({ + runId, + endedAt: entry.endedAt, + outcome, + reason: + wait.status === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); } catch { // ignore } @@ -612,6 +787,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) { export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { subagentRuns.clear(); resumedRuns.clear(); + endedHookInFlightRunIds.clear(); resetAnnounceQueuesForTests(); stopSweeper(); restoreAttempted = false; @@ -640,62 +816,23 @@ export function releaseSubagentRun(runId: string) { } function findRunIdsByChildSessionKey(childSessionKey: string): string[] { - const key = childSessionKey.trim(); - if (!key) { - return []; - } - const runIds: string[] = []; - for (const [runId, entry] of subagentRuns.entries()) { - if (entry.childSessionKey === key) { - runIds.push(runId); - } - } - return runIds; -} - -function getRunsSnapshotForRead(): Map { - const merged = new Map(); - const shouldReadDisk = !(process.env.VITEST || process.env.NODE_ENV === "test"); - if (shouldReadDisk) { - try { - // Registry state is persisted to disk so other worker processes (for - // example cron runners) can observe active children spawned elsewhere. - for (const [runId, entry] of loadSubagentRegistryFromDisk().entries()) { - merged.set(runId, entry); - } - } catch { - // Ignore disk read failures and fall back to local memory state. - } - } - for (const [runId, entry] of subagentRuns.entries()) { - merged.set(runId, entry); - } - return merged; + return findRunIdsByChildSessionKeyFromRuns(subagentRuns, childSessionKey); } export function resolveRequesterForChildSession(childSessionKey: string): { requesterSessionKey: string; requesterOrigin?: DeliveryContext; } | null { - const key = childSessionKey.trim(); - if (!key) { - return null; - } - let best: SubagentRunRecord | undefined; - for (const entry of getRunsSnapshotForRead().values()) { - if (entry.childSessionKey !== key) { - continue; - } - if (!best || entry.createdAt > best.createdAt) { - best = entry; - } - } - if (!best) { + const resolved = resolveRequesterForChildSessionFromRuns( + getSubagentRunsSnapshotForRead(subagentRuns), + childSessionKey, + ); + if (!resolved) { return null; } return { - requesterSessionKey: best.requesterSessionKey, - requesterOrigin: normalizeDeliveryContext(best.requesterOrigin), + requesterSessionKey: resolved.requesterSessionKey, + requesterOrigin: normalizeDeliveryContext(resolved.requesterOrigin), }; } @@ -734,6 +871,7 @@ export function markSubagentRunTerminated(params: { const now = Date.now(); const reason = params.reason?.trim() || "killed"; let updated = 0; + const entriesByChildSessionKey = new Map(); for (const runId of runIds) { const entry = subagentRuns.get(runId); if (!entry) { @@ -744,103 +882,57 @@ export function markSubagentRunTerminated(params: { } entry.endedAt = now; entry.outcome = { status: "error", error: reason }; + entry.endedReason = SUBAGENT_ENDED_REASON_KILLED; entry.cleanupHandled = true; entry.cleanupCompletedAt = now; entry.suppressAnnounceReason = "killed"; + if (!entriesByChildSessionKey.has(entry.childSessionKey)) { + entriesByChildSessionKey.set(entry.childSessionKey, entry); + } updated += 1; } if (updated > 0) { persistSubagentRuns(); + for (const entry of entriesByChildSessionKey.values()) { + void emitSubagentEndedHookOnce({ + entry, + reason: SUBAGENT_ENDED_REASON_KILLED, + sendFarewell: true, + outcome: SUBAGENT_ENDED_OUTCOME_KILLED, + error: reason, + inFlightRunIds: endedHookInFlightRunIds, + persist: persistSubagentRuns, + }).catch(() => { + // Hook failures should not break termination flow. + }); + } } return updated; } export function listSubagentRunsForRequester(requesterSessionKey: string): SubagentRunRecord[] { - const key = requesterSessionKey.trim(); - if (!key) { - return []; - } - return [...subagentRuns.values()].filter((entry) => entry.requesterSessionKey === key); + return listRunsForRequesterFromRuns(subagentRuns, requesterSessionKey); } export function countActiveRunsForSession(requesterSessionKey: string): number { - const key = requesterSessionKey.trim(); - if (!key) { - return 0; - } - let count = 0; - for (const entry of getRunsSnapshotForRead().values()) { - if (entry.requesterSessionKey !== key) { - continue; - } - if (typeof entry.endedAt === "number") { - continue; - } - count += 1; - } - return count; + return countActiveRunsForSessionFromRuns( + getSubagentRunsSnapshotForRead(subagentRuns), + requesterSessionKey, + ); } export function countActiveDescendantRuns(rootSessionKey: string): number { - const root = rootSessionKey.trim(); - if (!root) { - return 0; - } - const runs = getRunsSnapshotForRead(); - const pending = [root]; - const visited = new Set([root]); - let count = 0; - while (pending.length > 0) { - const requester = pending.shift(); - if (!requester) { - continue; - } - for (const entry of runs.values()) { - if (entry.requesterSessionKey !== requester) { - continue; - } - if (typeof entry.endedAt !== "number") { - count += 1; - } - const childKey = entry.childSessionKey.trim(); - if (!childKey || visited.has(childKey)) { - continue; - } - visited.add(childKey); - pending.push(childKey); - } - } - return count; + return countActiveDescendantRunsFromRuns( + getSubagentRunsSnapshotForRead(subagentRuns), + rootSessionKey, + ); } export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] { - const root = rootSessionKey.trim(); - if (!root) { - return []; - } - const runs = getRunsSnapshotForRead(); - const pending = [root]; - const visited = new Set([root]); - const descendants: SubagentRunRecord[] = []; - while (pending.length > 0) { - const requester = pending.shift(); - if (!requester) { - continue; - } - for (const entry of runs.values()) { - if (entry.requesterSessionKey !== requester) { - continue; - } - descendants.push(entry); - const childKey = entry.childSessionKey.trim(); - if (!childKey || visited.has(childKey)) { - continue; - } - visited.add(childKey); - pending.push(childKey); - } - } - return descendants; + return listDescendantRunsForRequesterFromRuns( + getSubagentRunsSnapshotForRead(subagentRuns), + rootSessionKey, + ); } export function initSubagentRegistry() { diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts new file mode 100644 index 00000000000..d85773f8be9 --- /dev/null +++ b/src/agents/subagent-registry.types.ts @@ -0,0 +1,35 @@ +import type { DeliveryContext } from "../utils/delivery-context.js"; +import type { SubagentRunOutcome } from "./subagent-announce.js"; +import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js"; +import type { SpawnSubagentMode } from "./subagent-spawn.js"; + +export type SubagentRunRecord = { + runId: string; + childSessionKey: string; + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; + requesterDisplayKey: string; + task: string; + cleanup: "delete" | "keep"; + label?: string; + model?: string; + runTimeoutSeconds?: number; + spawnMode?: SpawnSubagentMode; + createdAt: number; + startedAt?: number; + endedAt?: number; + outcome?: SubagentRunOutcome; + archiveAtMs?: number; + cleanupCompletedAt?: number; + cleanupHandled?: boolean; + suppressAnnounceReason?: "steer-restart" | "killed"; + expectsCompletionMessage?: boolean; + /** Number of announce delivery attempts that returned false (deferred). */ + announceRetryCount?: number; + /** Timestamp of the last announce retry attempt (for backoff). */ + lastAnnounceRetryAt?: number; + /** Terminal lifecycle reason recorded when the run finishes. */ + endedReason?: SubagentLifecycleEndedReason; + /** Set after the subagent_ended hook has been emitted successfully once. */ + endedHookEmittedAt?: number; +}; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index f14e9e50efc..d033c78bc3e 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -1,7 +1,9 @@ import crypto from "node:crypto"; import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js"; +import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; +import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import { resolveAgentConfig } from "./agent-scope.js"; @@ -17,6 +19,9 @@ import { resolveMainSessionAlias, } from "./tools/sessions-helpers.js"; +export const SUBAGENT_SPAWN_MODES = ["run", "session"] as const; +export type SpawnSubagentMode = (typeof SUBAGENT_SPAWN_MODES)[number]; + export type SpawnSubagentParams = { task: string; label?: string; @@ -24,6 +29,8 @@ export type SpawnSubagentParams = { model?: string; thinking?: string; runTimeoutSeconds?: number; + thread?: boolean; + mode?: SpawnSubagentMode; cleanup?: "delete" | "keep"; expectsCompletionMessage?: boolean; }; @@ -42,11 +49,14 @@ export type SpawnSubagentContext = { export const SUBAGENT_SPAWN_ACCEPTED_NOTE = "auto-announces on completion, do not poll/sleep. The response will be sent back as an user message."; +export const SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE = + "thread-bound session stays active after this task; continue in-thread for follow-ups."; export type SpawnSubagentResult = { status: "accepted" | "forbidden" | "error"; childSessionKey?: string; runId?: string; + mode?: SpawnSubagentMode; note?: string; modelApplied?: boolean; error?: string; @@ -67,6 +77,88 @@ export function splitModelRef(ref?: string) { return { provider: undefined, model: trimmed }; } +function resolveSpawnMode(params: { + requestedMode?: SpawnSubagentMode; + threadRequested: boolean; +}): SpawnSubagentMode { + if (params.requestedMode === "run" || params.requestedMode === "session") { + return params.requestedMode; + } + // Thread-bound spawns should default to persistent sessions. + return params.threadRequested ? "session" : "run"; +} + +function summarizeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + return "error"; +} + +async function ensureThreadBindingForSubagentSpawn(params: { + hookRunner: ReturnType; + childSessionKey: string; + agentId: string; + label?: string; + mode: SpawnSubagentMode; + requesterSessionKey?: string; + requester: { + channel?: string; + accountId?: string; + to?: string; + threadId?: string | number; + }; +}): Promise<{ status: "ok" } | { status: "error"; error: string }> { + const hookRunner = params.hookRunner; + if (!hookRunner?.hasHooks("subagent_spawning")) { + return { + status: "error", + error: + "thread=true is unavailable because no channel plugin registered subagent_spawning hooks.", + }; + } + + try { + const result = await hookRunner.runSubagentSpawning( + { + childSessionKey: params.childSessionKey, + agentId: params.agentId, + label: params.label, + mode: params.mode, + requester: params.requester, + threadRequested: true, + }, + { + childSessionKey: params.childSessionKey, + requesterSessionKey: params.requesterSessionKey, + }, + ); + if (result?.status === "error") { + const error = result.error.trim(); + return { + status: "error", + error: error || "Failed to prepare thread binding for this subagent session.", + }; + } + if (result?.status !== "ok" || !result.threadBindingReady) { + return { + status: "error", + error: + "Unable to create or bind a thread for this subagent session. Session mode is unavailable for this target.", + }; + } + return { status: "ok" }; + } catch (err) { + return { + status: "error", + error: `Thread bind failed: ${summarizeError(err)}`, + }; + } +} + export async function spawnSubagentDirect( params: SpawnSubagentParams, ctx: SpawnSubagentContext, @@ -76,19 +168,37 @@ export async function spawnSubagentDirect( const requestedAgentId = params.agentId; const modelOverride = params.model; const thinkingOverrideRaw = params.thinking; + const requestThreadBinding = params.thread === true; + const spawnMode = resolveSpawnMode({ + requestedMode: params.mode, + threadRequested: requestThreadBinding, + }); + if (spawnMode === "session" && !requestThreadBinding) { + return { + status: "error", + error: 'mode="session" requires thread=true so the subagent can stay bound to a thread.', + }; + } const cleanup = - params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; + spawnMode === "session" + ? "keep" + : params.cleanup === "keep" || params.cleanup === "delete" + ? params.cleanup + : "keep"; + const expectsCompletionMessage = params.expectsCompletionMessage !== false; const requesterOrigin = normalizeDeliveryContext({ channel: ctx.agentChannel, accountId: ctx.agentAccountId, to: ctx.agentTo, threadId: ctx.agentThreadId, }); + const hookRunner = getGlobalHookRunner(); const runTimeoutSeconds = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) : 0; let modelApplied = false; + let threadBindingReady = false; const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); @@ -107,7 +217,8 @@ export async function spawnSubagentDirect( }); const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg }); - const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + const maxSpawnDepth = + cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; if (callerDepth >= maxSpawnDepth) { return { status: "forbidden", @@ -227,6 +338,39 @@ export async function spawnSubagentDirect( }; } } + if (requestThreadBinding) { + const bindResult = await ensureThreadBindingForSubagentSpawn({ + hookRunner, + childSessionKey, + agentId: targetAgentId, + label: label || undefined, + mode: spawnMode, + requesterSessionKey: requesterInternalKey, + requester: { + channel: requesterOrigin?.channel, + accountId: requesterOrigin?.accountId, + to: requesterOrigin?.to, + threadId: requesterOrigin?.threadId, + }, + }); + if (bindResult.status === "error") { + try { + await callGateway({ + method: "sessions.delete", + params: { key: childSessionKey, emitLifecycleHooks: false }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort cleanup only. + } + return { + status: "error", + error: bindResult.error, + childSessionKey, + }; + } + threadBindingReady = true; + } const childSystemPrompt = buildSubagentSystemPrompt({ requesterSessionKey, requesterOrigin, @@ -238,8 +382,13 @@ export async function spawnSubagentDirect( }); const childTaskMessage = [ `[Subagent Context] You are running as a subagent (depth ${childDepth}/${maxSpawnDepth}). Results auto-announce to your requester; do not busy-poll for status.`, + spawnMode === "session" + ? "[Subagent Context] This subagent session is persistent and remains available for thread follow-up messages." + : undefined, `[Subagent Task]: ${task}`, - ].join("\n\n"); + ] + .filter((line): line is string => Boolean(line)) + .join("\n\n"); const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; @@ -271,8 +420,50 @@ export async function spawnSubagentDirect( childRunId = response.runId; } } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + if (threadBindingReady) { + const hasEndedHook = hookRunner?.hasHooks("subagent_ended") === true; + let endedHookEmitted = false; + if (hasEndedHook) { + try { + await hookRunner?.runSubagentEnded( + { + targetSessionKey: childSessionKey, + targetKind: "subagent", + reason: "spawn-failed", + sendFarewell: true, + accountId: requesterOrigin?.accountId, + runId: childRunId, + outcome: "error", + error: "Session failed to start", + }, + { + runId: childRunId, + childSessionKey, + requesterSessionKey: requesterInternalKey, + }, + ); + endedHookEmitted = true; + } catch { + // Spawn should still return an actionable error even if cleanup hooks fail. + } + } + // Always delete the provisional child session after a failed spawn attempt. + // If we already emitted subagent_ended above, suppress a duplicate lifecycle hook. + try { + await callGateway({ + method: "sessions.delete", + params: { + key: childSessionKey, + deleteTranscript: true, + emitLifecycleHooks: !endedHookEmitted, + }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort only. + } + } + const messageText = summarizeError(err); return { status: "error", error: messageText, @@ -292,14 +483,45 @@ export async function spawnSubagentDirect( label: label || undefined, model: resolvedModel, runTimeoutSeconds, - expectsCompletionMessage: params.expectsCompletionMessage === true, + expectsCompletionMessage, + spawnMode, }); + if (hookRunner?.hasHooks("subagent_spawned")) { + try { + await hookRunner.runSubagentSpawned( + { + runId: childRunId, + childSessionKey, + agentId: targetAgentId, + label: label || undefined, + requester: { + channel: requesterOrigin?.channel, + accountId: requesterOrigin?.accountId, + to: requesterOrigin?.to, + threadId: requesterOrigin?.threadId, + }, + threadRequested: requestThreadBinding, + mode: spawnMode, + }, + { + runId: childRunId, + childSessionKey, + requesterSessionKey: requesterInternalKey, + }, + ); + } catch { + // Spawn should still return accepted if spawn lifecycle hooks fail. + } + } + return { status: "accepted", childSessionKey, runId: childRunId, - note: SUBAGENT_SPAWN_ACCEPTED_NOTE, + mode: spawnMode, + note: + spawnMode === "session" ? SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE : SUBAGENT_SPAWN_ACCEPTED_NOTE, modelApplied: resolvedModel ? modelApplied : undefined, }; } diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 93ac229a3d1..9102d24847d 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,7 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { optionalStringEnum } from "../schema/typebox.js"; -import { spawnSubagentDirect } from "../subagent-spawn.js"; +import { SUBAGENT_SPAWN_MODES, spawnSubagentDirect } from "../subagent-spawn.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; @@ -14,6 +14,8 @@ const SessionsSpawnToolSchema = Type.Object({ runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), // Back-compat: older callers used timeoutSeconds for this tool. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + thread: Type.Optional(Type.Boolean()), + mode: optionalStringEnum(SUBAGENT_SPAWN_MODES), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -34,7 +36,7 @@ export function createSessionsSpawnTool(opts?: { label: "Sessions", name: "sessions_spawn", description: - "Spawn a background sub-agent run in an isolated session and announce the result back to the requester chat.", + 'Spawn a sub-agent in an isolated session (mode="run" one-shot or mode="session" persistent) and route results back to the requester chat/thread.', parameters: SessionsSpawnToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -43,6 +45,7 @@ export function createSessionsSpawnTool(opts?: { const requestedAgentId = readStringParam(params, "agentId"); const modelOverride = readStringParam(params, "model"); const thinkingOverrideRaw = readStringParam(params, "thinking"); + const mode = params.mode === "run" || params.mode === "session" ? params.mode : undefined; const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; // Back-compat: older callers used timeoutSeconds for this tool. @@ -56,6 +59,7 @@ export function createSessionsSpawnTool(opts?: { typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate) ? Math.max(0, Math.floor(timeoutSecondsCandidate)) : undefined; + const thread = params.thread === true; const result = await spawnSubagentDirect( { @@ -65,6 +69,8 @@ export function createSessionsSpawnTool(opts?: { model: modelOverride, thinking: thinkingOverrideRaw, runTimeoutSeconds, + thread, + mode, cleanup, expectsCompletionMessage: true, }, diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index bf88212d6a0..9b0b75ce857 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -7,6 +7,7 @@ import { sortSubagentRuns, type SubagentTargetResolution, } from "../../auto-reply/reply/subagents-utils.js"; +import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js"; import { loadConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; @@ -199,7 +200,8 @@ function resolveRequesterKey(params: { // Check if this sub-agent can spawn children (orchestrator). // If so, it should see its own children, not its parent's children. const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg }); - const maxSpawnDepth = params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + const maxSpawnDepth = + params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; if (callerDepth < maxSpawnDepth) { // Orchestrator sub-agent: use its own session key as requester // so it sees children it spawned. diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 5a7f3277efa..eb3e6f6d5a2 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -262,6 +262,28 @@ function buildChatCommands(): ChatCommandDefinition[] { textAlias: "/whoami", category: "status", }), + defineChatCommand({ + key: "session", + nativeName: "session", + description: "Manage session-level settings (for example /session ttl).", + textAlias: "/session", + category: "session", + args: [ + { + name: "action", + description: "ttl", + type: "string", + choices: ["ttl"], + }, + { + name: "value", + description: "Duration (24h, 90m) or off", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: "auto", + }), defineChatCommand({ key: "subagents", nativeName: "subagents", @@ -289,6 +311,35 @@ function buildChatCommands(): ChatCommandDefinition[] { ], argsMenu: "auto", }), + defineChatCommand({ + key: "focus", + nativeName: "focus", + description: "Bind this Discord thread (or a new one) to a session target.", + textAlias: "/focus", + category: "management", + args: [ + { + name: "target", + description: "Subagent label/index or session key/id/label", + type: "string", + captureRemaining: true, + }, + ], + }), + defineChatCommand({ + key: "unfocus", + nativeName: "unfocus", + description: "Remove the current Discord thread binding.", + textAlias: "/unfocus", + category: "management", + }), + defineChatCommand({ + key: "agents", + nativeName: "agents", + description: "List thread-bound agents for this session.", + textAlias: "/agents", + category: "management", + }), defineChatCommand({ key: "kill", nativeName: "kill", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 11de311ee0e..40f1d49e75b 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -23,6 +23,7 @@ import { handleAbortTrigger, handleActivationCommand, handleRestartCommand, + handleSessionCommand, handleSendPolicyCommand, handleStopCommand, handleUsageCommand, @@ -47,6 +48,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise { + const getThreadBindingManagerMock = vi.fn(); + const setThreadBindingTtlBySessionKeyMock = vi.fn(); + return { + getThreadBindingManagerMock, + setThreadBindingTtlBySessionKeyMock, + }; +}); + +vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getThreadBindingManager: hoisted.getThreadBindingManagerMock, + setThreadBindingTtlBySessionKey: hoisted.setThreadBindingTtlBySessionKeyMock, + }; +}); + +const { handleSessionCommand } = await import("./commands-session.js"); +const { buildCommandTestParams } = await import("./commands.test-harness.js"); + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +type FakeBinding = { + threadId: string; + targetSessionKey: string; + expiresAt?: number; + boundBy?: string; +}; + +function createDiscordCommandParams(commandBody: string, overrides?: Record) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "channel:thread-1", + AccountId: "default", + MessageThreadId: "thread-1", + ...overrides, + }); +} + +function createFakeThreadBindingManager(binding: FakeBinding | null) { + return { + getByThreadId: vi.fn((_threadId: string) => binding), + }; +} + +describe("/session ttl", () => { + beforeEach(() => { + hoisted.getThreadBindingManagerMock.mockReset(); + hoisted.setThreadBindingTtlBySessionKeyMock.mockReset(); + vi.useRealTimers(); + }); + + it("sets ttl for the focused session", async () => { + const binding: FakeBinding = { + threadId: "thread-1", + targetSessionKey: "agent:main:subagent:child", + }; + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([ + { + ...binding, + boundAt: Date.now(), + expiresAt: new Date("2026-02-21T02:00:00.000Z").getTime(), + }, + ]); + + const result = await handleSessionCommand(createDiscordCommandParams("/session ttl 2h"), true); + const text = result?.reply?.text ?? ""; + + expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + ttlMs: 2 * 60 * 60 * 1000, + }); + expect(text).toContain("Session TTL set to 2h"); + expect(text).toContain("2026-02-21T02:00:00.000Z"); + }); + + it("shows active ttl when no value is provided", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + const binding: FakeBinding = { + threadId: "thread-1", + targetSessionKey: "agent:main:subagent:child", + expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(), + }; + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + + const result = await handleSessionCommand(createDiscordCommandParams("/session ttl"), true); + expect(result?.reply?.text).toContain("Session TTL active (2h"); + }); + + it("disables ttl when set to off", async () => { + const binding: FakeBinding = { + threadId: "thread-1", + targetSessionKey: "agent:main:subagent:child", + expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(), + }; + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([ + { ...binding, boundAt: Date.now(), expiresAt: undefined }, + ]); + + const result = await handleSessionCommand(createDiscordCommandParams("/session ttl off"), true); + + expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + ttlMs: 0, + }); + expect(result?.reply?.text).toContain("Session TTL disabled"); + }); + + it("is unavailable outside discord", async () => { + const params = buildCommandTestParams("/session ttl 2h", baseCfg); + const result = await handleSessionCommand(params, true); + expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions"); + }); + + it("requires binding owner for ttl updates", async () => { + const binding: FakeBinding = { + threadId: "thread-1", + targetSessionKey: "agent:main:subagent:child", + boundBy: "owner-1", + }; + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + + const result = await handleSessionCommand( + createDiscordCommandParams("/session ttl 2h", { + SenderId: "other-user", + }), + true, + ); + + expect(hoisted.setThreadBindingTtlBySessionKeyMock).not.toHaveBeenCalled(); + expect(result?.reply?.text).toContain("Only owner-1 can update session TTL"); + }); +}); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 168364adceb..ea5bd9200f6 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -1,7 +1,13 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; +import { parseDurationMs } from "../../cli/parse-duration.js"; import { isRestartEnabled } from "../../config/commands.js"; import type { SessionEntry } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js"; +import { + formatThreadBindingTtlLabel, + getThreadBindingManager, + setThreadBindingTtlBySessionKey, +} from "../../discord/monitor/thread-bindings.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js"; @@ -41,6 +47,53 @@ function resolveAbortTarget(params: { return { entry: undefined, key: targetSessionKey, sessionId: undefined }; } +const SESSION_COMMAND_PREFIX = "/session"; +const SESSION_TTL_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]); + +function isDiscordSurface(params: Parameters[0]): boolean { + const channel = + params.ctx.OriginatingChannel ?? + params.command.channel ?? + params.ctx.Surface ?? + params.ctx.Provider; + return ( + String(channel ?? "") + .trim() + .toLowerCase() === "discord" + ); +} + +function resolveDiscordAccountId(params: Parameters[0]): string { + const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : ""; + return accountId || "default"; +} + +function resolveSessionCommandUsage() { + return "Usage: /session ttl (example: /session ttl 24h)"; +} + +function parseSessionTtlMs(raw: string): number { + const normalized = raw.trim().toLowerCase(); + if (!normalized) { + throw new Error("missing ttl"); + } + if (SESSION_TTL_OFF_VALUES.has(normalized)) { + return 0; + } + if (/^\d+(?:\.\d+)?$/.test(normalized)) { + const hours = Number(normalized); + if (!Number.isFinite(hours) || hours < 0) { + throw new Error("invalid ttl"); + } + return Math.round(hours * 60 * 60 * 1000); + } + return parseDurationMs(normalized, { defaultUnit: "h" }); +} + +function formatSessionExpiry(expiresAt: number) { + return new Date(expiresAt).toISOString(); +} + async function applyAbortTarget(params: { abortTarget: ReturnType; sessionStore?: Record; @@ -244,6 +297,133 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman }; }; +export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const normalized = params.command.commandBodyNormalized; + if (!/^\/session(?:\s|$)/.test(normalized)) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /session from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim(); + const tokens = rest.split(/\s+/).filter(Boolean); + const action = tokens[0]?.toLowerCase(); + if (action !== "ttl") { + return { + shouldContinue: false, + reply: { text: resolveSessionCommandUsage() }, + }; + } + + if (!isDiscordSurface(params)) { + return { + shouldContinue: false, + reply: { text: "⚠️ /session ttl is currently available for Discord thread-bound sessions." }, + }; + } + + const threadId = + params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + if (!threadId) { + return { + shouldContinue: false, + reply: { text: "⚠️ /session ttl must be run inside a focused Discord thread." }, + }; + } + + const accountId = resolveDiscordAccountId(params); + const threadBindings = getThreadBindingManager(accountId); + if (!threadBindings) { + return { + shouldContinue: false, + reply: { text: "⚠️ Discord thread bindings are unavailable for this account." }, + }; + } + + const binding = threadBindings.getByThreadId(threadId); + if (!binding) { + return { + shouldContinue: false, + reply: { text: "ℹ️ This thread is not currently focused." }, + }; + } + + const ttlArgRaw = tokens.slice(1).join(""); + if (!ttlArgRaw) { + const expiresAt = binding.expiresAt; + if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > Date.now()) { + return { + shouldContinue: false, + reply: { + text: `ℹ️ Session TTL active (${formatThreadBindingTtlLabel(expiresAt - Date.now())}, auto-unfocus at ${formatSessionExpiry(expiresAt)}).`, + }, + }; + } + return { + shouldContinue: false, + reply: { text: "ℹ️ Session TTL is currently disabled for this focused session." }, + }; + } + + const senderId = params.command.senderId?.trim() || ""; + if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) { + return { + shouldContinue: false, + reply: { text: `⚠️ Only ${binding.boundBy} can update session TTL for this thread.` }, + }; + } + + let ttlMs: number; + try { + ttlMs = parseSessionTtlMs(ttlArgRaw); + } catch { + return { + shouldContinue: false, + reply: { text: resolveSessionCommandUsage() }, + }; + } + + const updatedBindings = setThreadBindingTtlBySessionKey({ + targetSessionKey: binding.targetSessionKey, + accountId, + ttlMs, + }); + if (updatedBindings.length === 0) { + return { + shouldContinue: false, + reply: { text: "⚠️ Failed to update session TTL for the current binding." }, + }; + } + + if (ttlMs <= 0) { + return { + shouldContinue: false, + reply: { + text: `✅ Session TTL disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`, + }, + }; + } + + const expiresAt = updatedBindings[0]?.expiresAt; + const expiryLabel = + typeof expiresAt === "number" && Number.isFinite(expiresAt) + ? formatSessionExpiry(expiresAt) + : "n/a"; + return { + shouldContinue: false, + reply: { + text: `✅ Session TTL set to ${formatThreadBindingTtlLabel(ttlMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (auto-unfocus at ${expiryLabel}).`, + }, + }; +}; + export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts new file mode 100644 index 00000000000..420431210bf --- /dev/null +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + addSubagentRunForTests, + resetSubagentRegistryForTests, +} from "../../agents/subagent-registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; + +const hoisted = vi.hoisted(() => { + const callGatewayMock = vi.fn(); + const getThreadBindingManagerMock = vi.fn(); + const resolveThreadBindingThreadNameMock = vi.fn(() => "🤖 codex"); + return { + callGatewayMock, + getThreadBindingManagerMock, + resolveThreadBindingThreadNameMock, + }; +}); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: hoisted.callGatewayMock, +})); + +vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getThreadBindingManager: hoisted.getThreadBindingManagerMock, + resolveThreadBindingThreadName: hoisted.resolveThreadBindingThreadNameMock, + }; +}); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({}), + }; +}); + +// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. +vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: () => ({}), +})); + +const { handleSubagentsCommand } = await import("./commands-subagents.js"); +const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js"); + +type FakeBinding = { + accountId: string; + channelId: string; + threadId: string; + targetKind: "subagent" | "acp"; + targetSessionKey: string; + agentId: string; + label?: string; + webhookId?: string; + webhookToken?: string; + boundBy: string; + boundAt: number; +}; + +function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) { + const byThread = new Map( + initialBindings.map((binding) => [binding.threadId, binding]), + ); + + const manager = { + getSessionTtlMs: vi.fn(() => 24 * 60 * 60 * 1000), + getByThreadId: vi.fn((threadId: string) => byThread.get(threadId)), + listBySessionKey: vi.fn((targetSessionKey: string) => + [...byThread.values()].filter((binding) => binding.targetSessionKey === targetSessionKey), + ), + listBindings: vi.fn(() => [...byThread.values()]), + bindTarget: vi.fn(async (params: Record) => { + const threadId = + typeof params.threadId === "string" && params.threadId.trim() + ? params.threadId.trim() + : "thread-created"; + const targetSessionKey = + typeof params.targetSessionKey === "string" ? params.targetSessionKey.trim() : ""; + const agentId = + typeof params.agentId === "string" && params.agentId.trim() + ? params.agentId.trim() + : "main"; + const binding: FakeBinding = { + accountId: "default", + channelId: + typeof params.channelId === "string" && params.channelId.trim() + ? params.channelId.trim() + : "parent-1", + threadId, + targetKind: + params.targetKind === "subagent" || params.targetKind === "acp" + ? params.targetKind + : "acp", + targetSessionKey, + agentId, + label: typeof params.label === "string" ? params.label : undefined, + boundBy: typeof params.boundBy === "string" ? params.boundBy : "system", + boundAt: Date.now(), + }; + byThread.set(threadId, binding); + return binding; + }), + unbindThread: vi.fn((params: { threadId: string }) => { + const binding = byThread.get(params.threadId) ?? null; + if (binding) { + byThread.delete(params.threadId); + } + return binding; + }), + }; + + return { manager, byThread }; +} + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +function createDiscordCommandParams(commandBody: string) { + const params = buildCommandTestParams(commandBody, baseCfg, { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "channel:parent-1", + AccountId: "default", + MessageThreadId: "thread-1", + }); + params.command.senderId = "user-1"; + return params; +} + +describe("/focus, /unfocus, /agents", () => { + beforeEach(() => { + resetSubagentRegistryForTests(); + hoisted.callGatewayMock.mockReset(); + hoisted.getThreadBindingManagerMock.mockReset(); + hoisted.resolveThreadBindingThreadNameMock.mockReset().mockReturnValue("🤖 codex"); + }); + + it("/focus resolves ACP sessions and binds the current Discord thread", async () => { + const fake = createFakeThreadBindingManager(); + hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); + hoisted.callGatewayMock.mockImplementation(async (request: unknown) => { + const method = (request as { method?: string }).method; + if (method === "sessions.resolve") { + return { key: "agent:codex-acp:session-1" }; + } + return {}; + }); + + const params = createDiscordCommandParams("/focus codex-acp"); + const result = await handleSubagentsCommand(params, true); + + expect(result?.reply?.text).toContain("bound this thread"); + expect(result?.reply?.text).toContain("(acp)"); + expect(fake.manager.bindTarget).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread-1", + createThread: false, + targetKind: "acp", + targetSessionKey: "agent:codex-acp:session-1", + introText: + "🤖 codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.", + }), + ); + }); + + it("/unfocus removes an active thread binding for the binding owner", async () => { + const fake = createFakeThreadBindingManager([ + { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "child", + boundBy: "user-1", + boundAt: Date.now(), + }, + ]); + hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); + + const params = createDiscordCommandParams("/unfocus"); + const result = await handleSubagentsCommand(params, true); + + expect(result?.reply?.text).toContain("Thread unfocused"); + expect(fake.manager.unbindThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread-1", + reason: "manual", + }), + ); + }); + + it("/focus rejects rebinding when the thread is focused by another user", async () => { + const fake = createFakeThreadBindingManager([ + { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "child", + boundBy: "user-2", + boundAt: Date.now(), + }, + ]); + hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); + hoisted.callGatewayMock.mockImplementation(async (request: unknown) => { + const method = (request as { method?: string }).method; + if (method === "sessions.resolve") { + return { key: "agent:codex-acp:session-1" }; + } + return {}; + }); + + const params = createDiscordCommandParams("/focus codex-acp"); + const result = await handleSubagentsCommand(params, true); + + expect(result?.reply?.text).toContain("Only user-2 can refocus this thread."); + expect(fake.manager.bindTarget).not.toHaveBeenCalled(); + }); + + it("/agents includes bound persistent sessions and requester-scoped ACP bindings", async () => { + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:child-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "test task", + cleanup: "keep", + label: "child-1", + createdAt: Date.now(), + }); + + const fake = createFakeThreadBindingManager([ + { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child-1", + agentId: "main", + label: "child-1", + boundBy: "user-1", + boundAt: Date.now(), + }, + { + accountId: "default", + channelId: "parent-1", + threadId: "thread-2", + targetKind: "acp", + targetSessionKey: "agent:main:main", + agentId: "codex-acp", + label: "main-session", + boundBy: "user-1", + boundAt: Date.now(), + }, + { + accountId: "default", + channelId: "parent-1", + threadId: "thread-3", + targetKind: "acp", + targetSessionKey: "agent:codex-acp:session-2", + agentId: "codex-acp", + label: "codex-acp", + boundBy: "user-1", + boundAt: Date.now(), + }, + ]); + hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); + + const params = createDiscordCommandParams("/agents"); + const result = await handleSubagentsCommand(params, true); + const text = result?.reply?.text ?? ""; + + expect(text).toContain("agents:"); + expect(text).toContain("thread:thread-1"); + expect(text).toContain("acp/session bindings:"); + expect(text).toContain("session:agent:main:main"); + expect(text).not.toContain("session:agent:codex-acp:session-2"); + }); + + it("/agents keeps finished session-mode runs visible while their thread binding remains", async () => { + addSubagentRunForTests({ + runId: "run-session-1", + childSessionKey: "agent:main:subagent:persistent-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "persistent task", + cleanup: "keep", + label: "persistent-1", + spawnMode: "session", + createdAt: Date.now(), + endedAt: Date.now(), + }); + + const fake = createFakeThreadBindingManager([ + { + accountId: "default", + channelId: "parent-1", + threadId: "thread-persistent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:persistent-1", + agentId: "main", + label: "persistent-1", + boundBy: "user-1", + boundAt: Date.now(), + }, + ]); + hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); + + const params = createDiscordCommandParams("/agents"); + const result = await handleSubagentsCommand(params, true); + const text = result?.reply?.text ?? ""; + + expect(text).toContain("agents:"); + expect(text).toContain("persistent-1"); + expect(text).toContain("thread:thread-persistent-1"); + }); + + it("/focus is discord-only", async () => { + const params = buildCommandTestParams("/focus codex-acp", baseCfg); + const result = await handleSubagentsCommand(params, true); + expect(result?.reply?.text).toContain("only available on Discord"); + }); +}); diff --git a/src/auto-reply/reply/commands-subagents-spawn.test.ts b/src/auto-reply/reply/commands-subagents-spawn.test.ts index f7655a2b576..e09392d002d 100644 --- a/src/auto-reply/reply/commands-subagents-spawn.test.ts +++ b/src/auto-reply/reply/commands-subagents-spawn.test.ts @@ -11,6 +11,7 @@ const hoisted = vi.hoisted(() => { vi.mock("../../agents/subagent-spawn.js", () => ({ spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args), + SUBAGENT_SPAWN_MODES: ["run", "session"], })); vi.mock("../../gateway/call.js", () => ({ @@ -93,6 +94,7 @@ describe("/subagents spawn command", () => { const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; expect(spawnParams.task).toBe("do the thing"); expect(spawnParams.agentId).toBe("beta"); + expect(spawnParams.mode).toBe("run"); expect(spawnParams.cleanup).toBe("keep"); expect(spawnParams.expectsCompletionMessage).toBe(true); expect(spawnCtx.agentSessionKey).toBeDefined(); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 1eb0ad13f84..7f1963c52f7 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -1,255 +1,38 @@ -import crypto from "node:crypto"; -import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js"; -import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; -import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; -import { - clearSubagentRunSteerRestart, - listSubagentRunsForRequester, - markSubagentRunTerminated, - markSubagentRunForSteerRestart, - replaceSubagentRunAfterSteer, -} from "../../agents/subagent-registry.js"; -import { spawnSubagentDirect } from "../../agents/subagent-spawn.js"; -import { - extractAssistantText, - resolveInternalSessionKey, - resolveMainSessionAlias, - sanitizeTextContent, - stripToolMessages, -} from "../../agents/tools/sessions-helpers.js"; -import { - type SessionEntry, - loadSessionStore, - resolveStorePath, - updateSessionStore, -} from "../../config/sessions.js"; -import { callGateway } from "../../gateway/call.js"; +import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js"; import { logVerbose } from "../../globals.js"; -import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; -import { parseAgentSessionKey } from "../../routing/session-key.js"; -import { extractTextFromChatContent } from "../../shared/chat-content.js"; +import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js"; +import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js"; +import { handleSubagentsHelpAction } from "./commands-subagents/action-help.js"; +import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js"; +import { handleSubagentsKillAction } from "./commands-subagents/action-kill.js"; +import { handleSubagentsListAction } from "./commands-subagents/action-list.js"; +import { handleSubagentsLogAction } from "./commands-subagents/action-log.js"; +import { handleSubagentsSendAction } from "./commands-subagents/action-send.js"; +import { handleSubagentsSpawnAction } from "./commands-subagents/action-spawn.js"; +import { handleSubagentsUnfocusAction } from "./commands-subagents/action-unfocus.js"; import { - formatDurationCompact, - formatTokenUsageDisplay, - truncateLine, -} from "../../shared/subagents-format.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; -import { stopSubagentsForRequester } from "./abort.js"; + type SubagentsCommandContext, + extractMessageText, + resolveHandledPrefix, + resolveRequesterSessionKey, + resolveSubagentsAction, + stopWithText, +} from "./commands-subagents/shared.js"; import type { CommandHandler } from "./commands-types.js"; -import { clearSessionQueues } from "./queue.js"; -import { - formatRunLabel, - formatRunStatus, - resolveSubagentTargetFromRuns, - type SubagentTargetResolution, - sortSubagentRuns, -} from "./subagents-utils.js"; -const COMMAND = "/subagents"; -const COMMAND_KILL = "/kill"; -const COMMAND_STEER = "/steer"; -const COMMAND_TELL = "/tell"; -const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "spawn", "help"]); -const RECENT_WINDOW_MINUTES = 30; -const SUBAGENT_TASK_PREVIEW_MAX = 110; -const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; - -function compactLine(value: string) { - return value.replace(/\s+/g, " ").trim(); -} - -function formatTaskPreview(value: string) { - return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX); -} - -function resolveModelDisplay( - entry?: { - model?: unknown; - modelProvider?: unknown; - modelOverride?: unknown; - providerOverride?: unknown; - }, - fallbackModel?: string, -) { - const model = typeof entry?.model === "string" ? entry.model.trim() : ""; - const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; - let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model; - if (!combined) { - // Fall back to override fields which are populated at spawn time, - // before the first run completes and writes model/modelProvider. - const overrideModel = - typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; - const overrideProvider = - typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; - combined = overrideModel.includes("/") - ? overrideModel - : overrideModel && overrideProvider - ? `${overrideProvider}/${overrideModel}` - : overrideModel; - } - if (!combined) { - combined = fallbackModel?.trim() || ""; - } - if (!combined) { - return "model n/a"; - } - const slash = combined.lastIndexOf("/"); - if (slash >= 0 && slash < combined.length - 1) { - return combined.slice(slash + 1); - } - return combined; -} - -function resolveDisplayStatus(entry: SubagentRunRecord) { - const status = formatRunStatus(entry); - return status === "error" ? "failed" : status; -} - -function formatSubagentListLine(params: { - entry: SubagentRunRecord; - index: number; - runtimeMs: number; - sessionEntry?: SessionEntry; -}) { - const usageText = formatTokenUsageDisplay(params.sessionEntry); - const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48); - const task = formatTaskPreview(params.entry.task); - const runtime = formatDurationCompact(params.runtimeMs); - const status = resolveDisplayStatus(params.entry); - return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; -} - -function formatTimestamp(valueMs?: number) { - if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { - return "n/a"; - } - return new Date(valueMs).toISOString(); -} - -function formatTimestampWithAge(valueMs?: number) { - if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { - return "n/a"; - } - return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`; -} - -function resolveRequesterSessionKey( - params: Parameters[0], - opts?: { preferCommandTarget?: boolean }, -): string | undefined { - const commandTarget = params.ctx.CommandTargetSessionKey?.trim(); - const commandSession = params.sessionKey?.trim(); - const raw = opts?.preferCommandTarget - ? commandTarget || commandSession - : commandSession || commandTarget; - if (!raw) { - return undefined; - } - const { mainKey, alias } = resolveMainSessionAlias(params.cfg); - return resolveInternalSessionKey({ key: raw, alias, mainKey }); -} - -function resolveSubagentTarget( - runs: SubagentRunRecord[], - token: string | undefined, -): SubagentTargetResolution { - return resolveSubagentTargetFromRuns({ - runs, - token, - recentWindowMinutes: RECENT_WINDOW_MINUTES, - label: (entry) => formatRunLabel(entry), - errors: { - missingTarget: "Missing subagent id.", - invalidIndex: (value) => `Invalid subagent index: ${value}`, - unknownSession: (value) => `Unknown subagent session: ${value}`, - ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`, - ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`, - ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`, - unknownTarget: (value) => `Unknown subagent id: ${value}`, - }, - }); -} - -function buildSubagentsHelp() { - return [ - "Subagents", - "Usage:", - "- /subagents list", - "- /subagents kill ", - "- /subagents log [limit] [tools]", - "- /subagents info ", - "- /subagents send ", - "- /subagents steer ", - "- /subagents spawn [--model ] [--thinking ]", - "- /kill ", - "- /steer ", - "- /tell ", - "", - "Ids: use the list index (#), runId/session prefix, label, or full session key.", - ].join("\n"); -} - -type ChatMessage = { - role?: unknown; - content?: unknown; -}; - -export function extractMessageText(message: ChatMessage): { role: string; text: string } | null { - const role = typeof message.role === "string" ? message.role : ""; - const shouldSanitize = role === "assistant"; - const text = extractTextFromChatContent(message.content, { - sanitizeText: shouldSanitize ? sanitizeTextContent : undefined, - }); - return text ? { role, text } : null; -} - -function formatLogLines(messages: ChatMessage[]) { - const lines: string[] = []; - for (const msg of messages) { - const extracted = extractMessageText(msg); - if (!extracted) { - continue; - } - const label = extracted.role === "assistant" ? "Assistant" : "User"; - lines.push(`${label}: ${extracted.text}`); - } - return lines; -} - -type SessionStoreCache = Map>; - -function loadSubagentSessionEntry( - params: Parameters[0], - childKey: string, - storeCache?: SessionStoreCache, -) { - const parsed = parseAgentSessionKey(childKey); - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); - let store = storeCache?.get(storePath); - if (!store) { - store = loadSessionStore(storePath); - storeCache?.set(storePath, store); - } - return { storePath, store, entry: store[childKey] }; -} +export { extractMessageText }; export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; } + const normalized = params.command.commandBodyNormalized; - const handledPrefix = normalized.startsWith(COMMAND) - ? COMMAND - : normalized.startsWith(COMMAND_KILL) - ? COMMAND_KILL - : normalized.startsWith(COMMAND_STEER) - ? COMMAND_STEER - : normalized.startsWith(COMMAND_TELL) - ? COMMAND_TELL - : null; + const handledPrefix = resolveHandledPrefix(normalized); if (!handledPrefix) { return null; } + if (!params.command.isAuthorizedSender) { logVerbose( `Ignoring ${handledPrefix} from unauthorized sender: ${params.command.senderId || ""}`, @@ -259,438 +42,50 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo const rest = normalized.slice(handledPrefix.length).trim(); const restTokens = rest.split(/\s+/).filter(Boolean); - let action = "list"; - if (handledPrefix === COMMAND) { - const [actionRaw] = restTokens; - action = actionRaw?.toLowerCase() || "list"; - if (!ACTIONS.has(action)) { - return { shouldContinue: false, reply: { text: buildSubagentsHelp() } }; - } - restTokens.splice(0, 1); - } else if (handledPrefix === COMMAND_KILL) { - action = "kill"; - } else { - action = "steer"; + const action = resolveSubagentsAction({ handledPrefix, restTokens }); + if (!action) { + return handleSubagentsHelpAction(); } const requesterKey = resolveRequesterSessionKey(params, { preferCommandTarget: action === "spawn", }); if (!requesterKey) { - return { shouldContinue: false, reply: { text: "⚠️ Missing session key." } }; - } - const runs = listSubagentRunsForRequester(requesterKey); - - if (action === "help") { - return { shouldContinue: false, reply: { text: buildSubagentsHelp() } }; + return stopWithText("⚠️ Missing session key."); } - if (action === "list") { - const sorted = sortSubagentRuns(runs); - const now = Date.now(); - const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000; - const storeCache: SessionStoreCache = new Map(); - let index = 1; - const mapRuns = ( - entries: SubagentRunRecord[], - runtimeMs: (entry: SubagentRunRecord) => number, - ) => - entries.map((entry) => { - const { entry: sessionEntry } = loadSubagentSessionEntry( - params, - entry.childSessionKey, - storeCache, - ); - const line = formatSubagentListLine({ - entry, - index, - runtimeMs: runtimeMs(entry), - sessionEntry, - }); - index += 1; - return line; - }); - const activeEntries = sorted.filter((entry) => !entry.endedAt); - const activeLines = mapRuns( - activeEntries, - (entry) => now - (entry.startedAt ?? entry.createdAt), - ); - const recentEntries = sorted.filter( - (entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff, - ); - const recentLines = mapRuns( - recentEntries, - (entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), - ); + const ctx: SubagentsCommandContext = { + params, + handledPrefix, + requesterKey, + runs: listSubagentRunsForRequester(requesterKey), + restTokens, + }; - const lines = ["active subagents:", "-----"]; - if (activeLines.length === 0) { - lines.push("(none)"); - } else { - lines.push(activeLines.join("\n")); - } - lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----"); - if (recentLines.length === 0) { - lines.push("(none)"); - } else { - lines.push(recentLines.join("\n")); - } - return { shouldContinue: false, reply: { text: lines.join("\n") } }; + switch (action) { + case "help": + return handleSubagentsHelpAction(); + case "agents": + return handleSubagentsAgentsAction(ctx); + case "focus": + return await handleSubagentsFocusAction(ctx); + case "unfocus": + return handleSubagentsUnfocusAction(ctx); + case "list": + return handleSubagentsListAction(ctx); + case "kill": + return await handleSubagentsKillAction(ctx); + case "info": + return handleSubagentsInfoAction(ctx); + case "log": + return await handleSubagentsLogAction(ctx); + case "send": + return await handleSubagentsSendAction(ctx, false); + case "steer": + return await handleSubagentsSendAction(ctx, true); + case "spawn": + return await handleSubagentsSpawnAction(ctx); + default: + return handleSubagentsHelpAction(); } - - if (action === "kill") { - const target = restTokens[0]; - if (!target) { - return { - shouldContinue: false, - reply: { - text: - handledPrefix === COMMAND - ? "Usage: /subagents kill " - : "Usage: /kill ", - }, - }; - } - if (target === "all" || target === "*") { - stopSubagentsForRequester({ - cfg: params.cfg, - requesterSessionKey: requesterKey, - }); - return { shouldContinue: false }; - } - const resolved = resolveSubagentTarget(runs, target); - if (!resolved.entry) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` }, - }; - } - if (resolved.entry.endedAt) { - return { - shouldContinue: false, - reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` }, - }; - } - - const childKey = resolved.entry.childSessionKey; - const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey); - const sessionId = entry?.sessionId; - if (sessionId) { - abortEmbeddedPiRun(sessionId); - } - const cleared = clearSessionQueues([childKey, sessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, - ); - } - if (entry) { - entry.abortedLastRun = true; - entry.updatedAt = Date.now(); - store[childKey] = entry; - await updateSessionStore(storePath, (nextStore) => { - nextStore[childKey] = entry; - }); - } - markSubagentRunTerminated({ - runId: resolved.entry.runId, - childSessionKey: childKey, - reason: "killed", - }); - // Cascade: also stop any sub-sub-agents spawned by this child. - stopSubagentsForRequester({ - cfg: params.cfg, - requesterSessionKey: childKey, - }); - return { shouldContinue: false }; - } - - if (action === "info") { - const target = restTokens[0]; - if (!target) { - return { shouldContinue: false, reply: { text: "ℹ️ Usage: /subagents info " } }; - } - const resolved = resolveSubagentTarget(runs, target); - if (!resolved.entry) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` }, - }; - } - const run = resolved.entry; - const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey); - const runtime = - run.startedAt && Number.isFinite(run.startedAt) - ? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a") - : "n/a"; - const outcome = run.outcome - ? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}` - : "n/a"; - const lines = [ - "ℹ️ Subagent info", - `Status: ${resolveDisplayStatus(run)}`, - `Label: ${formatRunLabel(run)}`, - `Task: ${run.task}`, - `Run: ${run.runId}`, - `Session: ${run.childSessionKey}`, - `SessionId: ${sessionEntry?.sessionId ?? "n/a"}`, - `Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`, - `Runtime: ${runtime}`, - `Created: ${formatTimestampWithAge(run.createdAt)}`, - `Started: ${formatTimestampWithAge(run.startedAt)}`, - `Ended: ${formatTimestampWithAge(run.endedAt)}`, - `Cleanup: ${run.cleanup}`, - run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined, - run.cleanupHandled ? "Cleanup handled: yes" : undefined, - `Outcome: ${outcome}`, - ].filter(Boolean); - return { shouldContinue: false, reply: { text: lines.join("\n") } }; - } - - if (action === "log") { - const target = restTokens[0]; - if (!target) { - return { shouldContinue: false, reply: { text: "📜 Usage: /subagents log [limit]" } }; - } - const includeTools = restTokens.some((token) => token.toLowerCase() === "tools"); - const limitToken = restTokens.find((token) => /^\d+$/.test(token)); - const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20; - const resolved = resolveSubagentTarget(runs, target); - if (!resolved.entry) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` }, - }; - } - const history = await callGateway<{ messages: Array }>({ - method: "chat.history", - params: { sessionKey: resolved.entry.childSessionKey, limit }, - }); - const rawMessages = Array.isArray(history?.messages) ? history.messages : []; - const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages); - const lines = formatLogLines(filtered as ChatMessage[]); - const header = `📜 Subagent log: ${formatRunLabel(resolved.entry)}`; - if (lines.length === 0) { - return { shouldContinue: false, reply: { text: `${header}\n(no messages)` } }; - } - return { shouldContinue: false, reply: { text: [header, ...lines].join("\n") } }; - } - - if (action === "send" || action === "steer") { - const steerRequested = action === "steer"; - const target = restTokens[0]; - const message = restTokens.slice(1).join(" ").trim(); - if (!target || !message) { - return { - shouldContinue: false, - reply: { - text: steerRequested - ? handledPrefix === COMMAND - ? "Usage: /subagents steer " - : `Usage: ${handledPrefix} ` - : "Usage: /subagents send ", - }, - }; - } - const resolved = resolveSubagentTarget(runs, target); - if (!resolved.entry) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` }, - }; - } - if (steerRequested && resolved.entry.endedAt) { - return { - shouldContinue: false, - reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` }, - }; - } - const { entry: targetSessionEntry } = loadSubagentSessionEntry( - params, - resolved.entry.childSessionKey, - ); - const targetSessionId = - typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim() - ? targetSessionEntry.sessionId.trim() - : undefined; - - if (steerRequested) { - // Suppress stale announce before interrupting the in-flight run. - markSubagentRunForSteerRestart(resolved.entry.runId); - - // Force an immediate interruption and make steer the next run. - if (targetSessionId) { - abortEmbeddedPiRun(targetSessionId); - } - const cleared = clearSessionQueues([resolved.entry.childSessionKey, targetSessionId]); - if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { - logVerbose( - `subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, - ); - } - - // Best effort: wait for the interrupted run to settle so the steer - // message is appended on the existing conversation state. - try { - await callGateway({ - method: "agent.wait", - params: { - runId: resolved.entry.runId, - timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, - }, - timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, - }); - } catch { - // Continue even if wait fails; steer should still be attempted. - } - } - - const idempotencyKey = crypto.randomUUID(); - let runId: string = idempotencyKey; - try { - const response = await callGateway<{ runId: string }>({ - method: "agent", - params: { - message, - sessionKey: resolved.entry.childSessionKey, - sessionId: targetSessionId, - idempotencyKey, - deliver: false, - channel: INTERNAL_MESSAGE_CHANNEL, - lane: AGENT_LANE_SUBAGENT, - timeout: 0, - }, - timeoutMs: 10_000, - }); - const responseRunId = typeof response?.runId === "string" ? response.runId : undefined; - if (responseRunId) { - runId = responseRunId; - } - } catch (err) { - if (steerRequested) { - // Replacement launch failed; restore announce behavior for the - // original run so completion is not silently suppressed. - clearSubagentRunSteerRestart(resolved.entry.runId); - } - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return { shouldContinue: false, reply: { text: `send failed: ${messageText}` } }; - } - - if (steerRequested) { - replaceSubagentRunAfterSteer({ - previousRunId: resolved.entry.runId, - nextRunId: runId, - fallback: resolved.entry, - runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0, - }); - return { - shouldContinue: false, - reply: { - text: `steered ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`, - }, - }; - } - - const waitMs = 30_000; - const wait = await callGateway<{ status?: string; error?: string }>({ - method: "agent.wait", - params: { runId, timeoutMs: waitMs }, - timeoutMs: waitMs + 2000, - }); - if (wait?.status === "timeout") { - return { - shouldContinue: false, - reply: { text: `⏳ Subagent still running (run ${runId.slice(0, 8)}).` }, - }; - } - if (wait?.status === "error") { - const waitError = typeof wait.error === "string" ? wait.error : "unknown error"; - return { - shouldContinue: false, - reply: { - text: `⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`, - }, - }; - } - - const history = await callGateway<{ messages: Array }>({ - method: "chat.history", - params: { sessionKey: resolved.entry.childSessionKey, limit: 50 }, - }); - const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); - const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined; - const replyText = last ? extractAssistantText(last) : undefined; - return { - shouldContinue: false, - reply: { - text: - replyText ?? `✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`, - }, - }; - } - - if (action === "spawn") { - const agentId = restTokens[0]; - // Parse remaining tokens: task text with optional --model and --thinking flags. - const taskParts: string[] = []; - let model: string | undefined; - let thinking: string | undefined; - for (let i = 1; i < restTokens.length; i++) { - if (restTokens[i] === "--model" && i + 1 < restTokens.length) { - i += 1; - model = restTokens[i]; - } else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) { - i += 1; - thinking = restTokens[i]; - } else { - taskParts.push(restTokens[i]); - } - } - const task = taskParts.join(" ").trim(); - if (!agentId || !task) { - return { - shouldContinue: false, - reply: { - text: "Usage: /subagents spawn [--model ] [--thinking ]", - }, - }; - } - - const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : ""; - const originatingTo = - typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : ""; - const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : ""; - // OriginatingTo reflects the active conversation target and is safer than - // command.to for cross-surface command dispatch. - const normalizedTo = originatingTo || commandTo || fallbackTo || undefined; - - const result = await spawnSubagentDirect( - { task, agentId, model, thinking, cleanup: "keep", expectsCompletionMessage: true }, - { - agentSessionKey: requesterKey, - agentChannel: params.ctx.OriginatingChannel ?? params.command.channel, - agentAccountId: params.ctx.AccountId, - agentTo: normalizedTo, - agentThreadId: params.ctx.MessageThreadId, - agentGroupId: params.sessionEntry?.groupId ?? null, - agentGroupChannel: params.sessionEntry?.groupChannel ?? null, - agentGroupSpace: params.sessionEntry?.space ?? null, - }, - ); - if (result.status === "accepted") { - return { - shouldContinue: false, - reply: { - text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`, - }, - }; - } - return { - shouldContinue: false, - reply: { text: `Spawn failed: ${result.error ?? result.status}` }, - }; - } - - return { shouldContinue: false, reply: { text: buildSubagentsHelp() } }; }; diff --git a/src/auto-reply/reply/commands-subagents/action-agents.ts b/src/auto-reply/reply/commands-subagents/action-agents.ts new file mode 100644 index 00000000000..bdf14aeec92 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-agents.ts @@ -0,0 +1,55 @@ +import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js"; +import { + type SubagentsCommandContext, + isDiscordSurface, + resolveDiscordAccountId, + stopWithText, +} from "./shared.js"; + +export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): CommandHandlerResult { + const { params, requesterKey, runs } = ctx; + const isDiscord = isDiscordSurface(params); + const accountId = isDiscord ? resolveDiscordAccountId(params) : undefined; + const threadBindings = accountId ? getThreadBindingManager(accountId) : null; + const visibleRuns = sortSubagentRuns(runs).filter((entry) => { + if (!entry.endedAt) { + return true; + } + return Boolean(threadBindings?.listBySessionKey(entry.childSessionKey)[0]); + }); + + const lines = ["agents:", "-----"]; + if (visibleRuns.length === 0) { + lines.push("(none)"); + } else { + let index = 1; + for (const entry of visibleRuns) { + const threadBinding = threadBindings?.listBySessionKey(entry.childSessionKey)[0]; + const bindingText = threadBinding + ? `thread:${threadBinding.threadId}` + : isDiscord + ? "unbound" + : "bindings available on discord"; + lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`); + index += 1; + } + } + + if (threadBindings) { + const acpBindings = threadBindings + .listBindings() + .filter((entry) => entry.targetKind === "acp" && entry.targetSessionKey === requesterKey); + if (acpBindings.length > 0) { + lines.push("", "acp/session bindings:", "-----"); + for (const binding of acpBindings) { + lines.push( + `- ${binding.label ?? binding.targetSessionKey} (thread:${binding.threadId}, session:${binding.targetSessionKey})`, + ); + } + } + } + + return stopWithText(lines.join("\n")); +} diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts new file mode 100644 index 00000000000..1329c718637 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -0,0 +1,90 @@ +import { + getThreadBindingManager, + resolveThreadBindingIntroText, + resolveThreadBindingThreadName, +} from "../../../discord/monitor/thread-bindings.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { + type SubagentsCommandContext, + isDiscordSurface, + resolveDiscordAccountId, + resolveDiscordChannelIdForFocus, + resolveFocusTargetSession, + stopWithText, +} from "./shared.js"; + +export async function handleSubagentsFocusAction( + ctx: SubagentsCommandContext, +): Promise { + const { params, runs, restTokens } = ctx; + if (!isDiscordSurface(params)) { + return stopWithText("⚠️ /focus is only available on Discord."); + } + + const token = restTokens.join(" ").trim(); + if (!token) { + return stopWithText("Usage: /focus "); + } + + const accountId = resolveDiscordAccountId(params); + const threadBindings = getThreadBindingManager(accountId); + if (!threadBindings) { + return stopWithText("⚠️ Discord thread bindings are unavailable for this account."); + } + + const focusTarget = await resolveFocusTargetSession({ runs, token }); + if (!focusTarget) { + return stopWithText(`⚠️ Unable to resolve focus target: ${token}`); + } + + const currentThreadId = + params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params); + if (!currentThreadId && !parentChannelId) { + return stopWithText("⚠️ Could not resolve a Discord channel for /focus."); + } + + const senderId = params.command.senderId?.trim() || ""; + if (currentThreadId) { + const existingBinding = threadBindings.getByThreadId(currentThreadId); + if ( + existingBinding && + existingBinding.boundBy && + existingBinding.boundBy !== "system" && + senderId && + senderId !== existingBinding.boundBy + ) { + return stopWithText(`⚠️ Only ${existingBinding.boundBy} can refocus this thread.`); + } + } + + const label = focusTarget.label || token; + const binding = await threadBindings.bindTarget({ + threadId: currentThreadId || undefined, + channelId: parentChannelId, + createThread: !currentThreadId, + threadName: resolveThreadBindingThreadName({ + agentId: focusTarget.agentId, + label, + }), + targetKind: focusTarget.targetKind, + targetSessionKey: focusTarget.targetSessionKey, + agentId: focusTarget.agentId, + label, + boundBy: senderId || "unknown", + introText: resolveThreadBindingIntroText({ + agentId: focusTarget.agentId, + label, + sessionTtlMs: threadBindings.getSessionTtlMs(), + }), + }); + + if (!binding) { + return stopWithText("⚠️ Failed to bind a Discord thread to the target session."); + } + + const actionText = currentThreadId + ? `bound this thread to ${binding.targetSessionKey}` + : `created thread ${binding.threadId} and bound it to ${binding.targetSessionKey}`; + return stopWithText(`✅ ${actionText} (${binding.targetKind}).`); +} diff --git a/src/auto-reply/reply/commands-subagents/action-help.ts b/src/auto-reply/reply/commands-subagents/action-help.ts new file mode 100644 index 00000000000..d6df8a31e65 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-help.ts @@ -0,0 +1,6 @@ +import type { CommandHandlerResult } from "../commands-types.js"; +import { buildSubagentsHelp, stopWithText } from "./shared.js"; + +export function handleSubagentsHelpAction(): CommandHandlerResult { + return stopWithText(buildSubagentsHelp()); +} diff --git a/src/auto-reply/reply/commands-subagents/action-info.ts b/src/auto-reply/reply/commands-subagents/action-info.ts new file mode 100644 index 00000000000..de54b4eea01 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-info.ts @@ -0,0 +1,59 @@ +import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; +import { formatDurationCompact } from "../../../shared/subagents-format.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { formatRunLabel } from "../subagents-utils.js"; +import { + type SubagentsCommandContext, + formatTimestampWithAge, + loadSubagentSessionEntry, + resolveDisplayStatus, + resolveSubagentEntryForToken, + stopWithText, +} from "./shared.js"; + +export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): CommandHandlerResult { + const { params, runs, restTokens } = ctx; + const target = restTokens[0]; + if (!target) { + return stopWithText("ℹ️ Usage: /subagents info "); + } + + const targetResolution = resolveSubagentEntryForToken(runs, target); + if ("reply" in targetResolution) { + return targetResolution.reply; + } + + const run = targetResolution.entry; + const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey, { + loadSessionStore, + resolveStorePath, + }); + const runtime = + run.startedAt && Number.isFinite(run.startedAt) + ? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a") + : "n/a"; + const outcome = run.outcome + ? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}` + : "n/a"; + + const lines = [ + "ℹ️ Subagent info", + `Status: ${resolveDisplayStatus(run)}`, + `Label: ${formatRunLabel(run)}`, + `Task: ${run.task}`, + `Run: ${run.runId}`, + `Session: ${run.childSessionKey}`, + `SessionId: ${sessionEntry?.sessionId ?? "n/a"}`, + `Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`, + `Runtime: ${runtime}`, + `Created: ${formatTimestampWithAge(run.createdAt)}`, + `Started: ${formatTimestampWithAge(run.startedAt)}`, + `Ended: ${formatTimestampWithAge(run.endedAt)}`, + `Cleanup: ${run.cleanup}`, + run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined, + run.cleanupHandled ? "Cleanup handled: yes" : undefined, + `Outcome: ${outcome}`, + ].filter(Boolean); + + return stopWithText(lines.join("\n")); +} diff --git a/src/auto-reply/reply/commands-subagents/action-kill.ts b/src/auto-reply/reply/commands-subagents/action-kill.ts new file mode 100644 index 00000000000..cb91b4432f7 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-kill.ts @@ -0,0 +1,86 @@ +import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js"; +import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js"; +import { + loadSessionStore, + resolveStorePath, + updateSessionStore, +} from "../../../config/sessions.js"; +import { logVerbose } from "../../../globals.js"; +import { stopSubagentsForRequester } from "../abort.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { clearSessionQueues } from "../queue.js"; +import { formatRunLabel } from "../subagents-utils.js"; +import { + type SubagentsCommandContext, + COMMAND, + loadSubagentSessionEntry, + resolveSubagentEntryForToken, + stopWithText, +} from "./shared.js"; + +export async function handleSubagentsKillAction( + ctx: SubagentsCommandContext, +): Promise { + const { params, handledPrefix, requesterKey, runs, restTokens } = ctx; + const target = restTokens[0]; + if (!target) { + return stopWithText( + handledPrefix === COMMAND ? "Usage: /subagents kill " : "Usage: /kill ", + ); + } + + if (target === "all" || target === "*") { + stopSubagentsForRequester({ + cfg: params.cfg, + requesterSessionKey: requesterKey, + }); + return { shouldContinue: false }; + } + + const targetResolution = resolveSubagentEntryForToken(runs, target); + if ("reply" in targetResolution) { + return targetResolution.reply; + } + if (targetResolution.entry.endedAt) { + return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`); + } + + const childKey = targetResolution.entry.childSessionKey; + const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, { + loadSessionStore, + resolveStorePath, + }); + const sessionId = entry?.sessionId; + if (sessionId) { + abortEmbeddedPiRun(sessionId); + } + + const cleared = clearSessionQueues([childKey, sessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + + if (entry) { + entry.abortedLastRun = true; + entry.updatedAt = Date.now(); + store[childKey] = entry; + await updateSessionStore(storePath, (nextStore) => { + nextStore[childKey] = entry; + }); + } + + markSubagentRunTerminated({ + runId: targetResolution.entry.runId, + childSessionKey: childKey, + reason: "killed", + }); + + stopSubagentsForRequester({ + cfg: params.cfg, + requesterSessionKey: childKey, + }); + + return { shouldContinue: false }; +} diff --git a/src/auto-reply/reply/commands-subagents/action-list.ts b/src/auto-reply/reply/commands-subagents/action-list.ts new file mode 100644 index 00000000000..5b9bfd25250 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-list.ts @@ -0,0 +1,66 @@ +import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { sortSubagentRuns } from "../subagents-utils.js"; +import { + type SessionStoreCache, + type SubagentsCommandContext, + RECENT_WINDOW_MINUTES, + formatSubagentListLine, + loadSubagentSessionEntry, + stopWithText, +} from "./shared.js"; + +export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult { + const { params, runs } = ctx; + const sorted = sortSubagentRuns(runs); + const now = Date.now(); + const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000; + const storeCache: SessionStoreCache = new Map(); + let index = 1; + + const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) => + entries.map((entry) => { + const { entry: sessionEntry } = loadSubagentSessionEntry( + params, + entry.childSessionKey, + { + loadSessionStore, + resolveStorePath, + }, + storeCache, + ); + const line = formatSubagentListLine({ + entry, + index, + runtimeMs: runtimeMs(entry), + sessionEntry, + }); + index += 1; + return line; + }); + + const activeEntries = sorted.filter((entry) => !entry.endedAt); + const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt)); + const recentEntries = sorted.filter( + (entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff, + ); + const recentLines = mapRuns( + recentEntries, + (entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt), + ); + + const lines = ["active subagents:", "-----"]; + if (activeLines.length === 0) { + lines.push("(none)"); + } else { + lines.push(activeLines.join("\n")); + } + lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----"); + if (recentLines.length === 0) { + lines.push("(none)"); + } else { + lines.push(recentLines.join("\n")); + } + + return stopWithText(lines.join("\n")); +} diff --git a/src/auto-reply/reply/commands-subagents/action-log.ts b/src/auto-reply/reply/commands-subagents/action-log.ts new file mode 100644 index 00000000000..e59451d0a33 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-log.ts @@ -0,0 +1,43 @@ +import { callGateway } from "../../../gateway/call.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { formatRunLabel } from "../subagents-utils.js"; +import { + type ChatMessage, + type SubagentsCommandContext, + formatLogLines, + resolveSubagentEntryForToken, + stopWithText, + stripToolMessages, +} from "./shared.js"; + +export async function handleSubagentsLogAction( + ctx: SubagentsCommandContext, +): Promise { + const { runs, restTokens } = ctx; + const target = restTokens[0]; + if (!target) { + return stopWithText("📜 Usage: /subagents log [limit]"); + } + + const includeTools = restTokens.some((token) => token.toLowerCase() === "tools"); + const limitToken = restTokens.find((token) => /^\d+$/.test(token)); + const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20; + + const targetResolution = resolveSubagentEntryForToken(runs, target); + if ("reply" in targetResolution) { + return targetResolution.reply; + } + + const history = await callGateway<{ messages: Array }>({ + method: "chat.history", + params: { sessionKey: targetResolution.entry.childSessionKey, limit }, + }); + const rawMessages = Array.isArray(history?.messages) ? history.messages : []; + const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages); + const lines = formatLogLines(filtered as ChatMessage[]); + const header = `📜 Subagent log: ${formatRunLabel(targetResolution.entry)}`; + if (lines.length === 0) { + return stopWithText(`${header}\n(no messages)`); + } + return stopWithText([header, ...lines].join("\n")); +} diff --git a/src/auto-reply/reply/commands-subagents/action-send.ts b/src/auto-reply/reply/commands-subagents/action-send.ts new file mode 100644 index 00000000000..d8b752571c0 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-send.ts @@ -0,0 +1,159 @@ +import crypto from "node:crypto"; +import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js"; +import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js"; +import { + clearSubagentRunSteerRestart, + replaceSubagentRunAfterSteer, + markSubagentRunForSteerRestart, +} from "../../../agents/subagent-registry.js"; +import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; +import { callGateway } from "../../../gateway/call.js"; +import { logVerbose } from "../../../globals.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { clearSessionQueues } from "../queue.js"; +import { formatRunLabel } from "../subagents-utils.js"; +import { + type SubagentsCommandContext, + COMMAND, + STEER_ABORT_SETTLE_TIMEOUT_MS, + extractAssistantText, + loadSubagentSessionEntry, + resolveSubagentEntryForToken, + stopWithText, + stripToolMessages, +} from "./shared.js"; + +export async function handleSubagentsSendAction( + ctx: SubagentsCommandContext, + steerRequested: boolean, +): Promise { + const { params, handledPrefix, runs, restTokens } = ctx; + const target = restTokens[0]; + const message = restTokens.slice(1).join(" ").trim(); + if (!target || !message) { + return stopWithText( + steerRequested + ? handledPrefix === COMMAND + ? "Usage: /subagents steer " + : `Usage: ${handledPrefix} ` + : "Usage: /subagents send ", + ); + } + + const targetResolution = resolveSubagentEntryForToken(runs, target); + if ("reply" in targetResolution) { + return targetResolution.reply; + } + if (steerRequested && targetResolution.entry.endedAt) { + return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`); + } + + const { entry: targetSessionEntry } = loadSubagentSessionEntry( + params, + targetResolution.entry.childSessionKey, + { + loadSessionStore, + resolveStorePath, + }, + ); + const targetSessionId = + typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim() + ? targetSessionEntry.sessionId.trim() + : undefined; + + if (steerRequested) { + markSubagentRunForSteerRestart(targetResolution.entry.runId); + + if (targetSessionId) { + abortEmbeddedPiRun(targetSessionId); + } + + const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]); + if (cleared.followupCleared > 0 || cleared.laneCleared > 0) { + logVerbose( + `subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, + ); + } + + try { + await callGateway({ + method: "agent.wait", + params: { + runId: targetResolution.entry.runId, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, + }, + timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000, + }); + } catch { + // Continue even if wait fails; steer should still be attempted. + } + } + + const idempotencyKey = crypto.randomUUID(); + let runId: string = idempotencyKey; + try { + const response = await callGateway<{ runId: string }>({ + method: "agent", + params: { + message, + sessionKey: targetResolution.entry.childSessionKey, + sessionId: targetSessionId, + idempotencyKey, + deliver: false, + channel: INTERNAL_MESSAGE_CHANNEL, + lane: AGENT_LANE_SUBAGENT, + timeout: 0, + }, + timeoutMs: 10_000, + }); + const responseRunId = typeof response?.runId === "string" ? response.runId : undefined; + if (responseRunId) { + runId = responseRunId; + } + } catch (err) { + if (steerRequested) { + clearSubagentRunSteerRestart(targetResolution.entry.runId); + } + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return stopWithText(`send failed: ${messageText}`); + } + + if (steerRequested) { + replaceSubagentRunAfterSteer({ + previousRunId: targetResolution.entry.runId, + nextRunId: runId, + fallback: targetResolution.entry, + runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0, + }); + return stopWithText( + `steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`, + ); + } + + const waitMs = 30_000; + const wait = await callGateway<{ status?: string; error?: string }>({ + method: "agent.wait", + params: { runId, timeoutMs: waitMs }, + timeoutMs: waitMs + 2000, + }); + if (wait?.status === "timeout") { + return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`); + } + if (wait?.status === "error") { + const waitError = typeof wait.error === "string" ? wait.error : "unknown error"; + return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`); + } + + const history = await callGateway<{ messages: Array }>({ + method: "chat.history", + params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 }, + }); + const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); + const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined; + const replyText = last ? extractAssistantText(last) : undefined; + return stopWithText( + replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`, + ); +} diff --git a/src/auto-reply/reply/commands-subagents/action-spawn.ts b/src/auto-reply/reply/commands-subagents/action-spawn.ts new file mode 100644 index 00000000000..bb4b58bd865 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-spawn.ts @@ -0,0 +1,65 @@ +import { spawnSubagentDirect } from "../../../agents/subagent-spawn.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { type SubagentsCommandContext, stopWithText } from "./shared.js"; + +export async function handleSubagentsSpawnAction( + ctx: SubagentsCommandContext, +): Promise { + const { params, requesterKey, restTokens } = ctx; + const agentId = restTokens[0]; + + const taskParts: string[] = []; + let model: string | undefined; + let thinking: string | undefined; + for (let i = 1; i < restTokens.length; i++) { + if (restTokens[i] === "--model" && i + 1 < restTokens.length) { + i += 1; + model = restTokens[i]; + } else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) { + i += 1; + thinking = restTokens[i]; + } else { + taskParts.push(restTokens[i]); + } + } + const task = taskParts.join(" ").trim(); + if (!agentId || !task) { + return stopWithText( + "Usage: /subagents spawn [--model ] [--thinking ]", + ); + } + + const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : ""; + const originatingTo = + typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : ""; + const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : ""; + const normalizedTo = originatingTo || commandTo || fallbackTo || undefined; + + const result = await spawnSubagentDirect( + { + task, + agentId, + model, + thinking, + mode: "run", + cleanup: "keep", + expectsCompletionMessage: true, + }, + { + agentSessionKey: requesterKey, + agentChannel: params.ctx.OriginatingChannel ?? params.command.channel, + agentAccountId: params.ctx.AccountId, + agentTo: normalizedTo, + agentThreadId: params.ctx.MessageThreadId, + agentGroupId: params.sessionEntry?.groupId ?? null, + agentGroupChannel: params.sessionEntry?.groupChannel ?? null, + agentGroupSpace: params.sessionEntry?.space ?? null, + }, + ); + if (result.status === "accepted") { + return stopWithText( + `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`, + ); + } + return stopWithText(`Spawn failed: ${result.error ?? result.status}`); +} diff --git a/src/auto-reply/reply/commands-subagents/action-unfocus.ts b/src/auto-reply/reply/commands-subagents/action-unfocus.ts new file mode 100644 index 00000000000..baddf8dcb0d --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/action-unfocus.ts @@ -0,0 +1,42 @@ +import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js"; +import type { CommandHandlerResult } from "../commands-types.js"; +import { + type SubagentsCommandContext, + isDiscordSurface, + resolveDiscordAccountId, + stopWithText, +} from "./shared.js"; + +export function handleSubagentsUnfocusAction(ctx: SubagentsCommandContext): CommandHandlerResult { + const { params } = ctx; + if (!isDiscordSurface(params)) { + return stopWithText("⚠️ /unfocus is only available on Discord."); + } + + const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : ""; + if (!threadId.trim()) { + return stopWithText("⚠️ /unfocus must be run inside a Discord thread."); + } + + const threadBindings = getThreadBindingManager(resolveDiscordAccountId(params)); + if (!threadBindings) { + return stopWithText("⚠️ Discord thread bindings are unavailable for this account."); + } + + const binding = threadBindings.getByThreadId(threadId); + if (!binding) { + return stopWithText("ℹ️ This thread is not currently focused."); + } + + const senderId = params.command.senderId?.trim() || ""; + if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) { + return stopWithText(`⚠️ Only ${binding.boundBy} can unfocus this thread.`); + } + + threadBindings.unbindThread({ + threadId, + reason: "manual", + sendFarewell: true, + }); + return stopWithText("✅ Thread unfocused."); +} diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts new file mode 100644 index 00000000000..237b6c5b7b0 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -0,0 +1,432 @@ +import type { SubagentRunRecord } from "../../../agents/subagent-registry.js"; +import { + extractAssistantText, + resolveInternalSessionKey, + resolveMainSessionAlias, + sanitizeTextContent, + stripToolMessages, +} from "../../../agents/tools/sessions-helpers.js"; +import type { + SessionEntry, + loadSessionStore as loadSessionStoreFn, + resolveStorePath as resolveStorePathFn, +} from "../../../config/sessions.js"; +import { parseDiscordTarget } from "../../../discord/targets.js"; +import { callGateway } from "../../../gateway/call.js"; +import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts"; +import { parseAgentSessionKey } from "../../../routing/session-key.js"; +import { extractTextFromChatContent } from "../../../shared/chat-content.js"; +import { + formatDurationCompact, + formatTokenUsageDisplay, + truncateLine, +} from "../../../shared/subagents-format.js"; +import type { CommandHandler, CommandHandlerResult } from "../commands-types.js"; +import { + formatRunLabel, + formatRunStatus, + resolveSubagentTargetFromRuns, + type SubagentTargetResolution, +} from "../subagents-utils.js"; + +export { extractAssistantText, stripToolMessages }; + +export const COMMAND = "/subagents"; +export const COMMAND_KILL = "/kill"; +export const COMMAND_STEER = "/steer"; +export const COMMAND_TELL = "/tell"; +export const COMMAND_FOCUS = "/focus"; +export const COMMAND_UNFOCUS = "/unfocus"; +export const COMMAND_AGENTS = "/agents"; +export const ACTIONS = new Set([ + "list", + "kill", + "log", + "send", + "steer", + "info", + "spawn", + "focus", + "unfocus", + "agents", + "help", +]); + +export const RECENT_WINDOW_MINUTES = 30; +const SUBAGENT_TASK_PREVIEW_MAX = 110; +export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; + +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function compactLine(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function formatTaskPreview(value: string) { + return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX); +} + +function resolveModelDisplay( + entry?: { + model?: unknown; + modelProvider?: unknown; + modelOverride?: unknown; + providerOverride?: unknown; + }, + fallbackModel?: string, +) { + const model = typeof entry?.model === "string" ? entry.model.trim() : ""; + const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; + let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model; + if (!combined) { + const overrideModel = + typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; + const overrideProvider = + typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; + combined = overrideModel.includes("/") + ? overrideModel + : overrideModel && overrideProvider + ? `${overrideProvider}/${overrideModel}` + : overrideModel; + } + if (!combined) { + combined = fallbackModel?.trim() || ""; + } + if (!combined) { + return "model n/a"; + } + const slash = combined.lastIndexOf("/"); + if (slash >= 0 && slash < combined.length - 1) { + return combined.slice(slash + 1); + } + return combined; +} + +export function resolveDisplayStatus(entry: SubagentRunRecord) { + const status = formatRunStatus(entry); + return status === "error" ? "failed" : status; +} + +export function formatSubagentListLine(params: { + entry: SubagentRunRecord; + index: number; + runtimeMs: number; + sessionEntry?: SessionEntry; +}) { + const usageText = formatTokenUsageDisplay(params.sessionEntry); + const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48); + const task = formatTaskPreview(params.entry.task); + const runtime = formatDurationCompact(params.runtimeMs); + const status = resolveDisplayStatus(params.entry); + return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; +} + +function formatTimestamp(valueMs?: number) { + if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { + return "n/a"; + } + return new Date(valueMs).toISOString(); +} + +export function formatTimestampWithAge(valueMs?: number) { + if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { + return "n/a"; + } + return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`; +} + +export type SubagentsAction = + | "list" + | "kill" + | "log" + | "send" + | "steer" + | "info" + | "spawn" + | "focus" + | "unfocus" + | "agents" + | "help"; + +export type SubagentsCommandParams = Parameters[0]; + +export type SubagentsCommandContext = { + params: SubagentsCommandParams; + handledPrefix: string; + requesterKey: string; + runs: SubagentRunRecord[]; + restTokens: string[]; +}; + +export function stopWithText(text: string): CommandHandlerResult { + return { shouldContinue: false, reply: { text } }; +} + +export function stopWithUnknownTargetError(error?: string): CommandHandlerResult { + return stopWithText(`⚠️ ${error ?? "Unknown subagent."}`); +} + +export function resolveSubagentTarget( + runs: SubagentRunRecord[], + token: string | undefined, +): SubagentTargetResolution { + return resolveSubagentTargetFromRuns({ + runs, + token, + recentWindowMinutes: RECENT_WINDOW_MINUTES, + label: (entry) => formatRunLabel(entry), + errors: { + missingTarget: "Missing subagent id.", + invalidIndex: (value) => `Invalid subagent index: ${value}`, + unknownSession: (value) => `Unknown subagent session: ${value}`, + ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`, + ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`, + ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`, + unknownTarget: (value) => `Unknown subagent id: ${value}`, + }, + }); +} + +export function resolveSubagentEntryForToken( + runs: SubagentRunRecord[], + token: string | undefined, +): { entry: SubagentRunRecord } | { reply: CommandHandlerResult } { + const resolved = resolveSubagentTarget(runs, token); + if (!resolved.entry) { + return { reply: stopWithUnknownTargetError(resolved.error) }; + } + return { entry: resolved.entry }; +} + +export function resolveRequesterSessionKey( + params: SubagentsCommandParams, + opts?: { preferCommandTarget?: boolean }, +): string | undefined { + const commandTarget = params.ctx.CommandTargetSessionKey?.trim(); + const commandSession = params.sessionKey?.trim(); + const raw = opts?.preferCommandTarget + ? commandTarget || commandSession + : commandSession || commandTarget; + if (!raw) { + return undefined; + } + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + return resolveInternalSessionKey({ key: raw, alias, mainKey }); +} + +export function resolveHandledPrefix(normalized: string): string | null { + return normalized.startsWith(COMMAND) + ? COMMAND + : normalized.startsWith(COMMAND_KILL) + ? COMMAND_KILL + : normalized.startsWith(COMMAND_STEER) + ? COMMAND_STEER + : normalized.startsWith(COMMAND_TELL) + ? COMMAND_TELL + : normalized.startsWith(COMMAND_FOCUS) + ? COMMAND_FOCUS + : normalized.startsWith(COMMAND_UNFOCUS) + ? COMMAND_UNFOCUS + : normalized.startsWith(COMMAND_AGENTS) + ? COMMAND_AGENTS + : null; +} + +export function resolveSubagentsAction(params: { + handledPrefix: string; + restTokens: string[]; +}): SubagentsAction | null { + if (params.handledPrefix === COMMAND) { + const [actionRaw] = params.restTokens; + const action = (actionRaw?.toLowerCase() || "list") as SubagentsAction; + if (!ACTIONS.has(action)) { + return null; + } + params.restTokens.splice(0, 1); + return action; + } + if (params.handledPrefix === COMMAND_KILL) { + return "kill"; + } + if (params.handledPrefix === COMMAND_FOCUS) { + return "focus"; + } + if (params.handledPrefix === COMMAND_UNFOCUS) { + return "unfocus"; + } + if (params.handledPrefix === COMMAND_AGENTS) { + return "agents"; + } + return "steer"; +} + +export type FocusTargetResolution = { + targetKind: "subagent" | "acp"; + targetSessionKey: string; + agentId: string; + label?: string; +}; + +export function isDiscordSurface(params: SubagentsCommandParams): boolean { + const channel = + params.ctx.OriginatingChannel ?? + params.command.channel ?? + params.ctx.Surface ?? + params.ctx.Provider; + return ( + String(channel ?? "") + .trim() + .toLowerCase() === "discord" + ); +} + +export function resolveDiscordAccountId(params: SubagentsCommandParams): string { + const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : ""; + return accountId || "default"; +} + +export function resolveDiscordChannelIdForFocus( + params: SubagentsCommandParams, +): string | undefined { + const toCandidates = [ + typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "", + typeof params.command.to === "string" ? params.command.to.trim() : "", + typeof params.ctx.To === "string" ? params.ctx.To.trim() : "", + ].filter(Boolean); + for (const candidate of toCandidates) { + try { + const target = parseDiscordTarget(candidate, { defaultKind: "channel" }); + if (target?.kind === "channel" && target.id) { + return target.id; + } + } catch { + // Ignore parse failures and try the next candidate. + } + } + return undefined; +} + +export async function resolveFocusTargetSession(params: { + runs: SubagentRunRecord[]; + token: string; +}): Promise { + const subagentMatch = resolveSubagentTarget(params.runs, params.token); + if (subagentMatch.entry) { + const key = subagentMatch.entry.childSessionKey; + const parsed = parseAgentSessionKey(key); + return { + targetKind: "subagent", + targetSessionKey: key, + agentId: parsed?.agentId ?? "main", + label: formatRunLabel(subagentMatch.entry), + }; + } + + const token = params.token.trim(); + if (!token) { + return null; + } + + const attempts: Array> = []; + attempts.push({ key: token }); + if (SESSION_ID_RE.test(token)) { + attempts.push({ sessionId: token }); + } + attempts.push({ label: token }); + + for (const attempt of attempts) { + try { + const resolved = await callGateway<{ key?: string }>({ + method: "sessions.resolve", + params: attempt, + }); + const key = typeof resolved?.key === "string" ? resolved.key.trim() : ""; + if (!key) { + continue; + } + const parsed = parseAgentSessionKey(key); + return { + targetKind: key.includes(":subagent:") ? "subagent" : "acp", + targetSessionKey: key, + agentId: parsed?.agentId ?? "main", + label: token, + }; + } catch { + // Try the next resolution strategy. + } + } + return null; +} + +export function buildSubagentsHelp() { + return [ + "Subagents", + "Usage:", + "- /subagents list", + "- /subagents kill ", + "- /subagents log [limit] [tools]", + "- /subagents info ", + "- /subagents send ", + "- /subagents steer ", + "- /subagents spawn [--model ] [--thinking ]", + "- /focus ", + "- /unfocus", + "- /agents", + "- /session ttl ", + "- /kill ", + "- /steer ", + "- /tell ", + "", + "Ids: use the list index (#), runId/session prefix, label, or full session key.", + ].join("\n"); +} + +export type ChatMessage = { + role?: unknown; + content?: unknown; +}; + +export function extractMessageText(message: ChatMessage): { role: string; text: string } | null { + const role = typeof message.role === "string" ? message.role : ""; + const shouldSanitize = role === "assistant"; + const text = extractTextFromChatContent(message.content, { + sanitizeText: shouldSanitize ? sanitizeTextContent : undefined, + }); + return text ? { role, text } : null; +} + +export function formatLogLines(messages: ChatMessage[]) { + const lines: string[] = []; + for (const msg of messages) { + const extracted = extractMessageText(msg); + if (!extracted) { + continue; + } + const label = extracted.role === "assistant" ? "Assistant" : "User"; + lines.push(`${label}: ${extracted.text}`); + } + return lines; +} + +export type SessionStoreCache = Map>; + +export function loadSubagentSessionEntry( + params: SubagentsCommandParams, + childKey: string, + loaders: { + loadSessionStore: typeof loadSessionStoreFn; + resolveStorePath: typeof resolveStorePathFn; + }, + storeCache?: SessionStoreCache, +) { + const parsed = parseAgentSessionKey(childKey); + const storePath = loaders.resolveStorePath(params.cfg.session?.store, { + agentId: parsed?.agentId, + }); + let store = storeCache?.get(storePath); + if (!store) { + store = loaders.loadSessionStore(storePath); + storeCache?.set(storePath, store); + } + return { storePath, store, entry: store[childKey] }; +} diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 31e8f42d822..5c320d502f0 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -2,6 +2,7 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { extractReplyToTag } from "./reply-tags.js"; @@ -120,11 +121,6 @@ export function filterMessagingToolMediaDuplicates(params: { }); } -function normalizeAccountId(value?: string): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed.toLowerCase() : undefined; -} - export function shouldSuppressMessagingToolReplies(params: { messageProvider?: string; messagingToolSentTargets?: MessagingToolSend[]; @@ -139,7 +135,7 @@ export function shouldSuppressMessagingToolReplies(params: { if (!originTarget) { return false; } - const originAccount = normalizeAccountId(params.accountId); + const originAccount = normalizeOptionalAccountId(params.accountId); const sentTargets = params.messagingToolSentTargets ?? []; if (sentTargets.length === 0) { return false; @@ -155,7 +151,7 @@ export function shouldSuppressMessagingToolReplies(params: { if (!targetKey) { return false; } - const targetAccount = normalizeAccountId(target.accountId); + const targetAccount = normalizeOptionalAccountId(target.accountId); if (originAccount && targetAccount && originAccount !== targetAccount) { return false; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 4167e172768..cb4b8a194ed 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -11,6 +11,7 @@ import { evaluateSessionFreshness, type GroupKeyResolution, loadSessionStore, + resolveAndPersistSessionFile, resolveChannelResetConfig, resolveThreadFlag, resolveSessionResetPolicy, @@ -354,13 +355,21 @@ export async function initSessionState(params: { console.warn(`[session-init] forked session created: file=${forked.sessionFile}`); } } - if (!sessionEntry.sessionFile) { - sessionEntry.sessionFile = resolveSessionTranscriptPath( - sessionEntry.sessionId, - agentId, - ctx.MessageThreadId, - ); - } + const fallbackSessionFile = !sessionEntry.sessionFile + ? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId) + : undefined; + const resolvedSessionFile = await resolveAndPersistSessionFile({ + sessionId: sessionEntry.sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId, + sessionsDir: path.dirname(storePath), + fallbackSessionFile, + activeSessionKey: sessionKey, + }); + sessionEntry = resolvedSessionFile.sessionEntry; if (isNewSession) { sessionEntry.compactionCount = 0; sessionEntry.memoryFlushCompactionCount = undefined; diff --git a/src/channels/plugins/outbound/discord.test.ts b/src/channels/plugins/outbound/discord.test.ts index dc80bd18ed1..97bd8b2ff7b 100644 --- a/src/channels/plugins/outbound/discord.test.ts +++ b/src/channels/plugins/outbound/discord.test.ts @@ -1,6 +1,41 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; +const hoisted = vi.hoisted(() => { + const sendMessageDiscordMock = vi.fn(); + const sendPollDiscordMock = vi.fn(); + const sendWebhookMessageDiscordMock = vi.fn(); + const getThreadBindingManagerMock = vi.fn(); + return { + sendMessageDiscordMock, + sendPollDiscordMock, + sendWebhookMessageDiscordMock, + getThreadBindingManagerMock, + }; +}); + +vi.mock("../../../discord/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), + sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => + hoisted.sendWebhookMessageDiscordMock(...args), + }; +}); + +vi.mock("../../../discord/monitor/thread-bindings.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args), + }; +}); + +const { discordOutbound } = await import("./discord.js"); + describe("normalizeDiscordOutboundTarget", () => { it("normalizes bare numeric IDs to channel: prefix", () => { expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({ @@ -33,3 +68,203 @@ describe("normalizeDiscordOutboundTarget", () => { expect(normalizeDiscordOutboundTarget(" 123 ")).toEqual({ ok: true, to: "channel:123" }); }); }); + +describe("discordOutbound", () => { + beforeEach(() => { + hoisted.sendMessageDiscordMock.mockReset().mockResolvedValue({ + messageId: "msg-1", + channelId: "ch-1", + }); + hoisted.sendPollDiscordMock.mockReset().mockResolvedValue({ + messageId: "poll-1", + channelId: "ch-1", + }); + hoisted.sendWebhookMessageDiscordMock.mockReset().mockResolvedValue({ + messageId: "msg-webhook-1", + channelId: "thread-1", + }); + hoisted.getThreadBindingManagerMock.mockReset().mockReturnValue(null); + }); + + it("routes text sends to thread target when threadId is provided", async () => { + const result = await discordOutbound.sendText?.({ + cfg: {}, + to: "channel:parent-1", + text: "hello", + accountId: "default", + threadId: "thread-1", + }); + + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:thread-1", + "hello", + expect.objectContaining({ + accountId: "default", + }), + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-1", + channelId: "ch-1", + }); + }); + + it("uses webhook persona delivery for bound thread text replies", async () => { + hoisted.getThreadBindingManagerMock.mockReturnValue({ + getByThreadId: () => ({ + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "codex-thread", + webhookId: "wh-1", + webhookToken: "tok-1", + boundBy: "system", + boundAt: Date.now(), + }), + }); + + const result = await discordOutbound.sendText?.({ + cfg: {}, + to: "channel:parent-1", + text: "hello from persona", + accountId: "default", + threadId: "thread-1", + replyToId: "reply-1", + identity: { + name: "Codex", + avatarUrl: "https://example.com/avatar.png", + }, + }); + + expect(hoisted.sendWebhookMessageDiscordMock).toHaveBeenCalledWith( + "hello from persona", + expect.objectContaining({ + webhookId: "wh-1", + webhookToken: "tok-1", + accountId: "default", + threadId: "thread-1", + replyTo: "reply-1", + username: "Codex", + avatarUrl: "https://example.com/avatar.png", + }), + ); + expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-webhook-1", + channelId: "thread-1", + }); + }); + + it("falls back to bot send for silent delivery on bound threads", async () => { + hoisted.getThreadBindingManagerMock.mockReturnValue({ + getByThreadId: () => ({ + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + webhookId: "wh-1", + webhookToken: "tok-1", + boundBy: "system", + boundAt: Date.now(), + }), + }); + + const result = await discordOutbound.sendText?.({ + cfg: {}, + to: "channel:parent-1", + text: "silent update", + accountId: "default", + threadId: "thread-1", + silent: true, + }); + + expect(hoisted.sendWebhookMessageDiscordMock).not.toHaveBeenCalled(); + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:thread-1", + "silent update", + expect.objectContaining({ + accountId: "default", + silent: true, + }), + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-1", + channelId: "ch-1", + }); + }); + + it("falls back to bot send when webhook send fails", async () => { + hoisted.getThreadBindingManagerMock.mockReturnValue({ + getByThreadId: () => ({ + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + webhookId: "wh-1", + webhookToken: "tok-1", + boundBy: "system", + boundAt: Date.now(), + }), + }); + hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); + + const result = await discordOutbound.sendText?.({ + cfg: {}, + to: "channel:parent-1", + text: "fallback", + accountId: "default", + threadId: "thread-1", + }); + + expect(hoisted.sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1); + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:thread-1", + "fallback", + expect.objectContaining({ + accountId: "default", + }), + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-1", + channelId: "ch-1", + }); + }); + + it("routes poll sends to thread target when threadId is provided", async () => { + const result = await discordOutbound.sendPoll?.({ + cfg: {}, + to: "channel:parent-1", + poll: { + question: "Best snack?", + options: ["banana", "apple"], + }, + accountId: "default", + threadId: "thread-1", + }); + + expect(hoisted.sendPollDiscordMock).toHaveBeenCalledWith( + "channel:thread-1", + { + question: "Best snack?", + options: ["banana", "apple"], + }, + expect.objectContaining({ + accountId: "default", + }), + ); + expect(result).toEqual({ + messageId: "poll-1", + channelId: "ch-1", + }); + }); +}); diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index dc8ebb00e90..69026db2734 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -1,16 +1,101 @@ -import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js"; +import { + getThreadBindingManager, + type ThreadBindingRecord, +} from "../../../discord/monitor/thread-bindings.js"; +import { + sendMessageDiscord, + sendPollDiscord, + sendWebhookMessageDiscord, +} from "../../../discord/send.js"; +import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; import type { ChannelOutboundAdapter } from "../types.js"; +function resolveDiscordOutboundTarget(params: { + to: string; + threadId?: string | number | null; +}): string { + if (params.threadId == null) { + return params.to; + } + const threadId = String(params.threadId).trim(); + if (!threadId) { + return params.to; + } + return `channel:${threadId}`; +} + +function resolveDiscordWebhookIdentity(params: { + identity?: OutboundIdentity; + binding: ThreadBindingRecord; +}): { username?: string; avatarUrl?: string } { + const usernameRaw = params.identity?.name?.trim(); + const fallbackUsername = params.binding.label?.trim() || params.binding.agentId; + const username = (usernameRaw || fallbackUsername || "").slice(0, 80) || undefined; + const avatarUrl = params.identity?.avatarUrl?.trim() || undefined; + return { username, avatarUrl }; +} + +async function maybeSendDiscordWebhookText(params: { + text: string; + threadId?: string | number | null; + accountId?: string | null; + identity?: OutboundIdentity; + replyToId?: string | null; +}): Promise<{ messageId: string; channelId: string } | null> { + if (params.threadId == null) { + return null; + } + const threadId = String(params.threadId).trim(); + if (!threadId) { + return null; + } + const manager = getThreadBindingManager(params.accountId ?? undefined); + if (!manager) { + return null; + } + const binding = manager.getByThreadId(threadId); + if (!binding?.webhookId || !binding?.webhookToken) { + return null; + } + const persona = resolveDiscordWebhookIdentity({ + identity: params.identity, + binding, + }); + const result = await sendWebhookMessageDiscord(params.text, { + webhookId: binding.webhookId, + webhookToken: binding.webhookToken, + accountId: binding.accountId, + threadId: binding.threadId, + replyTo: params.replyToId ?? undefined, + username: persona.username, + avatarUrl: persona.avatarUrl, + }); + return result; +} + export const discordOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), - sendText: async ({ to, text, accountId, deps, replyToId, silent }) => { + sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity, silent }) => { + if (!silent) { + const webhookResult = await maybeSendDiscordWebhookText({ + text, + threadId, + accountId, + identity, + replyToId, + }).catch(() => null); + if (webhookResult) { + return { channel: "discord", ...webhookResult }; + } + } const send = deps?.sendDiscord ?? sendMessageDiscord; - const result = await send(to, text, { + const target = resolveDiscordOutboundTarget({ to, threadId }); + const result = await send(target, text, { verbose: false, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, @@ -26,10 +111,12 @@ export const discordOutbound: ChannelOutboundAdapter = { accountId, deps, replyToId, + threadId, silent, }) => { const send = deps?.sendDiscord ?? sendMessageDiscord; - const result = await send(to, text, { + const target = resolveDiscordOutboundTarget({ to, threadId }); + const result = await send(target, text, { verbose: false, mediaUrl, mediaLocalRoots, @@ -39,9 +126,11 @@ export const discordOutbound: ChannelOutboundAdapter = { }); return { channel: "discord", ...result }; }, - sendPoll: async ({ to, poll, accountId, silent }) => - await sendPollDiscord(to, poll, { + sendPoll: async ({ to, poll, accountId, threadId, silent }) => { + const target = resolveDiscordOutboundTarget({ to, threadId }); + return await sendPollDiscord(target, poll, { accountId: accountId ?? undefined, silent: silent ?? undefined, - }), + }); + }, }; diff --git a/src/commands/agent.e2e.test.ts b/src/commands/agent.e2e.test.ts index b821acf3906..e8f139476ff 100644 --- a/src/commands/agent.e2e.test.ts +++ b/src/commands/agent.e2e.test.ts @@ -286,6 +286,72 @@ describe("agentCommand", () => { }); }); + it("persists resolved sessionFile for existing session keys", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:abc": { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }); + mockConfig(home, store); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:abc", + }, + runtime, + ); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { sessionId?: string; sessionFile?: string } + >; + const entry = saved["agent:main:subagent:abc"]; + expect(entry?.sessionId).toBe("sess-main"); + expect(entry?.sessionFile).toContain( + `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.sessionFile).toBe(entry?.sessionFile); + }); + }); + + it("preserves topic transcript suffix when persisting missing sessionFile", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:telegram:group:123:topic:456": { + sessionId: "sess-topic", + updatedAt: Date.now(), + }, + }); + mockConfig(home, store); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:telegram:group:123:topic:456", + }, + runtime, + ); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { sessionId?: string; sessionFile?: string } + >; + const entry = saved["agent:main:telegram:group:123:topic:456"]; + expect(entry?.sessionId).toBe("sess-topic"); + expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl"); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.sessionFile).toBe(entry?.sessionFile); + }); + }); + it("derives session key from --agent when no routing target is provided", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 38ba29edc2a..a4ceb01c4bf 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { listAgentIds, resolveAgentDir, @@ -40,8 +41,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; import { loadConfig } from "../config/config.js"; import { + parseSessionThreadInfo, + resolveAndPersistSessionFile, resolveAgentIdFromSessionKey, resolveSessionFilePath, + resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, } from "../config/sessions.js"; @@ -359,6 +363,7 @@ export async function agentCommand( storePath, entry: next, }); + sessionEntry = next; } const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId); @@ -505,9 +510,31 @@ export async function agentCommand( }); } } - const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { + let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { agentId: sessionAgentId, }); + if (sessionStore && sessionKey) { + const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId; + const fallbackSessionFile = !sessionEntry?.sessionFile + ? resolveSessionTranscriptPath( + sessionId, + sessionAgentId, + opts.threadId ?? threadIdFromSessionKey, + ) + : undefined; + const resolvedSessionFile = await resolveAndPersistSessionFile({ + sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId: sessionAgentId, + sessionsDir: path.dirname(storePath), + fallbackSessionFile, + }); + sessionFile = resolvedSessionFile.sessionFile; + sessionEntry = resolvedSessionFile.sessionEntry; + } const startedAt = Date.now(); let lifecycleEnded = false; diff --git a/src/config/agent-limits.ts b/src/config/agent-limits.ts index 53df535ebb1..bc0f0aa2e79 100644 --- a/src/config/agent-limits.ts +++ b/src/config/agent-limits.ts @@ -2,6 +2,8 @@ import type { OpenClawConfig } from "./types.js"; export const DEFAULT_AGENT_MAX_CONCURRENT = 4; export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8; +// Keep depth-1 subagents as leaves unless config explicitly opts into nesting. +export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1; export function resolveAgentMaxConcurrent(cfg?: OpenClawConfig): number { const raw = cfg?.agents?.defaults?.maxConcurrent; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 6e3b658b917..f9bae5271d4 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -345,6 +345,10 @@ export const FIELD_HELP: Record = { 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', "session.identityLinks": "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", + "session.threadBindings.enabled": + "Global master switch for thread-bound session routing features. Channel/provider keys (for example channels.discord.threadBindings.enabled) override this default. Default: true.", + "session.threadBindings.ttlHours": + "Default auto-unfocus TTL in hours for thread-bound sessions across providers/channels. Set 0 to disable (default: 24). Provider keys (for example channels.discord.threadBindings.ttlHours) override this.", "channels.telegram.configWrites": "Allow Telegram to write config in response to channel events/commands (default: true).", "channels.slack.configWrites": @@ -439,6 +443,12 @@ export const FIELD_HELP: Record = { "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.threadBindings.enabled": + "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.", + "channels.discord.threadBindings.ttlHours": + "Auto-unfocus TTL in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable (default: 24). Overrides session.threadBindings.ttlHours when set.", + "channels.discord.threadBindings.spawnSubagentSessions": + "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", "channels.discord.ui.components.accentColor": "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "channels.discord.voice.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 2a6bf2dbccf..1a6d898ae05 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -239,6 +239,8 @@ export const FIELD_LABELS: Record = { "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", "session.dmScope": "DM Session Scope", + "session.threadBindings.enabled": "Thread Binding Enabled", + "session.threadBindings.ttlHours": "Thread Binding TTL (hours)", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "messages.suppressToolErrors": "Suppress Tool Error Warnings", "messages.ackReaction": "Ack Reaction Emoji", @@ -288,6 +290,9 @@ export const FIELD_LABELS: Record = { "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.threadBindings.enabled": "Discord Thread Binding Enabled", + "channels.discord.threadBindings.ttlHours": "Discord Thread Binding TTL (hours)", + "channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn", "channels.discord.ui.components.accentColor": "Discord Component Accent Color", "channels.discord.intents.presence": "Discord Presence Intent", "channels.discord.intents.guildMembers": "Discord Guild Members Intent", diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 0ea031cf050..f4a6cbc0926 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -7,4 +7,5 @@ export * from "./sessions/session-key.js"; export * from "./sessions/store.js"; export * from "./sessions/types.js"; export * from "./sessions/transcript.js"; +export * from "./sessions/session-file.js"; export * from "./sessions/delivery-info.js"; diff --git a/src/config/sessions/session-file.ts b/src/config/sessions/session-file.ts new file mode 100644 index 00000000000..17c886eb65a --- /dev/null +++ b/src/config/sessions/session-file.ts @@ -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; + 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 }; +} diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index c8aff2b4de4..99d415d315f 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -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); + }); +}); diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index eff566a00be..5e3aa0a082e 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -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 }; } diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 0836448b6f2..25cc6dcfb64 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -84,6 +84,19 @@ export type SessionResetByTypeConfig = { thread?: SessionResetConfig; }; +export type SessionThreadBindingsConfig = { + /** + * Master switch for thread-bound session routing features. + * Channel/provider keys can override this default. + */ + enabled?: boolean; + /** + * Auto-unfocus TTL for thread-bound sessions (hours). + * Set to 0 to disable. Default: 24. + */ + ttlHours?: number; +}; + export type SessionConfig = { scope?: SessionScope; /** DM session scoping (default: "main"). */ @@ -105,6 +118,8 @@ export type SessionConfig = { /** Max ping-pong turns between requester/target (0–5). Default: 5. */ maxPingPongTurns?: number; }; + /** Shared defaults for thread-bound session routing across channels/providers. */ + threadBindings?: SessionThreadBindingsConfig; /** Automatic session store maintenance (pruning, capping, file rotation). */ maintenance?: SessionMaintenanceConfig; }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 7e470120147..3b5fbf94b00 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -142,6 +142,25 @@ export type DiscordUiConfig = { components?: DiscordUiComponentsConfig; }; +export type DiscordThreadBindingsConfig = { + /** + * Enable Discord thread binding features (/focus, thread-bound delivery, and + * thread-bound subagent session flows). Overrides session.threadBindings.enabled + * when set. + */ + enabled?: boolean; + /** + * Auto-unfocus TTL for thread-bound sessions in hours. + * Set to 0 to disable TTL. Default: 24. + */ + ttlHours?: number; + /** + * Allow `sessions_spawn({ thread: true })` to auto-create + bind Discord + * threads for subagent sessions. Default: false (opt-in). + */ + spawnSubagentSessions?: boolean; +}; + export type DiscordSlashCommandConfig = { /** Reply ephemerally (default: true). */ ephemeral?: boolean; @@ -233,6 +252,8 @@ export type DiscordAccountConfig = { ui?: DiscordUiConfig; /** Slash command configuration. */ slashCommand?: DiscordSlashCommandConfig; + /** Thread binding lifecycle settings (focus/subagent thread sessions). */ + threadBindings?: DiscordThreadBindingsConfig; /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ intents?: DiscordIntentsConfig; /** Voice channel conversation settings. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 668c413a4e0..cac84e04b60 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -388,6 +388,14 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + threadBindings: z + .object({ + enabled: z.boolean().optional(), + ttlHours: z.number().nonnegative().optional(), + spawnSubagentSessions: z.boolean().optional(), + }) + .strict() + .optional(), intents: z .object({ presence: z.boolean().optional(), diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 8139d2368a6..edf73584a21 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -66,6 +66,13 @@ export const SessionSchema = z }) .strict() .optional(), + threadBindings: z + .object({ + enabled: z.boolean().optional(), + ttlHours: z.number().nonnegative().optional(), + }) + .strict() + .optional(), maintenance: z .object({ mode: z.enum(["enforce", "warn"]).optional(), @@ -168,4 +175,6 @@ export const CommandsSchema = z }) .strict() .optional() - .default({ native: "auto", nativeSkills: "auto", restart: true, ownerDisplay: "raw" }); + .default( + () => ({ native: "auto", nativeSkills: "auto", restart: true, ownerDisplay: "raw" }) as const, + ); diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index 6e334f74368..92a86189a91 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -11,6 +11,7 @@ import { upsertPairingRequestMock, } from "./monitor.tool-result.test-harness.js"; import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; +import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; const loadConfigMock = vi.fn(); vi.mock("../config/config.js", async (importOriginal) => { @@ -91,6 +92,7 @@ async function createHandler(cfg: LoadedConfig) { dmEnabled: true, groupDmEnabled: false, guildEntries: cfg.channels?.discord?.guilds, + threadBindings: createNoopThreadBindingManager("default"), }); } @@ -291,6 +293,7 @@ describe("discord tool result dispatch", () => { accountId: "default", sessionPrefix: "discord:slash", ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), }); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 8d5fef679f1..11b5d47e9fb 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -10,6 +10,7 @@ import { } from "./monitor.tool-result.test-harness.js"; import { createDiscordMessageHandler } from "./monitor/message-handler.js"; import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; +import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; type Config = ReturnType; @@ -71,6 +72,7 @@ async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown replyToMode: "off", dmEnabled: true, groupDmEnabled: false, + threadBindings: createNoopThreadBindingManager("default"), }); } @@ -107,6 +109,7 @@ async function createCategoryGuildHandler() { guildEntries: { "*": { requireMention: false, channels: { c1: { allow: true } } }, }, + threadBindings: createNoopThreadBindingManager("default"), }); } diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts new file mode 100644 index 00000000000..f8bc88600ef --- /dev/null +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -0,0 +1,209 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + preflightDiscordMessage, + resolvePreflightMentionRequirement, + shouldIgnoreBoundThreadWebhookMessage, +} from "./message-handler.preflight.js"; +import { + __testing as threadBindingTesting, + createThreadBindingManager, +} from "./thread-bindings.js"; + +function createThreadBinding( + overrides?: Partial, +) { + return { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child-1", + agentId: "main", + boundBy: "test", + boundAt: 1, + webhookId: "wh-1", + webhookToken: "tok-1", + ...overrides, + } satisfies import("./thread-bindings.js").ThreadBindingRecord; +} + +describe("resolvePreflightMentionRequirement", () => { + it("requires mention when config requires mention and thread is not bound", () => { + expect( + resolvePreflightMentionRequirement({ + shouldRequireMention: true, + isBoundThreadSession: false, + }), + ).toBe(true); + }); + + it("disables mention requirement for bound thread sessions", () => { + expect( + resolvePreflightMentionRequirement({ + shouldRequireMention: true, + isBoundThreadSession: true, + }), + ).toBe(false); + }); + + it("keeps mention requirement disabled when config already disables it", () => { + expect( + resolvePreflightMentionRequirement({ + shouldRequireMention: false, + isBoundThreadSession: false, + }), + ).toBe(false); + }); +}); + +describe("preflightDiscordMessage", () => { + it("bypasses mention gating in bound threads for allowed bot senders", async () => { + const threadBinding = createThreadBinding(); + const threadId = "thread-bot-focus"; + const parentId = "channel-parent-focus"; + const client = { + fetchChannel: async (channelId: string) => { + if (channelId === threadId) { + return { + id: threadId, + type: ChannelType.PublicThread, + name: "focus", + parentId, + ownerId: "owner-1", + }; + } + if (channelId === parentId) { + return { + id: parentId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as import("@buape/carbon").Client; + const message = { + id: "m-bot-1", + content: "relay message without mention", + timestamp: new Date().toISOString(), + channelId: threadId, + attachments: [], + mentionedUsers: [], + mentionedRoles: [], + mentionedEveryone: false, + author: { + id: "relay-bot-1", + bot: true, + username: "Relay", + }, + } as unknown as import("@buape/carbon").Message; + + const result = await preflightDiscordMessage({ + cfg: { + session: { + mainKey: "main", + scope: "per-sender", + }, + } as import("../../config/config.js").OpenClawConfig, + discordConfig: { + allowBots: true, + } as NonNullable["discord"], + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: { + getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), + } as import("./thread-bindings.js").ThreadBindingManager, + data: { + channel_id: threadId, + guild_id: "guild-1", + guild: { + id: "guild-1", + name: "Guild One", + }, + author: message.author, + message, + } as unknown as import("./listeners.js").DiscordMessageEvent, + client, + }); + + expect(result).not.toBeNull(); + expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); + expect(result?.shouldRequireMention).toBe(false); + }); +}); + +describe("shouldIgnoreBoundThreadWebhookMessage", () => { + beforeEach(() => { + threadBindingTesting.resetThreadBindingsForTests(); + }); + + it("returns true when inbound webhook id matches the bound thread webhook", () => { + expect( + shouldIgnoreBoundThreadWebhookMessage({ + webhookId: "wh-1", + threadBinding: createThreadBinding(), + }), + ).toBe(true); + }); + + it("returns false when webhook ids differ", () => { + expect( + shouldIgnoreBoundThreadWebhookMessage({ + webhookId: "wh-other", + threadBinding: createThreadBinding(), + }), + ).toBe(false); + }); + + it("returns false when there is no bound thread webhook", () => { + expect( + shouldIgnoreBoundThreadWebhookMessage({ + webhookId: "wh-1", + threadBinding: createThreadBinding({ webhookId: undefined }), + }), + ).toBe(false); + }); + + it("returns true for recently unbound thread webhook echoes", async () => { + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + }); + const binding = await manager.bindTarget({ + threadId: "thread-1", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child-1", + agentId: "main", + webhookId: "wh-1", + webhookToken: "tok-1", + }); + expect(binding).not.toBeNull(); + + manager.unbindThread({ + threadId: "thread-1", + sendFarewell: false, + }); + + expect( + shouldIgnoreBoundThreadWebhookMessage({ + accountId: "default", + threadId: "thread-1", + webhookId: "wh-1", + }), + ).toBe(true); + }); +}); diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index d474ce2d0a6..0d648aeb7ea 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -25,6 +25,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { sendMessageDiscord } from "../send.js"; import { @@ -55,6 +56,10 @@ import { } from "./message-utils.js"; import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js"; import { resolveDiscordSystemEvent } from "./system-events.js"; +import { + isRecentlyUnboundThreadWebhookMessage, + type ThreadBindingRecord, +} from "./thread-bindings.js"; import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js"; export type { @@ -62,6 +67,41 @@ export type { DiscordMessagePreflightParams, } from "./message-handler.preflight.types.js"; +export function resolvePreflightMentionRequirement(params: { + shouldRequireMention: boolean; + isBoundThreadSession: boolean; +}): boolean { + if (!params.shouldRequireMention) { + return false; + } + return !params.isBoundThreadSession; +} + +export function shouldIgnoreBoundThreadWebhookMessage(params: { + accountId?: string; + threadId?: string; + webhookId?: string | null; + threadBinding?: ThreadBindingRecord; +}): boolean { + const webhookId = params.webhookId?.trim() || ""; + if (!webhookId) { + return false; + } + const boundWebhookId = params.threadBinding?.webhookId?.trim() || ""; + if (!boundWebhookId) { + const threadId = params.threadId?.trim() || ""; + if (!threadId) { + return false; + } + return isRecentlyUnboundThreadWebhookMessage({ + accountId: params.accountId, + threadId, + webhookId, + }); + } + return webhookId === boundWebhookId; +} + export async function preflightDiscordMessage( params: DiscordMessagePreflightParams, ): Promise { @@ -253,7 +293,30 @@ export async function preflightDiscordMessage( // Pass parent peer for thread binding inheritance parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, }); - const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); + const threadBinding = earlyThreadChannel + ? params.threadBindings.getByThreadId(messageChannelId) + : undefined; + if ( + shouldIgnoreBoundThreadWebhookMessage({ + accountId: params.accountId, + threadId: messageChannelId, + webhookId, + threadBinding, + }) + ) { + logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`); + return null; + } + const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; + const effectiveRoute = boundSessionKey + ? { + ...route, + sessionKey: boundSessionKey, + agentId: boundAgentId ?? route.agentId, + } + : route; + const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId); const explicitlyMentioned = Boolean( botId && message.mentionedUsers?.some((user: User) => user.id === botId), ); @@ -314,7 +377,7 @@ export async function preflightDiscordMessage( const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; - const baseSessionKey = route.sessionKey; + const baseSessionKey = effectiveRoute.sessionKey; const channelConfig = isGuildMessage ? resolveDiscordChannelConfigWithFallback({ guildInfo, @@ -408,7 +471,7 @@ export async function preflightDiscordMessage( : undefined; const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; - const shouldRequireMention = resolveDiscordShouldRequireMention({ + const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({ isGuildMessage, isThread: Boolean(threadChannel), botId, @@ -416,6 +479,11 @@ export async function preflightDiscordMessage( channelConfig, guildInfo, }); + const isBoundThreadSession = Boolean(boundSessionKey && threadChannel); + const shouldRequireMention = resolvePreflightMentionRequirement({ + shouldRequireMention: shouldRequireMentionByConfig, + isBoundThreadSession, + }); // Preflight audio transcription for mention detection in guilds // This allows voice notes to be checked for mentions before being dropped @@ -547,7 +615,7 @@ export async function preflightDiscordMessage( }); const effectiveWasMentioned = mentionGate.effectiveWasMentioned; logDebug( - `[discord-preflight] shouldRequireMention=${shouldRequireMention} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`, + `[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`, ); if (isGuildMessage && shouldRequireMention) { if (botId && mentionGate.shouldSkip) { @@ -586,7 +654,7 @@ export async function preflightDiscordMessage( if (systemText) { logDebug(`[discord-preflight] drop: system event`); enqueueSystemEvent(systemText, { - sessionKey: route.sessionKey, + sessionKey: effectiveRoute.sessionKey, contextKey: `discord:system:${messageChannelId}:${message.id}`, }); return null; @@ -598,7 +666,9 @@ export async function preflightDiscordMessage( return null; } - logDebug(`[discord-preflight] success: route=${route.agentId} sessionKey=${route.sessionKey}`); + logDebug( + `[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`, + ); return { cfg: params.cfg, discordConfig: params.discordConfig, @@ -628,7 +698,10 @@ export async function preflightDiscordMessage( baseText, messageText, wasMentioned, - route, + route: effectiveRoute, + threadBinding, + boundSessionKey: boundSessionKey || undefined, + boundAgentId, guildInfo, guildSlug, threadChannel, @@ -651,5 +724,6 @@ export async function preflightDiscordMessage( effectiveWasMentioned, canDetectMention, historyEntry, + threadBindings: params.threadBindings, }; } diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index f06b8d453b8..86a32dbf7e8 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -5,6 +5,7 @@ import type { resolveAgentRoute } from "../../routing/resolve-route.js"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import type { DiscordChannelInfo } from "./message-utils.js"; import type { DiscordSenderIdentity } from "./sender-identity.js"; +import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js"; export type { DiscordSenderIdentity } from "./sender-identity.js"; import type { DiscordThreadChannel } from "./threading.js"; @@ -51,6 +52,9 @@ export type DiscordMessagePreflightContext = { wasMentioned: boolean; route: ReturnType; + threadBinding?: ThreadBindingRecord; + boundSessionKey?: string; + boundAgentId?: string; guildInfo: DiscordGuildEntryResolved | null; guildSlug: string; @@ -79,6 +83,7 @@ export type DiscordMessagePreflightContext = { canDetectMention: boolean; historyEntry?: HistoryEntry; + threadBindings: ThreadBindingManager; }; export type DiscordMessagePreflightParams = { @@ -100,6 +105,7 @@ export type DiscordMessagePreflightParams = { guildEntries?: Record; ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"]; groupPolicy: DiscordMessagePreflightContext["groupPolicy"]; + threadBindings: ThreadBindingManager; data: DiscordMessageEvent; client: Client; }; diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 9bbe5baf9f0..b344ff198af 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -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; @@ -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", () => { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 0badfe48369..307fca48f96 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -94,6 +94,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) guildSlug, channelConfig, baseSessionKey, + boundSessionKey, + threadBindings, route, commandAuthorized, } = ctx; @@ -324,7 +326,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) CommandBody: baseText, From: effectiveFrom, To: effectiveTo, - SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey, + SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey, AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "channel", ConversationLabel: fromLabel, @@ -346,6 +348,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ReplyToBody: replyContext?.body, ReplyToSender: replyContext?.sender, ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey, + MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, ThreadStarterBody: threadStarterBody, ThreadLabel: threadLabel, Timestamp: resolveTimestampMs(message.timestamp), @@ -633,6 +636,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) maxLinesPerMessage: discordConfig?.maxLinesPerMessage, tableMode, chunkMode, + sessionKey: ctxPayload.SessionKey, + threadBindings, }); replyReference.markSent(); }, diff --git a/src/discord/monitor/message-handler.test-harness.ts b/src/discord/monitor/message-handler.test-harness.ts index be8ecb10ebc..1913fa8cf81 100644 --- a/src/discord/monitor/message-handler.test-harness.ts +++ b/src/discord/monitor/message-handler.test-harness.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; export async function createBaseDiscordMessageContext( overrides: Record = {}, @@ -67,6 +68,7 @@ export async function createBaseDiscordMessageContext( sessionKey: "agent:main:discord:guild:g1", mainSessionKey: "agent:main:main", }, + threadBindings: createNoopThreadBindingManager("default"), ...overrides, } as unknown as DiscordMessagePreflightContext; } diff --git a/src/discord/monitor/model-picker-preferences.ts b/src/discord/monitor/model-picker-preferences.ts index 14850475c21..6c7d7b9608f 100644 --- a/src/discord/monitor/model-picker-preferences.ts +++ b/src/discord/monitor/model-picker-preferences.ts @@ -6,6 +6,7 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveStateDir } from "../../config/paths.js"; import { withFileLock } from "../../infra/file-lock.js"; import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; +import { normalizeAccountId as normalizeSharedAccountId } from "../../routing/account-id.js"; const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { retries: { @@ -41,11 +42,6 @@ function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): stri return path.join(stateDir, "discord", "model-picker-preferences.json"); } -function normalizeAccountId(value?: string): string { - const normalized = value?.trim().toLowerCase(); - return normalized || "default"; -} - function normalizeId(value?: string): string { return value?.trim() ?? ""; } @@ -57,7 +53,7 @@ export function buildDiscordModelPickerPreferenceKey( if (!userId) { return null; } - const accountId = normalizeAccountId(scope.accountId); + const accountId = normalizeSharedAccountId(scope.accountId); const guildId = normalizeId(scope.guildId); if (guildId) { return `discord:${accountId}:guild:${guildId}:user:${userId}`; diff --git a/src/discord/monitor/native-command.model-picker.test.ts b/src/discord/monitor/native-command.model-picker.test.ts index 95b9b4d62a4..f555fe79313 100644 --- a/src/discord/monitor/native-command.model-picker.test.ts +++ b/src/discord/monitor/native-command.model-picker.test.ts @@ -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): ModelsProviderData { const byProvider = new Map>(); @@ -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"); + }); }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index a9c2cef9fb7..19c0bc474dc 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -48,6 +48,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; import { chunkItems } from "../../utils/chunk-items.js"; import { withTimeout } from "../../utils/with-timeout.js"; @@ -80,6 +81,7 @@ import { type DiscordModelPickerCommandContext, } from "./model-picker.js"; import { resolveDiscordSenderIdentity } from "./sender-identity.js"; +import type { ThreadBindingManager } from "./thread-bindings.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; @@ -268,6 +270,7 @@ type DiscordCommandArgContext = { discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; + threadBindings: ThreadBindingManager; }; type DiscordModelPickerContext = DiscordCommandArgContext; @@ -353,6 +356,7 @@ async function resolveDiscordModelPickerRoute(params: { interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; cfg: ReturnType; accountId: string; + threadBindings: ThreadBindingManager; }) { const { interaction, cfg, accountId } = params; const channel = interaction.channel; @@ -383,7 +387,7 @@ async function resolveDiscordModelPickerRoute(params: { threadParentId = parentInfo.id; } - return resolveAgentRoute({ + const route = resolveAgentRoute({ cfg, channel: "discord", accountId, @@ -395,6 +399,19 @@ async function resolveDiscordModelPickerRoute(params: { }, parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, }); + + const threadBinding = isThreadChannel + ? params.threadBindings.getByThreadId(rawChannelId) + : undefined; + const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; + return boundSessionKey + ? { + ...route, + sessionKey: boundSessionKey, + agentId: boundAgentId ?? route.agentId, + } + : route; } function resolveDiscordModelPickerCurrentModel(params: { @@ -436,6 +453,7 @@ async function replyWithDiscordModelPickerProviders(params: { command: DiscordModelPickerCommandContext; userId: string; accountId: string; + threadBindings: ThreadBindingManager; preferFollowUp: boolean; }) { const data = await loadDiscordModelPickerData(params.cfg); @@ -443,6 +461,7 @@ async function replyWithDiscordModelPickerProviders(params: { interaction: params.interaction, cfg: params.cfg, accountId: params.accountId, + threadBindings: params.threadBindings, }); const currentModel = resolveDiscordModelPickerCurrentModel({ cfg: params.cfg, @@ -603,6 +622,7 @@ async function handleDiscordModelPickerInteraction( interaction, cfg: ctx.cfg, accountId: ctx.accountId, + threadBindings: ctx.threadBindings, }); const currentModelRef = resolveDiscordModelPickerCurrentModel({ cfg: ctx.cfg, @@ -827,6 +847,7 @@ async function handleDiscordModelPickerInteraction( accountId: ctx.accountId, sessionPrefix: ctx.sessionPrefix, preferFollowUp: true, + threadBindings: ctx.threadBindings, suppressReplies: true, }), 12000, @@ -957,6 +978,7 @@ async function handleDiscordCommandArgInteraction( accountId: ctx.accountId, sessionPrefix: ctx.sessionPrefix, preferFollowUp: true, + threadBindings: ctx.threadBindings, }); } @@ -968,6 +990,7 @@ class DiscordCommandArgButton extends Button { private discordConfig: DiscordConfig; private accountId: string; private sessionPrefix: string; + private threadBindings: ThreadBindingManager; constructor(params: { label: string; @@ -976,6 +999,7 @@ class DiscordCommandArgButton extends Button { discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; + threadBindings: ThreadBindingManager; }) { super(); this.label = params.label; @@ -984,6 +1008,7 @@ class DiscordCommandArgButton extends Button { this.discordConfig = params.discordConfig; this.accountId = params.accountId; this.sessionPrefix = params.sessionPrefix; + this.threadBindings = params.threadBindings; } async run(interaction: ButtonInteraction, data: ComponentData) { @@ -992,6 +1017,7 @@ class DiscordCommandArgButton extends Button { discordConfig: this.discordConfig, accountId: this.accountId, sessionPrefix: this.sessionPrefix, + threadBindings: this.threadBindings, }); } } @@ -1067,6 +1093,7 @@ function buildDiscordCommandArgMenu(params: { discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; + threadBindings: ThreadBindingManager; }): { content: string; components: Row