mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
223
docs/experiments/plans/session-binding-channel-agnostic.md
Normal file
223
docs/experiments/plans/session-binding-channel-agnostic.md
Normal file
@@ -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<string, unknown>;
|
||||
};
|
||||
```
|
||||
|
||||
Core service contract:
|
||||
|
||||
```ts
|
||||
export interface SessionBindingService {
|
||||
bind(input: {
|
||||
targetSessionKey: string;
|
||||
targetKind: BindingTargetKind;
|
||||
conversation: ConversationRef;
|
||||
metadata?: Record<string, unknown>;
|
||||
ttlMs?: number;
|
||||
}): Promise<SessionBindingRecord>;
|
||||
|
||||
listBySession(targetSessionKey: string): SessionBindingRecord[];
|
||||
resolveByConversation(ref: ConversationRef): SessionBindingRecord | null;
|
||||
touch(bindingId: string, at?: number): void;
|
||||
unbind(input: {
|
||||
bindingId?: string;
|
||||
targetSessionKey?: string;
|
||||
reason: string;
|
||||
}): Promise<SessionBindingRecord[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
338
docs/experiments/plans/thread-bound-subagents.md
Normal file
338
docs/experiments/plans/thread-bound-subagents.md
Normal file
@@ -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.<id>.threadBindings.<key>`
|
||||
- channel: `channels.discord.threadBindings.<key>`
|
||||
- global: `session.threadBindings.<key>`
|
||||
|
||||
### 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 <target>`
|
||||
- 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 <agentId> <task> [--model] [--thinking]` | spawn subagent; may be thread bound when `thread: true` path is used |
|
||||
| `/focus <subagent-label | session-key | session-id | session-label>` | manually bind thread to subagent or session |
|
||||
| `/unfocus` | remove binding from current thread |
|
||||
| `/agents` | list active agents and binding state |
|
||||
| `/session ttl <duration | off>` | 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.
|
||||
@@ -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 <duration|off>` (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 <target>` (Discord: bind this thread, or a new thread, to a session/subagent target)
|
||||
- `/unfocus` (Discord: remove the current thread binding)
|
||||
- `/kill <id|#|all>` (immediately abort one or all running sub-agents for this session; no confirmation message)
|
||||
- `/steer <id|#> <message>` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message)
|
||||
- `/tell <id|#> <message>` (alias for `/steer`)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
430
extensions/discord/src/subagent-hooks.test.ts
Normal file
430
extensions/discord/src/subagent-hooks.test.ts
Normal file
@@ -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<string, unknown> = {
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
) {
|
||||
const handlers = new Map<string, (event: unknown, ctx: unknown) => 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();
|
||||
});
|
||||
});
|
||||
152
extensions/discord/src/subagent-hooks.ts
Normal file
152
extensions/discord/src/subagent-hooks.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 : [])];
|
||||
|
||||
373
src/agents/sessions-spawn-hooks.test.ts
Normal file
373
src/agents/sessions-spawn-hooks.test.ts
Normal file
@@ -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<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
];
|
||||
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<string, unknown> })
|
||||
.find(
|
||||
(request: { method?: string; params?: Record<string, unknown> }) =>
|
||||
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<string, unknown>,
|
||||
];
|
||||
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<string, unknown> })
|
||||
.find(
|
||||
(request: { method?: string; params?: Record<string, unknown> }) =>
|
||||
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<string, unknown> })
|
||||
.find(
|
||||
(request: { method?: string; params?: Record<string, unknown> }) =>
|
||||
request.method === "sessions.delete",
|
||||
);
|
||||
expect(deleteCall?.params).toMatchObject({
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> };
|
||||
type RequesterResolution = {
|
||||
requesterSessionKey: string;
|
||||
requesterOrigin?: Record<string, unknown>;
|
||||
} | 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<SubagentDeliveryTargetResult | undefined> =>
|
||||
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<unknown>,
|
||||
}));
|
||||
@@ -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<typeof import("../config/config.js")>();
|
||||
@@ -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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
// 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 () => {
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<unknown> }>({
|
||||
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<string> {
|
||||
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<SubagentAnnounceDeliveryResult> {
|
||||
// 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<boolean> {
|
||||
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 {
|
||||
|
||||
47
src/agents/subagent-lifecycle-events.ts
Normal file
47
src/agents/subagent-lifecycle-events.ts
Normal file
@@ -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;
|
||||
}
|
||||
67
src/agents/subagent-registry-cleanup.ts
Normal file
67
src/agents/subagent-registry-cleanup.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
79
src/agents/subagent-registry-completion.test.ts
Normal file
79
src/agents/subagent-registry-completion.test.ts
Normal file
@@ -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<string>(),
|
||||
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<string>(),
|
||||
persist,
|
||||
});
|
||||
|
||||
expect(emitted).toBe(true);
|
||||
expect(lifecycleMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
|
||||
expect(typeof entry.endedHookEmittedAt).toBe("number");
|
||||
expect(persist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
96
src/agents/subagent-registry-completion.ts
Normal file
96
src/agents/subagent-registry-completion.ts
Normal file
@@ -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<string>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
146
src/agents/subagent-registry-queries.ts
Normal file
146
src/agents/subagent-registry-queries.ts
Normal file
@@ -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<string, SubagentRunRecord>,
|
||||
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<string, SubagentRunRecord>,
|
||||
requesterSessionKey: string,
|
||||
): SubagentRunRecord[] {
|
||||
const key = requesterSessionKey.trim();
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
return [...runs.values()].filter((entry) => entry.requesterSessionKey === key);
|
||||
}
|
||||
|
||||
export function resolveRequesterForChildSessionFromRuns(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
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<string, SubagentRunRecord>,
|
||||
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<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
): number {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return 0;
|
||||
}
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([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<string, SubagentRunRecord>,
|
||||
rootSessionKey: string,
|
||||
): SubagentRunRecord[] {
|
||||
const root = rootSessionKey.trim();
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
const pending = [root];
|
||||
const visited = new Set<string>([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;
|
||||
}
|
||||
56
src/agents/subagent-registry-state.ts
Normal file
56
src/agents/subagent-registry-state.ts
Normal file
@@ -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<string, SubagentRunRecord>) {
|
||||
try {
|
||||
saveSubagentRegistryToDisk(runs);
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreSubagentRunsFromDisk(params: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
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<string, SubagentRunRecord>,
|
||||
): Map<string, SubagentRunRecord> {
|
||||
const merged = new Map<string, SubagentRunRecord>();
|
||||
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;
|
||||
}
|
||||
90
src/agents/subagent-registry.archive.test.ts
Normal file
90
src/agents/subagent-registry.archive.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>(() => undefined);
|
||||
}
|
||||
if (originalCallGateway) {
|
||||
return originalCallGateway(request as Parameters<typeof callGateway>[0]);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
try {
|
||||
let resolveAnnounce!: (value: boolean) => void;
|
||||
announceSpy.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((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<unknown>(() => undefined);
|
||||
}
|
||||
if (originalCallGateway) {
|
||||
return originalCallGateway(request as Parameters<typeof callGateway>[0]);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
try {
|
||||
let resolveAnnounce!: (value: boolean) => void;
|
||||
announceSpy.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, SubagentRunRecord> {
|
||||
requesterOrigin,
|
||||
cleanupCompletedAt,
|
||||
cleanupHandled,
|
||||
spawnMode: typed.spawnMode === "session" ? "session" : "run",
|
||||
});
|
||||
if (isLegacy) {
|
||||
migrated = true;
|
||||
|
||||
@@ -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<string, SubagentRunRecord>();
|
||||
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<string>();
|
||||
const endedHookInFlightRunIds = new Set<string>();
|
||||
|
||||
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<string, SubagentRunRecord> {
|
||||
const merged = new Map<string, SubagentRunRecord>();
|
||||
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<string, SubagentRunRecord>();
|
||||
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<string>([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<string>([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() {
|
||||
|
||||
35
src/agents/subagent-registry.types.ts
Normal file
35
src/agents/subagent-registry.types.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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<typeof getGlobalHookRunner>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
handleAbortTrigger,
|
||||
handleActivationCommand,
|
||||
handleRestartCommand,
|
||||
handleSessionCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
@@ -47,6 +48,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleUsageCommand,
|
||||
handleSessionCommand,
|
||||
handleRestartCommand,
|
||||
handleTtsCommands,
|
||||
handleHelpCommand,
|
||||
|
||||
147
src/auto-reply/reply/commands-session-ttl.test.ts
Normal file
147
src/auto-reply/reply/commands-session-ttl.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const getThreadBindingManagerMock = vi.fn();
|
||||
const setThreadBindingTtlBySessionKeyMock = vi.fn();
|
||||
return {
|
||||
getThreadBindingManagerMock,
|
||||
setThreadBindingTtlBySessionKeyMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
|
||||
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<string, unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<CommandHandler>[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<CommandHandler>[0]): string {
|
||||
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
|
||||
return accountId || "default";
|
||||
}
|
||||
|
||||
function resolveSessionCommandUsage() {
|
||||
return "Usage: /session ttl <duration|off> (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<typeof resolveAbortTarget>;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
@@ -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 || "<unknown>"}`,
|
||||
);
|
||||
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;
|
||||
|
||||
331
src/auto-reply/reply/commands-subagents-focus.test.ts
Normal file
331
src/auto-reply/reply/commands-subagents-focus.test.ts
Normal file
@@ -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<typeof import("../../discord/monitor/thread-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
|
||||
resolveThreadBindingThreadName: hoisted.resolveThreadBindingThreadNameMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
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<string, FakeBinding>(
|
||||
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<string, unknown>) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<CommandHandler>[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 <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"- /subagents steer <id|#> <message>",
|
||||
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
"",
|
||||
"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<string, Record<string, SessionEntry>>;
|
||||
|
||||
function loadSubagentSessionEntry(
|
||||
params: Parameters<CommandHandler>[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 || "<unknown>"}`,
|
||||
@@ -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 <id|#|all>"
|
||||
: "Usage: /kill <id|#|all>",
|
||||
},
|
||||
};
|
||||
}
|
||||
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 <id|#>" } };
|
||||
}
|
||||
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 <id|#> [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<unknown> }>({
|
||||
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 <id|#> <message>"
|
||||
: `Usage: ${handledPrefix} <id|#> <message>`
|
||||
: "Usage: /subagents send <id|#> <message>",
|
||||
},
|
||||
};
|
||||
}
|
||||
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<unknown> }>({
|
||||
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 <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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() } };
|
||||
};
|
||||
|
||||
55
src/auto-reply/reply/commands-subagents/action-agents.ts
Normal file
55
src/auto-reply/reply/commands-subagents/action-agents.ts
Normal file
@@ -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"));
|
||||
}
|
||||
90
src/auto-reply/reply/commands-subagents/action-focus.ts
Normal file
90
src/auto-reply/reply/commands-subagents/action-focus.ts
Normal file
@@ -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<CommandHandlerResult> {
|
||||
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 <subagent-label|session-key|session-id|session-label>");
|
||||
}
|
||||
|
||||
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}).`);
|
||||
}
|
||||
6
src/auto-reply/reply/commands-subagents/action-help.ts
Normal file
6
src/auto-reply/reply/commands-subagents/action-help.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { buildSubagentsHelp, stopWithText } from "./shared.js";
|
||||
|
||||
export function handleSubagentsHelpAction(): CommandHandlerResult {
|
||||
return stopWithText(buildSubagentsHelp());
|
||||
}
|
||||
59
src/auto-reply/reply/commands-subagents/action-info.ts
Normal file
59
src/auto-reply/reply/commands-subagents/action-info.ts
Normal file
@@ -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 <id|#>");
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
86
src/auto-reply/reply/commands-subagents/action-kill.ts
Normal file
86
src/auto-reply/reply/commands-subagents/action-kill.ts
Normal file
@@ -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<CommandHandlerResult> {
|
||||
const { params, handledPrefix, requesterKey, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText(
|
||||
handledPrefix === COMMAND ? "Usage: /subagents kill <id|#|all>" : "Usage: /kill <id|#|all>",
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
66
src/auto-reply/reply/commands-subagents/action-list.ts
Normal file
66
src/auto-reply/reply/commands-subagents/action-list.ts
Normal file
@@ -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"));
|
||||
}
|
||||
43
src/auto-reply/reply/commands-subagents/action-log.ts
Normal file
43
src/auto-reply/reply/commands-subagents/action-log.ts
Normal file
@@ -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<CommandHandlerResult> {
|
||||
const { runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText("📜 Usage: /subagents log <id|#> [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<unknown> }>({
|
||||
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"));
|
||||
}
|
||||
159
src/auto-reply/reply/commands-subagents/action-send.ts
Normal file
159
src/auto-reply/reply/commands-subagents/action-send.ts
Normal file
@@ -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<CommandHandlerResult> {
|
||||
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 <id|#> <message>"
|
||||
: `Usage: ${handledPrefix} <id|#> <message>`
|
||||
: "Usage: /subagents send <id|#> <message>",
|
||||
);
|
||||
}
|
||||
|
||||
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<unknown> }>({
|
||||
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)}).`,
|
||||
);
|
||||
}
|
||||
65
src/auto-reply/reply/commands-subagents/action-spawn.ts
Normal file
65
src/auto-reply/reply/commands-subagents/action-spawn.ts
Normal file
@@ -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<CommandHandlerResult> {
|
||||
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 <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
42
src/auto-reply/reply/commands-subagents/action-unfocus.ts
Normal file
42
src/auto-reply/reply/commands-subagents/action-unfocus.ts
Normal file
@@ -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.");
|
||||
}
|
||||
432
src/auto-reply/reply/commands-subagents/shared.ts
Normal file
432
src/auto-reply/reply/commands-subagents/shared.ts
Normal file
@@ -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<CommandHandler>[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<FocusTargetResolution | null> {
|
||||
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<Record<string, string>> = [];
|
||||
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 <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"- /subagents steer <id|#> <message>",
|
||||
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
"- /focus <subagent-label|session-key|session-id|session-label>",
|
||||
"- /unfocus",
|
||||
"- /agents",
|
||||
"- /session ttl <duration|off>",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
"",
|
||||
"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<string, Record<string, SessionEntry>>;
|
||||
|
||||
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] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof import("../../../discord/send.js")>();
|
||||
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<typeof import("../../../discord/monitor/thread-bindings.js")>();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -345,6 +345,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
"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.<id>.ui.components.accentColor.",
|
||||
"channels.discord.voice.enabled":
|
||||
|
||||
@@ -239,6 +239,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
50
src/config/sessions/session-file.ts
Normal file
50
src/config/sessions/session-file.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { resolveSessionFilePath } from "./paths.js";
|
||||
import { updateSessionStore } from "./store.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
export async function resolveAndPersistSessionFile(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
agentId?: string;
|
||||
sessionsDir?: string;
|
||||
fallbackSessionFile?: string;
|
||||
activeSessionKey?: string;
|
||||
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry }> {
|
||||
const { sessionId, sessionKey, sessionStore, storePath } = params;
|
||||
const baseEntry = params.sessionEntry ??
|
||||
sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() };
|
||||
const fallbackSessionFile = params.fallbackSessionFile?.trim();
|
||||
const entryForResolve =
|
||||
!baseEntry.sessionFile && fallbackSessionFile
|
||||
? { ...baseEntry, sessionFile: fallbackSessionFile }
|
||||
: baseEntry;
|
||||
const sessionFile = resolveSessionFilePath(sessionId, entryForResolve, {
|
||||
agentId: params.agentId,
|
||||
sessionsDir: params.sessionsDir,
|
||||
});
|
||||
const persistedEntry: SessionEntry = {
|
||||
...baseEntry,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
};
|
||||
if (baseEntry.sessionId !== sessionId || baseEntry.sessionFile !== sessionFile) {
|
||||
sessionStore[sessionKey] = persistedEntry;
|
||||
await updateSessionStore(
|
||||
storePath,
|
||||
(store) => {
|
||||
store[sessionKey] = {
|
||||
...store[sessionKey],
|
||||
...persistedEntry,
|
||||
};
|
||||
},
|
||||
params.activeSessionKey ? { activeSessionKey: params.activeSessionKey } : undefined,
|
||||
);
|
||||
return { sessionFile, sessionEntry: persistedEntry };
|
||||
}
|
||||
sessionStore[sessionKey] = persistedEntry;
|
||||
return { sessionFile, sessionEntry: persistedEntry };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
209
src/discord/monitor/message-handler.preflight.test.ts
Normal file
209
src/discord/monitor/message-handler.preflight.test.ts
Normal file
@@ -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<import("./thread-bindings.js").ThreadBindingRecord>,
|
||||
) {
|
||||
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<import("../../config/config.js").OpenClawConfig["channels"]>["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);
|
||||
});
|
||||
});
|
||||
@@ -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<DiscordMessagePreflightContext | null> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof resolveAgentRoute>;
|
||||
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<string, DiscordGuildEntryResolved>;
|
||||
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
|
||||
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
|
||||
threadBindings: ThreadBindingManager;
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
};
|
||||
|
||||
@@ -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<boolean>;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
|
||||
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import * as globalsModule from "../../globals.js";
|
||||
import * as timeoutModule from "../../utils/with-timeout.js";
|
||||
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
|
||||
import * as modelPickerModule from "./model-picker.js";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
createDiscordModelPickerFallbackButton,
|
||||
createDiscordModelPickerFallbackSelect,
|
||||
} from "./native-command.js";
|
||||
import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
@@ -70,6 +72,7 @@ function createModelPickerContext(): ModelPickerContext {
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +102,38 @@ function createInteraction(params?: { userId?: string; values?: string[] }): Moc
|
||||
};
|
||||
}
|
||||
|
||||
function createBoundThreadBindingManager(params: {
|
||||
accountId: string;
|
||||
threadId: string;
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
}): ThreadBindingManager {
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
getSessionTtlMs: () => 24 * 60 * 60 * 1000,
|
||||
getByThreadId: (threadId: string) =>
|
||||
threadId === params.threadId
|
||||
? {
|
||||
accountId: params.accountId,
|
||||
channelId: "parent-1",
|
||||
threadId: params.threadId,
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
agentId: params.agentId,
|
||||
boundBy: "system",
|
||||
boundAt: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
getBySessionKey: () => undefined,
|
||||
listBySessionKey: () => [],
|
||||
listBindings: () => [],
|
||||
bindTarget: async () => null,
|
||||
unbindThread: () => null,
|
||||
unbindBySessionKey: () => [],
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Discord model picker interactions", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -375,4 +410,78 @@ describe("Discord model picker interactions", () => {
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("verifies model state against the bound thread session", async () => {
|
||||
const context = createModelPickerContext();
|
||||
context.threadBindings = createBoundThreadBindingManager({
|
||||
accountId: "default",
|
||||
threadId: "thread-bound",
|
||||
targetSessionKey: "agent:worker:subagent:bound",
|
||||
agentId: "worker",
|
||||
});
|
||||
const pickerData = createModelsProviderData({
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
const modelCommand: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Switch model",
|
||||
textAliases: ["/model"],
|
||||
acceptsArgs: true,
|
||||
argsParsing: "none" as CommandArgsParsing,
|
||||
scope: "native",
|
||||
};
|
||||
|
||||
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
|
||||
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
|
||||
name === "model" ? modelCommand : undefined,
|
||||
);
|
||||
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
|
||||
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
|
||||
vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({} as never);
|
||||
const verboseSpy = vi.spyOn(globalsModule, "logVerbose").mockImplementation(() => {});
|
||||
|
||||
const select = createDiscordModelPickerFallbackSelect(context);
|
||||
const selectInteraction = createInteraction({
|
||||
userId: "owner",
|
||||
values: ["gpt-4o"],
|
||||
});
|
||||
selectInteraction.channel = {
|
||||
type: ChannelType.PublicThread,
|
||||
id: "thread-bound",
|
||||
};
|
||||
const selectData: PickerSelectData = {
|
||||
cmd: "model",
|
||||
act: "model",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
};
|
||||
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
submitInteraction.channel = {
|
||||
type: ChannelType.PublicThread,
|
||||
id: "thread-bound",
|
||||
};
|
||||
const submitData: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
mi: "2",
|
||||
};
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
|
||||
const mismatchLog = verboseSpy.mock.calls.find((call) =>
|
||||
String(call[0] ?? "").includes("model picker override mismatch"),
|
||||
)?.[0];
|
||||
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<OpenClawConfig["channels"]>["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<typeof loadConfig>;
|
||||
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<Button>[] } {
|
||||
const { command, menu, interaction } = params;
|
||||
const commandLabel = command.nativeName ?? command.key;
|
||||
@@ -1086,6 +1113,7 @@ function buildDiscordCommandArgMenu(params: {
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
sessionPrefix: params.sessionPrefix,
|
||||
threadBindings: params.threadBindings,
|
||||
}),
|
||||
);
|
||||
return new Row(buttons);
|
||||
@@ -1102,8 +1130,17 @@ export function createDiscordNativeCommand(params: {
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
ephemeralDefault: boolean;
|
||||
threadBindings: ThreadBindingManager;
|
||||
}): Command {
|
||||
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
|
||||
const {
|
||||
command,
|
||||
cfg,
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
threadBindings,
|
||||
} = params;
|
||||
const commandDefinition =
|
||||
findCommandByNativeName(command.name, "discord") ??
|
||||
({
|
||||
@@ -1164,6 +1201,7 @@ export function createDiscordNativeCommand(params: {
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
preferFollowUp: false,
|
||||
threadBindings,
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1179,6 +1217,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
preferFollowUp: boolean;
|
||||
threadBindings: ThreadBindingManager;
|
||||
suppressReplies?: boolean;
|
||||
}) {
|
||||
const {
|
||||
@@ -1191,6 +1230,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
preferFollowUp,
|
||||
threadBindings,
|
||||
suppressReplies,
|
||||
} = params;
|
||||
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||
@@ -1391,6 +1431,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
});
|
||||
if (preferFollowUp) {
|
||||
await safeDiscordInteractionCall("interaction follow-up", () =>
|
||||
@@ -1423,6 +1464,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
command: pickerCommandContext,
|
||||
userId: user.id,
|
||||
accountId,
|
||||
threadBindings,
|
||||
preferFollowUp,
|
||||
});
|
||||
return;
|
||||
@@ -1443,6 +1485,16 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
},
|
||||
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
||||
});
|
||||
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
|
||||
const effectiveRoute = boundSessionKey
|
||||
? {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId ?? route.agentId,
|
||||
}
|
||||
: route;
|
||||
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
|
||||
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
||||
channelConfig,
|
||||
@@ -1461,9 +1513,9 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
? `discord:group:${channelId}`
|
||||
: `discord:channel:${channelId}`,
|
||||
To: `slash:${user.id}`,
|
||||
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`,
|
||||
CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey,
|
||||
AccountId: effectiveRoute.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGuild ? interaction.guild?.name : undefined,
|
||||
@@ -1496,6 +1548,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: true,
|
||||
MessageSid: interactionId,
|
||||
MessageThreadId: isThreadChannel ? channelId : undefined,
|
||||
Timestamp: Date.now(),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
@@ -1508,11 +1561,11 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
agentId: effectiveRoute.agentId,
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
accountId: effectiveRoute.accountId,
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||
|
||||
let didReply = false;
|
||||
await dispatchReplyWithDispatcher({
|
||||
@@ -1520,7 +1573,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId),
|
||||
deliver: async (payload) => {
|
||||
if (suppressReplies) {
|
||||
return;
|
||||
|
||||
207
src/discord/monitor/provider.allowlist.ts
Normal file
207
src/discord/monitor/provider.allowlist.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
mergeAllowlist,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
resolveAllowlistIdAdditions,
|
||||
summarizeMapping,
|
||||
} from "../../channels/allowlists/resolve-utils.js";
|
||||
import type { DiscordGuildEntry } from "../../config/types.discord.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
|
||||
type GuildEntries = Record<string, DiscordGuildEntry>;
|
||||
|
||||
function toGuildEntries(value: unknown): GuildEntries {
|
||||
if (!value || typeof value !== "object") {
|
||||
return {};
|
||||
}
|
||||
const out: GuildEntries = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
out[key] = entry as DiscordGuildEntry;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toAllowlistEntries(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value.map((entry) => String(entry).trim()).filter((entry) => Boolean(entry));
|
||||
}
|
||||
|
||||
export async function resolveDiscordAllowlistConfig(params: {
|
||||
token: string;
|
||||
guildEntries: unknown;
|
||||
allowFrom: unknown;
|
||||
fetcher: typeof fetch;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<{ guildEntries: GuildEntries | undefined; allowFrom: string[] | undefined }> {
|
||||
let guildEntries = toGuildEntries(params.guildEntries);
|
||||
let allowFrom = toAllowlistEntries(params.allowFrom);
|
||||
|
||||
if (Object.keys(guildEntries).length > 0) {
|
||||
try {
|
||||
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
|
||||
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
|
||||
if (guildKey === "*") {
|
||||
continue;
|
||||
}
|
||||
const channels = guildCfg?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
|
||||
if (channelKeys.length === 0) {
|
||||
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
|
||||
entries.push({ input, guildKey });
|
||||
continue;
|
||||
}
|
||||
for (const channelKey of channelKeys) {
|
||||
entries.push({
|
||||
input: `${guildKey}/${channelKey}`,
|
||||
guildKey,
|
||||
channelKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveDiscordChannelAllowlist({
|
||||
token: params.token,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
fetcher: params.fetcher,
|
||||
});
|
||||
const nextGuilds = { ...guildEntries };
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
const source = entries.find((item) => item.input === entry.input);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const sourceGuild = guildEntries[source.guildKey] ?? {};
|
||||
if (!entry.resolved || !entry.guildId) {
|
||||
unresolved.push(entry.input);
|
||||
continue;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.guildId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.guildId}`,
|
||||
);
|
||||
const existing = nextGuilds[entry.guildId] ?? {};
|
||||
const mergedChannels = {
|
||||
...sourceGuild.channels,
|
||||
...existing.channels,
|
||||
};
|
||||
const mergedGuild: DiscordGuildEntry = {
|
||||
...sourceGuild,
|
||||
...existing,
|
||||
channels: mergedChannels,
|
||||
};
|
||||
nextGuilds[entry.guildId] = mergedGuild;
|
||||
|
||||
if (source.channelKey && entry.channelId) {
|
||||
const sourceChannel = sourceGuild.channels?.[source.channelKey];
|
||||
if (sourceChannel) {
|
||||
nextGuilds[entry.guildId] = {
|
||||
...mergedGuild,
|
||||
channels: {
|
||||
...mergedChannels,
|
||||
[entry.channelId]: {
|
||||
...sourceChannel,
|
||||
...mergedChannels[entry.channelId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channels", mapping, unresolved, params.runtime);
|
||||
}
|
||||
} catch (err) {
|
||||
params.runtime.log?.(
|
||||
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowEntries =
|
||||
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token: params.token,
|
||||
entries: allowEntries.map((entry) => String(entry)),
|
||||
fetcher: params.fetcher,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
summarizeMapping("discord users", mapping, unresolved, params.runtime);
|
||||
} catch (err) {
|
||||
params.runtime.log?.(
|
||||
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(guildEntries).length > 0) {
|
||||
const userEntries = new Set<string>();
|
||||
for (const guild of Object.values(guildEntries)) {
|
||||
if (!guild || typeof guild !== "object") {
|
||||
continue;
|
||||
}
|
||||
addAllowlistUserEntriesFromConfigEntry(userEntries, guild);
|
||||
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
for (const channel of Object.values(channels)) {
|
||||
addAllowlistUserEntriesFromConfigEntry(userEntries, channel);
|
||||
}
|
||||
}
|
||||
|
||||
if (userEntries.size > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token: params.token,
|
||||
entries: Array.from(userEntries),
|
||||
fetcher: params.fetcher,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
|
||||
const nextGuilds = { ...guildEntries };
|
||||
for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
|
||||
if (!guildConfig || typeof guildConfig !== "object") {
|
||||
continue;
|
||||
}
|
||||
const nextGuild = { ...guildConfig } as Record<string, unknown>;
|
||||
const users = (guildConfig as { users?: string[] }).users;
|
||||
if (Array.isArray(users) && users.length > 0) {
|
||||
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
|
||||
nextGuild.users = mergeAllowlist({ existing: users, additions });
|
||||
}
|
||||
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
if (channels && typeof channels === "object") {
|
||||
nextGuild.channels = patchAllowlistUsersInConfigEntries({
|
||||
entries: channels,
|
||||
resolvedMap,
|
||||
});
|
||||
}
|
||||
nextGuilds[guildKey] = nextGuild as DiscordGuildEntry;
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channel users", mapping, unresolved, params.runtime);
|
||||
} catch (err) {
|
||||
params.runtime.log?.(
|
||||
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
guildEntries: Object.keys(guildEntries).length > 0 ? guildEntries : undefined,
|
||||
allowFrom,
|
||||
};
|
||||
}
|
||||
106
src/discord/monitor/provider.lifecycle.test.ts
Normal file
106
src/discord/monitor/provider.lifecycle.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
const {
|
||||
attachDiscordGatewayLoggingMock,
|
||||
getDiscordGatewayEmitterMock,
|
||||
registerGatewayMock,
|
||||
stopGatewayLoggingMock,
|
||||
unregisterGatewayMock,
|
||||
waitForDiscordGatewayStopMock,
|
||||
} = vi.hoisted(() => {
|
||||
const stopGatewayLoggingMock = vi.fn();
|
||||
return {
|
||||
attachDiscordGatewayLoggingMock: vi.fn(() => stopGatewayLoggingMock),
|
||||
getDiscordGatewayEmitterMock: vi.fn(() => undefined),
|
||||
waitForDiscordGatewayStopMock: vi.fn(() => Promise.resolve()),
|
||||
registerGatewayMock: vi.fn(),
|
||||
unregisterGatewayMock: vi.fn(),
|
||||
stopGatewayLoggingMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../gateway-logging.js", () => ({
|
||||
attachDiscordGatewayLogging: attachDiscordGatewayLoggingMock,
|
||||
}));
|
||||
|
||||
vi.mock("../monitor.gateway.js", () => ({
|
||||
getDiscordGatewayEmitter: getDiscordGatewayEmitterMock,
|
||||
waitForDiscordGatewayStop: waitForDiscordGatewayStopMock,
|
||||
}));
|
||||
|
||||
vi.mock("./gateway-registry.js", () => ({
|
||||
registerGateway: registerGatewayMock,
|
||||
unregisterGateway: unregisterGatewayMock,
|
||||
}));
|
||||
|
||||
describe("runDiscordGatewayLifecycle", () => {
|
||||
beforeEach(() => {
|
||||
attachDiscordGatewayLoggingMock.mockClear();
|
||||
getDiscordGatewayEmitterMock.mockClear();
|
||||
waitForDiscordGatewayStopMock.mockClear();
|
||||
registerGatewayMock.mockClear();
|
||||
unregisterGatewayMock.mockClear();
|
||||
stopGatewayLoggingMock.mockClear();
|
||||
});
|
||||
|
||||
it("cleans up thread bindings when exec approvals startup fails", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
|
||||
const start = vi.fn(async () => {
|
||||
throw new Error("startup failed");
|
||||
});
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const threadStop = vi.fn();
|
||||
|
||||
await expect(
|
||||
runDiscordGatewayLifecycle({
|
||||
accountId: "default",
|
||||
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
|
||||
runtime: {} as RuntimeEnv,
|
||||
isDisallowedIntentsError: () => false,
|
||||
voiceManager: null,
|
||||
voiceManagerRef: { current: null },
|
||||
execApprovalsHandler: { start, stop },
|
||||
threadBindings: { stop: threadStop },
|
||||
}),
|
||||
).rejects.toThrow("startup failed");
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled();
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up when gateway wait fails after startup", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
|
||||
|
||||
const start = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const threadStop = vi.fn();
|
||||
|
||||
await expect(
|
||||
runDiscordGatewayLifecycle({
|
||||
accountId: "default",
|
||||
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
|
||||
runtime: {} as RuntimeEnv,
|
||||
isDisallowedIntentsError: () => false,
|
||||
voiceManager: null,
|
||||
voiceManagerRef: { current: null },
|
||||
execApprovalsHandler: { start, stop },
|
||||
threadBindings: { stop: threadStop },
|
||||
}),
|
||||
).rejects.toThrow("gateway wait failed");
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
132
src/discord/monitor/provider.lifecycle.ts
Normal file
132
src/discord/monitor/provider.lifecycle.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { danger } from "../../globals.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
|
||||
import type { DiscordVoiceManager } from "../voice/manager.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
|
||||
type ExecApprovalsHandler = {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function runDiscordGatewayLifecycle(params: {
|
||||
accountId: string;
|
||||
client: Client;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
isDisallowedIntentsError: (err: unknown) => boolean;
|
||||
voiceManager: DiscordVoiceManager | null;
|
||||
voiceManagerRef: { current: DiscordVoiceManager | null };
|
||||
execApprovalsHandler: ExecApprovalsHandler | null;
|
||||
threadBindings: { stop: () => void };
|
||||
}) {
|
||||
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (gateway) {
|
||||
registerGateway(params.accountId, gateway);
|
||||
}
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
gatewayEmitter?.once("error", () => {});
|
||||
gateway.options.reconnect = { maxAttempts: 0 };
|
||||
gateway.disconnect();
|
||||
};
|
||||
|
||||
if (params.abortSignal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const onGatewayDebug = (msg: unknown) => {
|
||||
const message = String(msg);
|
||||
if (!message.includes("WebSocket connection opened")) {
|
||||
return;
|
||||
}
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
helloTimeoutId = setTimeout(() => {
|
||||
if (!gateway?.isConnected) {
|
||||
params.runtime.log?.(
|
||||
danger(
|
||||
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
|
||||
),
|
||||
);
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
}
|
||||
helloTimeoutId = undefined;
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
|
||||
let sawDisallowedIntents = false;
|
||||
try {
|
||||
if (params.execApprovalsHandler) {
|
||||
await params.execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal: params.abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
sawDisallowedIntents = true;
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error") ||
|
||||
params.isDisallowedIntentsError(err)
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
unregisterGateway(params.accountId);
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (params.voiceManager) {
|
||||
await params.voiceManager.destroy();
|
||||
params.voiceManagerRef.current = null;
|
||||
}
|
||||
if (params.execApprovalsHandler) {
|
||||
await params.execApprovalsHandler.stop();
|
||||
}
|
||||
params.threadBindings.stop();
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,38 @@ describe("dedupeSkillCommandsForDiscord", () => {
|
||||
expect(output[0]?.name).toBe("ClawHub");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveThreadBindingsEnabled", () => {
|
||||
it("defaults to enabled when unset", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: undefined,
|
||||
sessionEnabledRaw: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses global session default when channel value is unset", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: undefined,
|
||||
sessionEnabledRaw: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses channel value to override global session default", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: true,
|
||||
sessionEnabledRaw: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: false,
|
||||
sessionEnabledRaw: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
293
src/discord/monitor/provider.test.ts
Normal file
293
src/discord/monitor/provider.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
const {
|
||||
createDiscordNativeCommandMock,
|
||||
createNoopThreadBindingManagerMock,
|
||||
createThreadBindingManagerMock,
|
||||
createdBindingManagers,
|
||||
listNativeCommandSpecsForConfigMock,
|
||||
listSkillCommandsForAgentsMock,
|
||||
monitorLifecycleMock,
|
||||
resolveDiscordAccountMock,
|
||||
resolveDiscordAllowlistConfigMock,
|
||||
resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabledMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
||||
return {
|
||||
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
|
||||
createNoopThreadBindingManagerMock: vi.fn(() => {
|
||||
const manager = { stop: vi.fn() };
|
||||
createdBindingManagers.push(manager);
|
||||
return manager;
|
||||
}),
|
||||
createThreadBindingManagerMock: vi.fn(() => {
|
||||
const manager = { stop: vi.fn() };
|
||||
createdBindingManagers.push(manager);
|
||||
return manager;
|
||||
}),
|
||||
createdBindingManagers,
|
||||
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
|
||||
listSkillCommandsForAgentsMock: vi.fn(() => []),
|
||||
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
|
||||
params.threadBindings.stop();
|
||||
}),
|
||||
resolveDiscordAccountMock: vi.fn(() => ({
|
||||
accountId: "default",
|
||||
token: "cfg-token",
|
||||
config: {
|
||||
commands: { native: true, nativeSkills: false },
|
||||
voice: { enabled: false },
|
||||
agentComponents: { enabled: false },
|
||||
execApprovals: { enabled: false },
|
||||
},
|
||||
})),
|
||||
resolveDiscordAllowlistConfigMock: vi.fn(async () => ({
|
||||
guildEntries: undefined,
|
||||
allowFrom: undefined,
|
||||
})),
|
||||
resolveNativeCommandsEnabledMock: vi.fn(() => true),
|
||||
resolveNativeSkillsEnabledMock: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@buape/carbon", () => {
|
||||
class ReadyListener {}
|
||||
class Client {
|
||||
listeners: unknown[];
|
||||
rest: { put: ReturnType<typeof vi.fn> };
|
||||
constructor(_options: unknown, handlers: { listeners?: unknown[] }) {
|
||||
this.listeners = handlers.listeners ?? [];
|
||||
this.rest = { put: vi.fn(async () => undefined) };
|
||||
}
|
||||
async handleDeployRequest() {
|
||||
return undefined;
|
||||
}
|
||||
async fetchUser(_target: string) {
|
||||
return { id: "bot-1" };
|
||||
}
|
||||
getPlugin(_name: string) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return { Client, ReadyListener };
|
||||
});
|
||||
|
||||
vi.mock("@buape/carbon/gateway", () => ({
|
||||
GatewayCloseCodes: { DisallowedIntents: 4014 },
|
||||
}));
|
||||
|
||||
vi.mock("@buape/carbon/voice", () => ({
|
||||
VoicePlugin: class VoicePlugin {},
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/chunk.js", () => ({
|
||||
resolveTextChunkLimit: () => 2000,
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/commands-registry.js", () => ({
|
||||
listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/skill-commands.js", () => ({
|
||||
listSkillCommandsForAgents: listSkillCommandsForAgentsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/commands.js", () => ({
|
||||
isNativeCommandsExplicitlyDisabled: () => false,
|
||||
resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../globals.js", () => ({
|
||||
danger: (v: string) => v,
|
||||
logVerbose: vi.fn(),
|
||||
shouldLogVerbose: () => false,
|
||||
warn: (v: string) => v,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/errors.js", () => ({
|
||||
formatErrorMessage: (err: unknown) => String(err),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/retry-policy.js", () => ({
|
||||
createDiscordRetryRunner: () => async (run: () => Promise<unknown>) => run(),
|
||||
}));
|
||||
|
||||
vi.mock("../../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../accounts.js", () => ({
|
||||
resolveDiscordAccount: resolveDiscordAccountMock,
|
||||
}));
|
||||
|
||||
vi.mock("../probe.js", () => ({
|
||||
fetchDiscordApplicationId: async () => "app-1",
|
||||
}));
|
||||
|
||||
vi.mock("../token.js", () => ({
|
||||
normalizeDiscordToken: (value?: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../voice/command.js", () => ({
|
||||
createDiscordVoiceCommand: () => ({ name: "voice-command" }),
|
||||
}));
|
||||
|
||||
vi.mock("../voice/manager.js", () => ({
|
||||
DiscordVoiceManager: class DiscordVoiceManager {},
|
||||
DiscordVoiceReadyListener: class DiscordVoiceReadyListener {},
|
||||
}));
|
||||
|
||||
vi.mock("./agent-components.js", () => ({
|
||||
createAgentComponentButton: () => ({ id: "btn" }),
|
||||
createAgentSelectMenu: () => ({ id: "menu" }),
|
||||
createDiscordComponentButton: () => ({ id: "btn2" }),
|
||||
createDiscordComponentChannelSelect: () => ({ id: "channel" }),
|
||||
createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }),
|
||||
createDiscordComponentModal: () => ({ id: "modal" }),
|
||||
createDiscordComponentRoleSelect: () => ({ id: "role" }),
|
||||
createDiscordComponentStringSelect: () => ({ id: "string" }),
|
||||
createDiscordComponentUserSelect: () => ({ id: "user" }),
|
||||
}));
|
||||
|
||||
vi.mock("./commands.js", () => ({
|
||||
resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./exec-approvals.js", () => ({
|
||||
createExecApprovalButton: () => ({ id: "exec-approval" }),
|
||||
DiscordExecApprovalHandler: class DiscordExecApprovalHandler {
|
||||
async start() {
|
||||
return undefined;
|
||||
}
|
||||
async stop() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }),
|
||||
}));
|
||||
|
||||
vi.mock("./listeners.js", () => ({
|
||||
DiscordMessageListener: class DiscordMessageListener {},
|
||||
DiscordPresenceListener: class DiscordPresenceListener {},
|
||||
DiscordReactionListener: class DiscordReactionListener {},
|
||||
DiscordReactionRemoveListener: class DiscordReactionRemoveListener {},
|
||||
registerDiscordListener: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./message-handler.js", () => ({
|
||||
createDiscordMessageHandler: () => ({ handle: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./native-command.js", () => ({
|
||||
createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }),
|
||||
createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }),
|
||||
createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }),
|
||||
createDiscordNativeCommand: createDiscordNativeCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("./presence.js", () => ({
|
||||
resolveDiscordPresenceUpdate: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./provider.allowlist.js", () => ({
|
||||
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./provider.lifecycle.js", () => ({
|
||||
runDiscordGatewayLifecycle: monitorLifecycleMock,
|
||||
}));
|
||||
|
||||
vi.mock("./rest-fetch.js", () => ({
|
||||
resolveDiscordRestFetch: () => async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./thread-bindings.js", () => ({
|
||||
createNoopThreadBindingManager: createNoopThreadBindingManagerMock,
|
||||
createThreadBindingManager: createThreadBindingManagerMock,
|
||||
}));
|
||||
|
||||
describe("monitorDiscordProvider", () => {
|
||||
const baseRuntime = (): RuntimeEnv => {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const baseConfig = (): OpenClawConfig =>
|
||||
({
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
createDiscordNativeCommandMock.mockReset().mockReturnValue({ name: "mock-command" });
|
||||
createNoopThreadBindingManagerMock.mockClear();
|
||||
createThreadBindingManagerMock.mockClear();
|
||||
createdBindingManagers.length = 0;
|
||||
listNativeCommandSpecsForConfigMock.mockReset().mockReturnValue([{ name: "cmd" }]);
|
||||
listSkillCommandsForAgentsMock.mockReset().mockReturnValue([]);
|
||||
monitorLifecycleMock.mockReset().mockImplementation(async (params) => {
|
||||
params.threadBindings.stop();
|
||||
});
|
||||
resolveDiscordAccountMock.mockClear();
|
||||
resolveDiscordAllowlistConfigMock.mockReset().mockResolvedValue({
|
||||
guildEntries: undefined,
|
||||
allowFrom: undefined,
|
||||
});
|
||||
resolveNativeCommandsEnabledMock.mockReset().mockReturnValue(true);
|
||||
resolveNativeSkillsEnabledMock.mockReset().mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("stops thread bindings when startup fails before lifecycle begins", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
createDiscordNativeCommandMock.mockImplementation(() => {
|
||||
throw new Error("native command boom");
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime: baseRuntime(),
|
||||
}),
|
||||
).rejects.toThrow("native command boom");
|
||||
|
||||
expect(monitorLifecycleMock).not.toHaveBeenCalled();
|
||||
expect(createdBindingManagers).toHaveLength(1);
|
||||
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not double-stop thread bindings when lifecycle performs cleanup", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime: baseRuntime(),
|
||||
});
|
||||
|
||||
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
|
||||
expect(createdBindingManagers).toHaveLength(1);
|
||||
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,6 @@ import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||
import {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
mergeAllowlist,
|
||||
resolveAllowlistIdAdditions,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../../channels/allowlists/resolve-utils.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
@@ -35,11 +27,7 @@ import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
|
||||
import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js";
|
||||
@@ -57,7 +45,6 @@ import {
|
||||
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
@@ -73,7 +60,10 @@ import {
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { resolveDiscordPresenceUpdate } from "./presence.js";
|
||||
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
||||
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
|
||||
import { resolveDiscordRestFetch } from "./rest-fetch.js";
|
||||
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
@@ -105,6 +95,61 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
const DEFAULT_THREAD_BINDING_TTL_HOURS = 24;
|
||||
|
||||
function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
if (raw < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingSessionTtlMs(params: {
|
||||
channelTtlHoursRaw: unknown;
|
||||
sessionTtlHoursRaw: unknown;
|
||||
}): number {
|
||||
const ttlHours =
|
||||
normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ??
|
||||
normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ??
|
||||
DEFAULT_THREAD_BINDING_TTL_HOURS;
|
||||
return Math.floor(ttlHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined {
|
||||
if (typeof raw !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingsEnabled(params: {
|
||||
channelEnabledRaw: unknown;
|
||||
sessionEnabledRaw: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
normalizeThreadBindingsEnabled(params.channelEnabledRaw) ??
|
||||
normalizeThreadBindingsEnabled(params.sessionEnabledRaw) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "off";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
function dedupeSkillCommandsForDiscord(
|
||||
skillCommands: ReturnType<typeof listSkillCommandsForAgents>,
|
||||
) {
|
||||
@@ -201,6 +246,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
|
||||
const discordCfg = account.config;
|
||||
const discordRootThreadBindings = cfg.channels?.discord?.threadBindings;
|
||||
const discordAccountThreadBindings =
|
||||
cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
|
||||
const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime);
|
||||
const dmConfig = discordCfg.dm;
|
||||
let guildEntries = discordCfg.guilds;
|
||||
@@ -230,6 +278,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing";
|
||||
const threadBindingSessionTtlMs = resolveThreadBindingSessionTtlMs({
|
||||
channelTtlHoursRaw:
|
||||
discordAccountThreadBindings?.ttlHours ?? discordRootThreadBindings?.ttlHours,
|
||||
sessionTtlHoursRaw: cfg.session?.threadBindings?.ttlHours,
|
||||
});
|
||||
const threadBindingsEnabled = resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled,
|
||||
sessionEnabledRaw: cfg.session?.threadBindings?.enabled,
|
||||
});
|
||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||
const groupDmChannels = dmConfig?.groupChannels;
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
@@ -252,159 +309,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const ephemeralDefault = slashCommand.ephemeral;
|
||||
const voiceEnabled = discordCfg.voice?.enabled !== false;
|
||||
|
||||
if (token) {
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0) {
|
||||
try {
|
||||
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
|
||||
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
|
||||
if (guildKey === "*") {
|
||||
continue;
|
||||
}
|
||||
const channels = guildCfg?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
|
||||
if (channelKeys.length === 0) {
|
||||
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
|
||||
entries.push({ input, guildKey });
|
||||
continue;
|
||||
}
|
||||
for (const channelKey of channelKeys) {
|
||||
entries.push({
|
||||
input: `${guildKey}/${channelKey}`,
|
||||
guildKey,
|
||||
channelKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveDiscordChannelAllowlist({
|
||||
token,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const nextGuilds = { ...guildEntries };
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
const source = entries.find((item) => item.input === entry.input);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const sourceGuild = guildEntries?.[source.guildKey] ?? {};
|
||||
if (!entry.resolved || !entry.guildId) {
|
||||
unresolved.push(entry.input);
|
||||
continue;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.guildId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.guildId}`,
|
||||
);
|
||||
const existing = nextGuilds[entry.guildId] ?? {};
|
||||
const mergedChannels = { ...sourceGuild.channels, ...existing.channels };
|
||||
const mergedGuild = { ...sourceGuild, ...existing, channels: mergedChannels };
|
||||
nextGuilds[entry.guildId] = mergedGuild;
|
||||
if (source.channelKey && entry.channelId) {
|
||||
const sourceChannel = sourceGuild.channels?.[source.channelKey];
|
||||
if (sourceChannel) {
|
||||
nextGuilds[entry.guildId] = {
|
||||
...mergedGuild,
|
||||
channels: {
|
||||
...mergedChannels,
|
||||
[entry.channelId]: {
|
||||
...sourceChannel,
|
||||
...mergedChannels?.[entry.channelId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channels", mapping, unresolved, runtime);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowEntries =
|
||||
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: allowEntries.map((entry) => String(entry)),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
summarizeMapping("discord users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0) {
|
||||
const userEntries = new Set<string>();
|
||||
for (const guild of Object.values(guildEntries)) {
|
||||
if (!guild || typeof guild !== "object") {
|
||||
continue;
|
||||
}
|
||||
addAllowlistUserEntriesFromConfigEntry(userEntries, guild);
|
||||
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
for (const channel of Object.values(channels)) {
|
||||
addAllowlistUserEntriesFromConfigEntry(userEntries, channel);
|
||||
}
|
||||
}
|
||||
|
||||
if (userEntries.size > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: Array.from(userEntries),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } =
|
||||
buildAllowlistResolutionSummary(resolvedUsers);
|
||||
|
||||
const nextGuilds = { ...guildEntries };
|
||||
for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
|
||||
if (!guildConfig || typeof guildConfig !== "object") {
|
||||
continue;
|
||||
}
|
||||
const nextGuild = { ...guildConfig } as Record<string, unknown>;
|
||||
const users = (guildConfig as { users?: string[] }).users;
|
||||
if (Array.isArray(users) && users.length > 0) {
|
||||
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
|
||||
nextGuild.users = mergeAllowlist({ existing: users, additions });
|
||||
}
|
||||
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
if (channels && typeof channels === "object") {
|
||||
nextGuild.channels = patchAllowlistUsersInConfigEntries({
|
||||
entries: channels,
|
||||
resolvedMap,
|
||||
});
|
||||
}
|
||||
nextGuilds[guildKey] = nextGuild;
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channel users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const allowlistResolved = await resolveDiscordAllowlistConfig({
|
||||
token,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
fetcher: discordRestFetch,
|
||||
runtime,
|
||||
});
|
||||
guildEntries = allowlistResolved.guildEntries;
|
||||
allowFrom = allowlistResolved.allowFrom;
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
|
||||
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadSessionTtl=${formatThreadBindingSessionTtlLabel(threadBindingSessionTtlMs)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,326 +356,250 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
);
|
||||
}
|
||||
const voiceManagerRef: { current: DiscordVoiceManager | null } = { current: null };
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
if (nativeEnabled && voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
const threadBindings = threadBindingsEnabled
|
||||
? createThreadBindingManager({
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
sessionTtlMs: threadBindingSessionTtlMs,
|
||||
})
|
||||
: createNoopThreadBindingManager(account.accountId);
|
||||
let lifecycleStarted = false;
|
||||
try {
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
getManager: () => voiceManagerRef.current,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
threadBindings,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (nativeEnabled && voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
getManager: () => voiceManagerRef.current,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
cfg,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const agentComponentsConfig = discordCfg.agentComponents ?? {};
|
||||
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const agentComponentsConfig = discordCfg.agentComponents ?? {};
|
||||
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
async handle(_data: unknown, client: Client) {
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
const presence = resolveDiscordPresenceUpdate(discordCfg);
|
||||
if (!presence) {
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.updatePresence(presence);
|
||||
token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
}
|
||||
|
||||
const clientPlugins: Plugin[] = [
|
||||
createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }),
|
||||
];
|
||||
if (voiceEnabled) {
|
||||
clientPlugins.push(new VoicePlugin());
|
||||
}
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
deploySecret: "a",
|
||||
clientId: applicationId,
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: false,
|
||||
},
|
||||
{
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
let voiceManager: DiscordVoiceManager | null = null;
|
||||
|
||||
if (nativeDisabledExplicit) {
|
||||
await clearDiscordNativeCommands({
|
||||
client,
|
||||
applicationId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const botUser = await client.fetchUser("@me");
|
||||
botUserId = botUser?.id;
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
|
||||
}
|
||||
|
||||
if (voiceEnabled) {
|
||||
voiceManager = new DiscordVoiceManager({
|
||||
client,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
});
|
||||
voiceManagerRef.current = voiceManager;
|
||||
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
|
||||
}
|
||||
|
||||
const messageHandler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildHistories,
|
||||
historyLimit,
|
||||
mediaMaxBytes,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
dmEnabled,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
allowFrom,
|
||||
guildEntries,
|
||||
});
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordPresenceListener({ logger, accountId: account.accountId }),
|
||||
);
|
||||
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
|
||||
}
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (gateway) {
|
||||
registerGateway(account.accountId, gateway);
|
||||
}
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
runtime,
|
||||
});
|
||||
const abortSignal = opts.abortSignal;
|
||||
const onAbort = () => {
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
// Carbon emits an error when maxAttempts is 0; keep a one-shot listener to avoid
|
||||
// an unhandled error after we tear down listeners during abort.
|
||||
gatewayEmitter?.once("error", () => {});
|
||||
gateway.options.reconnect = { maxAttempts: 0 };
|
||||
gateway.disconnect();
|
||||
};
|
||||
if (abortSignal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Timeout to detect zombie connections where HELLO is never received.
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const onGatewayDebug = (msg: unknown) => {
|
||||
const message = String(msg);
|
||||
if (!message.includes("WebSocket connection opened")) {
|
||||
return;
|
||||
}
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
helloTimeoutId = setTimeout(() => {
|
||||
if (!gateway?.isConnected) {
|
||||
runtime.log?.(
|
||||
danger(
|
||||
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
|
||||
),
|
||||
);
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
}
|
||||
helloTimeoutId = undefined;
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
// Disallowed intents (4014) should stop the provider without crashing the gateway.
|
||||
let sawDisallowedIntents = false;
|
||||
try {
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
if (isDiscordDisallowedIntentsError(err)) {
|
||||
sawDisallowedIntents = true;
|
||||
runtime.error?.(
|
||||
danger(
|
||||
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
|
||||
),
|
||||
);
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
async handle(_data: unknown, client: Client) {
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
|
||||
const presence = resolveDiscordPresenceUpdate(discordCfg);
|
||||
if (!presence) {
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.updatePresence(presence);
|
||||
}
|
||||
}
|
||||
|
||||
const clientPlugins: Plugin[] = [
|
||||
createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }),
|
||||
];
|
||||
if (voiceEnabled) {
|
||||
clientPlugins.push(new VoicePlugin());
|
||||
}
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
deploySecret: "a",
|
||||
clientId: applicationId,
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: false,
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error") ||
|
||||
isDiscordDisallowedIntentsError(err)
|
||||
);
|
||||
{
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
let voiceManager: DiscordVoiceManager | null = null;
|
||||
|
||||
if (nativeDisabledExplicit) {
|
||||
await clearDiscordNativeCommands({
|
||||
client,
|
||||
applicationId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const botUser = await client.fetchUser("@me");
|
||||
botUserId = botUser?.id;
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
|
||||
}
|
||||
|
||||
if (voiceEnabled) {
|
||||
voiceManager = new DiscordVoiceManager({
|
||||
client,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
});
|
||||
voiceManagerRef.current = voiceManager;
|
||||
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
|
||||
}
|
||||
|
||||
const messageHandler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildHistories,
|
||||
historyLimit,
|
||||
mediaMaxBytes,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
dmEnabled,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
allowFrom,
|
||||
guildEntries,
|
||||
threadBindings,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!sawDisallowedIntents && !isDiscordDisallowedIntentsError(err)) {
|
||||
throw err;
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordPresenceListener({ logger, accountId: account.accountId }),
|
||||
);
|
||||
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
|
||||
}
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
lifecycleStarted = true;
|
||||
await runDiscordGatewayLifecycle({
|
||||
accountId: account.accountId,
|
||||
client,
|
||||
runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
|
||||
voiceManager,
|
||||
voiceManagerRef,
|
||||
execApprovalsHandler,
|
||||
threadBindings,
|
||||
});
|
||||
} finally {
|
||||
unregisterGateway(account.accountId);
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (voiceManager) {
|
||||
await voiceManager.destroy();
|
||||
voiceManagerRef.current = null;
|
||||
}
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.stop();
|
||||
if (!lifecycleStarted) {
|
||||
threadBindings.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -782,4 +623,5 @@ export const __testing = {
|
||||
createDiscordGatewayPlugin,
|
||||
dedupeSkillCommandsForDiscord,
|
||||
resolveDiscordRestFetch,
|
||||
resolveThreadBindingsEnabled,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
|
||||
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args),
|
||||
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args),
|
||||
}));
|
||||
|
||||
describe("deliverDiscordReply", () => {
|
||||
@@ -22,6 +28,11 @@ describe("deliverDiscordReply", () => {
|
||||
messageId: "voice-1",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
sendWebhookMessageDiscordMock.mockReset().mockResolvedValue({
|
||||
messageId: "webhook-1",
|
||||
channelId: "thread-1",
|
||||
});
|
||||
threadBindingTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("routes audioAsVoice payloads through the voice API and sends text separately", async () => {
|
||||
@@ -104,4 +115,141 @@ describe("deliverDiscordReply", () => {
|
||||
expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.replyTo).toBe("reply-1");
|
||||
expect(sendMessageDiscordMock.mock.calls[1]?.[2]?.replyTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not consume replyToId for replyToMode=first on whitespace-only payloads", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: " " }, { text: "actual reply" }],
|
||||
target: "channel:789",
|
||||
token: "token",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
replyToId: "reply-1",
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:789",
|
||||
"actual reply",
|
||||
expect.objectContaining({ token: "token", replyTo: "reply-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends bound-session text replies through webhook delivery", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "codex-refactor",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Hello from subagent" }],
|
||||
target: "channel:thread-1",
|
||||
token: "token",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
replyToId: "reply-1",
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
});
|
||||
|
||||
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"Hello from subagent",
|
||||
expect.objectContaining({
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
replyTo: "reply-1",
|
||||
}),
|
||||
);
|
||||
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to bot send when webhook delivery fails", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Fallback path" }],
|
||||
target: "channel:thread-1",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
});
|
||||
|
||||
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:thread-1",
|
||||
"Fallback path",
|
||||
expect.objectContaining({ token: "token", accountId: "default" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use thread webhook when outbound target is not a bound thread", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Parent channel delivery" }],
|
||||
target: "channel:parent-1",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
});
|
||||
|
||||
expect(sendWebhookMessageDiscordMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:parent-1",
|
||||
"Parent channel delivery",
|
||||
expect.objectContaining({ token: "token", accountId: "default" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,104 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { resolveAgentAvatar } from "../../agents/identity-avatar.js";
|
||||
import type { ChunkMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { sendMessageDiscord, sendVoiceMessageDiscord } from "../send.js";
|
||||
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
|
||||
|
||||
function resolveTargetChannelId(target: string): string | undefined {
|
||||
if (!target.startsWith("channel:")) {
|
||||
return undefined;
|
||||
}
|
||||
const channelId = target.slice("channel:".length).trim();
|
||||
return channelId || undefined;
|
||||
}
|
||||
|
||||
function resolveBoundThreadBinding(params: {
|
||||
threadBindings?: ThreadBindingManager;
|
||||
sessionKey?: string;
|
||||
target: string;
|
||||
}): ThreadBindingRecord | undefined {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!params.threadBindings || !sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const bindings = params.threadBindings.listBySessionKey(sessionKey);
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const targetChannelId = resolveTargetChannelId(params.target);
|
||||
if (!targetChannelId) {
|
||||
return undefined;
|
||||
}
|
||||
return bindings.find((entry) => entry.threadId === targetChannelId);
|
||||
}
|
||||
|
||||
function resolveBindingPersona(binding: ThreadBindingRecord | undefined): {
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
} {
|
||||
if (!binding) {
|
||||
return {};
|
||||
}
|
||||
const baseLabel = binding.label?.trim() || binding.agentId;
|
||||
const username = (`🤖 ${baseLabel}`.trim() || "🤖 agent").slice(0, 80);
|
||||
|
||||
let avatarUrl: string | undefined;
|
||||
try {
|
||||
const avatar = resolveAgentAvatar(loadConfig(), binding.agentId);
|
||||
if (avatar.kind === "remote") {
|
||||
avatarUrl = avatar.url;
|
||||
}
|
||||
} catch {
|
||||
avatarUrl = undefined;
|
||||
}
|
||||
return { username, avatarUrl };
|
||||
}
|
||||
|
||||
async function sendDiscordChunkWithFallback(params: {
|
||||
target: string;
|
||||
text: string;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
replyTo?: string;
|
||||
binding?: ThreadBindingRecord;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
const text = params.text.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const binding = params.binding;
|
||||
if (binding?.webhookId && binding?.webhookToken) {
|
||||
try {
|
||||
await sendWebhookMessageDiscord(text, {
|
||||
webhookId: binding.webhookId,
|
||||
webhookToken: binding.webhookToken,
|
||||
accountId: binding.accountId,
|
||||
threadId: binding.threadId,
|
||||
replyTo: params.replyTo,
|
||||
username: params.username,
|
||||
avatarUrl: params.avatarUrl,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to the standard bot sender path.
|
||||
}
|
||||
}
|
||||
await sendMessageDiscord(params.target, text, {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo: params.replyTo,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deliverDiscordReply(params: {
|
||||
replies: ReplyPayload[];
|
||||
@@ -20,6 +113,8 @@ export async function deliverDiscordReply(params: {
|
||||
replyToMode?: ReplyToMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
sessionKey?: string;
|
||||
threadBindings?: ThreadBindingManager;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||
const replyTo = params.replyToId?.trim() || undefined;
|
||||
@@ -40,6 +135,12 @@ export async function deliverDiscordReply(params: {
|
||||
replyUsed = true;
|
||||
return replyTo;
|
||||
};
|
||||
const binding = resolveBoundThreadBinding({
|
||||
threadBindings: params.threadBindings,
|
||||
sessionKey: params.sessionKey,
|
||||
target: params.target,
|
||||
});
|
||||
const persona = resolveBindingPersona(binding);
|
||||
for (const payload of params.replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const rawText = payload.text ?? "";
|
||||
@@ -59,16 +160,20 @@ export async function deliverDiscordReply(params: {
|
||||
chunks.push(text);
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
if (!chunk.trim()) {
|
||||
continue;
|
||||
}
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendMessageDiscord(params.target, trimmed, {
|
||||
await sendDiscordChunkWithFallback({
|
||||
target: params.target,
|
||||
text: chunk,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
binding,
|
||||
username: persona.username,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -79,7 +184,7 @@ export async function deliverDiscordReply(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord
|
||||
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
|
||||
if (payload.audioAsVoice) {
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendVoiceMessageDiscord(params.target, firstMedia, {
|
||||
@@ -88,17 +193,19 @@ export async function deliverDiscordReply(params: {
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
});
|
||||
// Voice messages cannot include text; send remaining text separately if present
|
||||
if (text.trim()) {
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendMessageDiscord(params.target, text, {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
});
|
||||
}
|
||||
// Additional media items are sent as regular attachments (voice is single-file only)
|
||||
// Voice messages cannot include text; send remaining text separately if present.
|
||||
await sendDiscordChunkWithFallback({
|
||||
target: params.target,
|
||||
text,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo: resolveReplyTo(),
|
||||
binding,
|
||||
username: persona.username,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
});
|
||||
// Additional media items are sent as regular attachments (voice is single-file only).
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendMessageDiscord(params.target, "", {
|
||||
|
||||
85
src/discord/monitor/thread-bindings.discord-api.test.ts
Normal file
85
src/discord/monitor/thread-bindings.discord-api.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const restGet = vi.fn();
|
||||
const createDiscordRestClient = vi.fn(() => ({
|
||||
rest: {
|
||||
get: restGet,
|
||||
},
|
||||
}));
|
||||
return {
|
||||
restGet,
|
||||
createDiscordRestClient,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
const { resolveChannelIdForBinding } = await import("./thread-bindings.discord-api.js");
|
||||
|
||||
describe("resolveChannelIdForBinding", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.restGet.mockReset();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
});
|
||||
|
||||
it("returns explicit channelId without resolving route", async () => {
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
channelId: "channel-explicit",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("channel-explicit");
|
||||
expect(hoisted.createDiscordRestClient).not.toHaveBeenCalled();
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns parent channel for thread channels", async () => {
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "thread-1",
|
||||
type: ChannelType.PublicThread,
|
||||
parent_id: "channel-parent",
|
||||
});
|
||||
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("channel-parent");
|
||||
});
|
||||
|
||||
it("keeps non-thread channel id even when parent_id exists", async () => {
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "channel-text",
|
||||
type: ChannelType.GuildText,
|
||||
parent_id: "category-1",
|
||||
});
|
||||
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "channel-text",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("channel-text");
|
||||
});
|
||||
|
||||
it("keeps forum channel id instead of parent category", async () => {
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "forum-1",
|
||||
type: ChannelType.GuildForum,
|
||||
parent_id: "category-1",
|
||||
});
|
||||
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "forum-1",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("forum-1");
|
||||
});
|
||||
});
|
||||
289
src/discord/monitor/thread-bindings.discord-api.ts
Normal file
289
src/discord/monitor/thread-bindings.discord-api.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import { createThreadDiscord } from "../send.messages.js";
|
||||
import { summarizeBindingPersona } from "./thread-bindings.messages.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL,
|
||||
rememberReusableWebhook,
|
||||
toReusableWebhookKey,
|
||||
} from "./thread-bindings.state.js";
|
||||
import {
|
||||
DISCORD_UNKNOWN_CHANNEL_ERROR_CODE,
|
||||
type ThreadBindingRecord,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
function buildThreadTarget(threadId: string): string {
|
||||
return `channel:${threadId}`;
|
||||
}
|
||||
|
||||
export function isThreadArchived(raw: unknown): boolean {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const asRecord = raw as {
|
||||
archived?: unknown;
|
||||
thread_metadata?: { archived?: unknown };
|
||||
threadMetadata?: { archived?: unknown };
|
||||
};
|
||||
if (asRecord.archived === true) {
|
||||
return true;
|
||||
}
|
||||
if (asRecord.thread_metadata?.archived === true) {
|
||||
return true;
|
||||
}
|
||||
if (asRecord.threadMetadata?.archived === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isThreadChannelType(type: unknown): boolean {
|
||||
return (
|
||||
type === ChannelType.PublicThread ||
|
||||
type === ChannelType.PrivateThread ||
|
||||
type === ChannelType.AnnouncementThread
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeDiscordError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (
|
||||
typeof err === "number" ||
|
||||
typeof err === "boolean" ||
|
||||
typeof err === "bigint" ||
|
||||
typeof err === "symbol"
|
||||
) {
|
||||
return String(err);
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function extractNumericDiscordErrorValue(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractDiscordErrorStatus(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = err as {
|
||||
status?: unknown;
|
||||
statusCode?: unknown;
|
||||
response?: { status?: unknown };
|
||||
};
|
||||
return (
|
||||
extractNumericDiscordErrorValue(candidate.status) ??
|
||||
extractNumericDiscordErrorValue(candidate.statusCode) ??
|
||||
extractNumericDiscordErrorValue(candidate.response?.status)
|
||||
);
|
||||
}
|
||||
|
||||
function extractDiscordErrorCode(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = err as {
|
||||
code?: unknown;
|
||||
rawError?: { code?: unknown };
|
||||
body?: { code?: unknown };
|
||||
response?: { body?: { code?: unknown }; data?: { code?: unknown } };
|
||||
};
|
||||
return (
|
||||
extractNumericDiscordErrorValue(candidate.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.rawError?.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.body?.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.response?.body?.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.response?.data?.code)
|
||||
);
|
||||
}
|
||||
|
||||
export function isDiscordThreadGoneError(err: unknown): boolean {
|
||||
const code = extractDiscordErrorCode(err);
|
||||
if (code === DISCORD_UNKNOWN_CHANNEL_ERROR_CODE) {
|
||||
return true;
|
||||
}
|
||||
const status = extractDiscordErrorStatus(err);
|
||||
// 404: deleted/unknown channel. 403: bot no longer has access.
|
||||
return status === 404 || status === 403;
|
||||
}
|
||||
|
||||
export async function maybeSendBindingMessage(params: {
|
||||
record: ThreadBindingRecord;
|
||||
text: string;
|
||||
preferWebhook?: boolean;
|
||||
}) {
|
||||
const text = params.text.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const record = params.record;
|
||||
if (params.preferWebhook !== false && record.webhookId && record.webhookToken) {
|
||||
try {
|
||||
await sendWebhookMessageDiscord(text, {
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
username: summarizeBindingPersona(record),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
logVerbose(`discord thread binding webhook send failed: ${summarizeDiscordError(err)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await sendMessageDiscord(buildThreadTarget(record.threadId), text, {
|
||||
accountId: record.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`discord thread binding fallback send failed: ${summarizeDiscordError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebhookForChannel(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
channelId: string;
|
||||
}): Promise<{ webhookId?: string; webhookToken?: string }> {
|
||||
try {
|
||||
const rest = createDiscordRestClient({
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
}).rest;
|
||||
const created = (await rest.post(Routes.channelWebhooks(params.channelId), {
|
||||
body: {
|
||||
name: "OpenClaw Agents",
|
||||
},
|
||||
})) as { id?: string; token?: string };
|
||||
const webhookId = typeof created?.id === "string" ? created.id.trim() : "";
|
||||
const webhookToken = typeof created?.token === "string" ? created.token.trim() : "";
|
||||
if (!webhookId || !webhookToken) {
|
||||
return {};
|
||||
}
|
||||
return { webhookId, webhookToken };
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord thread binding webhook create failed for ${params.channelId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function findReusableWebhook(params: { accountId: string; channelId: string }): {
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
} {
|
||||
const reusableKey = toReusableWebhookKey({
|
||||
accountId: params.accountId,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
const cached = REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.get(reusableKey);
|
||||
if (cached) {
|
||||
return {
|
||||
webhookId: cached.webhookId,
|
||||
webhookToken: cached.webhookToken,
|
||||
};
|
||||
}
|
||||
for (const record of BINDINGS_BY_THREAD_ID.values()) {
|
||||
if (record.accountId !== params.accountId) {
|
||||
continue;
|
||||
}
|
||||
if (record.channelId !== params.channelId) {
|
||||
continue;
|
||||
}
|
||||
if (!record.webhookId || !record.webhookToken) {
|
||||
continue;
|
||||
}
|
||||
rememberReusableWebhook(record);
|
||||
return {
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function resolveChannelIdForBinding(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
threadId: string;
|
||||
channelId?: string;
|
||||
}): Promise<string | null> {
|
||||
const explicit = params.channelId?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const rest = createDiscordRestClient({
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
}).rest;
|
||||
const channel = (await rest.get(Routes.channel(params.threadId))) as {
|
||||
id?: string;
|
||||
type?: number;
|
||||
parent_id?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
|
||||
const type = channel?.type;
|
||||
const parentId =
|
||||
typeof channel?.parent_id === "string"
|
||||
? channel.parent_id.trim()
|
||||
: typeof channel?.parentId === "string"
|
||||
? channel.parentId.trim()
|
||||
: "";
|
||||
// Only thread channels should resolve to their parent channel.
|
||||
// Non-thread channels (text/forum/media) must keep their own ID.
|
||||
if (parentId && isThreadChannelType(type)) {
|
||||
return parentId;
|
||||
}
|
||||
return channelId || null;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord thread binding channel resolve failed for ${params.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createThreadForBinding(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
channelId: string;
|
||||
threadName: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const created = await createThreadDiscord(
|
||||
params.channelId,
|
||||
{
|
||||
name: params.threadName,
|
||||
autoArchiveMinutes: 60,
|
||||
},
|
||||
{
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
},
|
||||
);
|
||||
const createdId = typeof created?.id === "string" ? created.id.trim() : "";
|
||||
return createdId || null;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord thread binding auto-thread create failed for ${params.channelId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
225
src/discord/monitor/thread-bindings.lifecycle.ts
Normal file
225
src/discord/monitor/thread-bindings.lifecycle.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { parseDiscordTarget } from "../targets.js";
|
||||
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
|
||||
import { getThreadBindingManager } from "./thread-bindings.manager.js";
|
||||
import {
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
MANAGERS_BY_ACCOUNT_ID,
|
||||
ensureBindingsLoaded,
|
||||
getThreadBindingToken,
|
||||
normalizeThreadBindingTtlMs,
|
||||
normalizeThreadId,
|
||||
rememberRecentUnboundWebhookEcho,
|
||||
removeBindingRecord,
|
||||
resolveBindingIdsForSession,
|
||||
saveBindingsToDisk,
|
||||
setBindingRecord,
|
||||
shouldPersistBindingMutations,
|
||||
} from "./thread-bindings.state.js";
|
||||
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
|
||||
|
||||
export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
|
||||
const manager = getThreadBindingManager(accountId);
|
||||
if (!manager) {
|
||||
return [];
|
||||
}
|
||||
return manager.listBindings();
|
||||
}
|
||||
|
||||
export function listThreadBindingsBySessionKey(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
return ids
|
||||
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
|
||||
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
|
||||
}
|
||||
|
||||
export async function autoBindSpawnedDiscordSubagent(params: {
|
||||
accountId?: string;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
childSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
}): Promise<ThreadBindingRecord | null> {
|
||||
const channel = params.channel?.trim().toLowerCase();
|
||||
if (channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const manager = getThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
const managerToken = getThreadBindingToken(manager.accountId);
|
||||
|
||||
const requesterThreadId = normalizeThreadId(params.threadId);
|
||||
let channelId = "";
|
||||
if (requesterThreadId) {
|
||||
const existing = manager.getByThreadId(requesterThreadId);
|
||||
if (existing?.channelId?.trim()) {
|
||||
channelId = existing.channelId.trim();
|
||||
} else {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId: manager.accountId,
|
||||
token: managerToken,
|
||||
threadId: requesterThreadId,
|
||||
})) ?? "";
|
||||
}
|
||||
}
|
||||
if (!channelId) {
|
||||
const to = params.to?.trim() || "";
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const target = parseDiscordTarget(to, { defaultKind: "channel" });
|
||||
if (!target || target.kind !== "channel") {
|
||||
return null;
|
||||
}
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId: manager.accountId,
|
||||
token: managerToken,
|
||||
threadId: target.id,
|
||||
})) ?? "";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return await manager.bindTarget({
|
||||
threadId: undefined,
|
||||
channelId,
|
||||
createThread: true,
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
}),
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: params.childSessionKey,
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
boundBy: params.boundBy ?? "system",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
sessionTtlMs: manager.getSessionTtlMs(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function unbindThreadBindingsBySessionKey(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
reason?: string;
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const removed: ThreadBindingRecord[] = [];
|
||||
for (const bindingKey of ids) {
|
||||
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
const manager = MANAGERS_BY_ACCOUNT_ID.get(record.accountId);
|
||||
if (manager) {
|
||||
const unbound = manager.unbindThread({
|
||||
threadId: record.threadId,
|
||||
reason: params.reason,
|
||||
sendFarewell: params.sendFarewell,
|
||||
farewellText: params.farewellText,
|
||||
});
|
||||
if (unbound) {
|
||||
removed.push(unbound);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const unbound = removeBindingRecord(bindingKey);
|
||||
if (unbound) {
|
||||
rememberRecentUnboundWebhookEcho(unbound);
|
||||
removed.push(unbound);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0 && shouldPersistBindingMutations()) {
|
||||
saveBindingsToDisk({ force: true });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function setThreadBindingTtlBySessionKey(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
ttlMs: number;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
});
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const ttlMs = normalizeThreadBindingTtlMs(params.ttlMs);
|
||||
const now = Date.now();
|
||||
const expiresAt = ttlMs > 0 ? now + ttlMs : 0;
|
||||
const updated: ThreadBindingRecord[] = [];
|
||||
for (const bindingKey of ids) {
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
const nextRecord: ThreadBindingRecord = {
|
||||
...existing,
|
||||
boundAt: now,
|
||||
expiresAt,
|
||||
};
|
||||
setBindingRecord(nextRecord);
|
||||
updated.push(nextRecord);
|
||||
}
|
||||
if (updated.length > 0 && shouldPersistBindingMutations()) {
|
||||
saveBindingsToDisk({ force: true });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
515
src/discord/monitor/thread-bindings.manager.ts
Normal file
515
src/discord/monitor/thread-bindings.manager.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
type BindingTargetKind,
|
||||
type SessionBindingRecord,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import {
|
||||
createThreadForBinding,
|
||||
createWebhookForChannel,
|
||||
findReusableWebhook,
|
||||
isDiscordThreadGoneError,
|
||||
isThreadArchived,
|
||||
maybeSendBindingMessage,
|
||||
resolveChannelIdForBinding,
|
||||
summarizeDiscordError,
|
||||
} from "./thread-bindings.discord-api.js";
|
||||
import {
|
||||
resolveThreadBindingFarewellText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
forgetThreadBindingToken,
|
||||
getThreadBindingToken,
|
||||
MANAGERS_BY_ACCOUNT_ID,
|
||||
PERSIST_BY_ACCOUNT_ID,
|
||||
ensureBindingsLoaded,
|
||||
rememberThreadBindingToken,
|
||||
normalizeTargetKind,
|
||||
normalizeThreadBindingTtlMs,
|
||||
normalizeThreadId,
|
||||
rememberRecentUnboundWebhookEcho,
|
||||
removeBindingRecord,
|
||||
resolveBindingIdsForSession,
|
||||
resolveBindingRecordKey,
|
||||
resolveThreadBindingExpiresAt,
|
||||
resolveThreadBindingsPath,
|
||||
saveBindingsToDisk,
|
||||
setBindingRecord,
|
||||
shouldDefaultPersist,
|
||||
resetThreadBindingsForTests,
|
||||
} from "./thread-bindings.state.js";
|
||||
import {
|
||||
DEFAULT_THREAD_BINDING_TTL_MS,
|
||||
THREAD_BINDINGS_SWEEP_INTERVAL_MS,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
function registerManager(manager: ThreadBindingManager) {
|
||||
MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager);
|
||||
}
|
||||
|
||||
function unregisterManager(accountId: string, manager: ThreadBindingManager) {
|
||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||
if (existing === manager) {
|
||||
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
|
||||
const accountId = normalizeAccountId(accountIdRaw);
|
||||
return {
|
||||
accountId,
|
||||
getSessionTtlMs: () => DEFAULT_THREAD_BINDING_TTL_MS,
|
||||
getByThreadId: () => undefined,
|
||||
getBySessionKey: () => undefined,
|
||||
listBySessionKey: () => [],
|
||||
listBindings: () => [],
|
||||
bindTarget: async () => null,
|
||||
unbindThread: () => null,
|
||||
unbindBySessionKey: () => [],
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
|
||||
return raw === "subagent" ? "subagent" : "session";
|
||||
}
|
||||
|
||||
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingRecord {
|
||||
const bindingId =
|
||||
resolveBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
}) ?? `${record.accountId}:${record.threadId}`;
|
||||
return {
|
||||
bindingId,
|
||||
targetSessionKey: record.targetSessionKey,
|
||||
targetKind: toSessionBindingTargetKind(record.targetKind),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: record.accountId,
|
||||
conversationId: record.threadId,
|
||||
parentConversationId: record.channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt: record.expiresAt,
|
||||
metadata: {
|
||||
agentId: record.agentId,
|
||||
label: record.label,
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
boundBy: record.boundBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThreadIdFromBindingId(params: {
|
||||
accountId: string;
|
||||
bindingId?: string;
|
||||
}): string | undefined {
|
||||
const bindingId = params.bindingId?.trim();
|
||||
if (!bindingId) {
|
||||
return undefined;
|
||||
}
|
||||
const prefix = `${params.accountId}:`;
|
||||
if (!bindingId.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const threadId = bindingId.slice(prefix.length).trim();
|
||||
return threadId || undefined;
|
||||
}
|
||||
|
||||
export function createThreadBindingManager(
|
||||
params: {
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
persist?: boolean;
|
||||
enableSweeper?: boolean;
|
||||
sessionTtlMs?: number;
|
||||
} = {},
|
||||
): ThreadBindingManager {
|
||||
ensureBindingsLoaded();
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||
if (existing) {
|
||||
rememberThreadBindingToken({ accountId, token: params.token });
|
||||
return existing;
|
||||
}
|
||||
|
||||
rememberThreadBindingToken({ accountId, token: params.token });
|
||||
|
||||
const persist = params.persist ?? shouldDefaultPersist();
|
||||
PERSIST_BY_ACCOUNT_ID.set(accountId, persist);
|
||||
const sessionTtlMs = normalizeThreadBindingTtlMs(params.sessionTtlMs);
|
||||
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
|
||||
|
||||
let sweepTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const manager: ThreadBindingManager = {
|
||||
accountId,
|
||||
getSessionTtlMs: () => sessionTtlMs,
|
||||
getByThreadId: (threadId) => {
|
||||
const key = resolveBindingRecordKey({
|
||||
accountId,
|
||||
threadId,
|
||||
});
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = BINDINGS_BY_THREAD_ID.get(key);
|
||||
if (!entry || entry.accountId !== accountId) {
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
getBySessionKey: (targetSessionKey) => {
|
||||
const all = manager.listBySessionKey(targetSessionKey);
|
||||
return all[0];
|
||||
},
|
||||
listBySessionKey: (targetSessionKey) => {
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
});
|
||||
return ids
|
||||
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
|
||||
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
|
||||
},
|
||||
listBindings: () =>
|
||||
[...BINDINGS_BY_THREAD_ID.values()].filter((entry) => entry.accountId === accountId),
|
||||
bindTarget: async (bindParams) => {
|
||||
let threadId = normalizeThreadId(bindParams.threadId);
|
||||
let channelId = bindParams.channelId?.trim() || "";
|
||||
|
||||
if (!threadId && bindParams.createThread) {
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
const threadName = resolveThreadBindingThreadName({
|
||||
agentId: bindParams.agentId,
|
||||
label: bindParams.label,
|
||||
});
|
||||
threadId =
|
||||
(await createThreadForBinding({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
channelId,
|
||||
threadName: bindParams.threadName?.trim() || threadName,
|
||||
})) ?? undefined;
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
threadId,
|
||||
channelId: bindParams.channelId,
|
||||
})) ?? "";
|
||||
}
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetSessionKey = bindParams.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
|
||||
let webhookId = bindParams.webhookId?.trim() || "";
|
||||
let webhookToken = bindParams.webhookToken?.trim() || "";
|
||||
if (!webhookId || !webhookToken) {
|
||||
const cachedWebhook = findReusableWebhook({ accountId, channelId });
|
||||
webhookId = cachedWebhook.webhookId ?? "";
|
||||
webhookToken = cachedWebhook.webhookToken ?? "";
|
||||
}
|
||||
if (!webhookId || !webhookToken) {
|
||||
const createdWebhook = await createWebhookForChannel({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
channelId,
|
||||
});
|
||||
webhookId = createdWebhook.webhookId ?? "";
|
||||
webhookToken = createdWebhook.webhookToken ?? "";
|
||||
}
|
||||
|
||||
const boundAt = Date.now();
|
||||
const record: ThreadBindingRecord = {
|
||||
accountId,
|
||||
channelId,
|
||||
threadId,
|
||||
targetKind,
|
||||
targetSessionKey,
|
||||
agentId: bindParams.agentId?.trim() || resolveAgentIdFromSessionKey(targetSessionKey),
|
||||
label: bindParams.label?.trim() || undefined,
|
||||
webhookId: webhookId || undefined,
|
||||
webhookToken: webhookToken || undefined,
|
||||
boundBy: bindParams.boundBy?.trim() || "system",
|
||||
boundAt,
|
||||
expiresAt: sessionTtlMs > 0 ? boundAt + sessionTtlMs : undefined,
|
||||
};
|
||||
|
||||
setBindingRecord(record);
|
||||
if (persist) {
|
||||
saveBindingsToDisk();
|
||||
}
|
||||
|
||||
const introText = bindParams.introText?.trim();
|
||||
if (introText) {
|
||||
void maybeSendBindingMessage({ record, text: introText });
|
||||
}
|
||||
return record;
|
||||
},
|
||||
unbindThread: (unbindParams) => {
|
||||
const bindingKey = resolveBindingRecordKey({
|
||||
accountId,
|
||||
threadId: unbindParams.threadId,
|
||||
});
|
||||
if (!bindingKey) {
|
||||
return null;
|
||||
}
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!existing || existing.accountId !== accountId) {
|
||||
return null;
|
||||
}
|
||||
const removed = removeBindingRecord(bindingKey);
|
||||
if (!removed) {
|
||||
return null;
|
||||
}
|
||||
rememberRecentUnboundWebhookEcho(removed);
|
||||
if (persist) {
|
||||
saveBindingsToDisk();
|
||||
}
|
||||
if (unbindParams.sendFarewell !== false) {
|
||||
const farewell = resolveThreadBindingFarewellText({
|
||||
reason: unbindParams.reason,
|
||||
farewellText: unbindParams.farewellText,
|
||||
sessionTtlMs,
|
||||
});
|
||||
// Use bot send path for farewell messages so unbound threads don't process
|
||||
// webhook echoes as fresh inbound turns when allowBots is enabled.
|
||||
void maybeSendBindingMessage({ record: removed, text: farewell, preferWebhook: false });
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
unbindBySessionKey: (unbindParams) => {
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey: unbindParams.targetSessionKey,
|
||||
accountId,
|
||||
targetKind: unbindParams.targetKind,
|
||||
});
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const removed: ThreadBindingRecord[] = [];
|
||||
for (const bindingKey of ids) {
|
||||
const binding = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!binding) {
|
||||
continue;
|
||||
}
|
||||
const entry = manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: unbindParams.reason,
|
||||
sendFarewell: unbindParams.sendFarewell,
|
||||
farewellText: unbindParams.farewellText,
|
||||
});
|
||||
if (entry) {
|
||||
removed.push(entry);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
stop: () => {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
unregisterManager(accountId, manager);
|
||||
unregisterSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId,
|
||||
});
|
||||
forgetThreadBindingToken(accountId);
|
||||
},
|
||||
};
|
||||
|
||||
if (params.enableSweeper !== false) {
|
||||
sweepTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
const bindings = manager.listBindings();
|
||||
if (bindings.length === 0) {
|
||||
return;
|
||||
}
|
||||
let rest;
|
||||
try {
|
||||
rest = createDiscordRestClient({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
}).rest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const binding of bindings) {
|
||||
const expiresAt = resolveThreadBindingExpiresAt({
|
||||
record: binding,
|
||||
sessionTtlMs,
|
||||
});
|
||||
if (expiresAt != null && Date.now() >= expiresAt) {
|
||||
const ttlFromBinding = Math.max(0, expiresAt - binding.boundAt);
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "ttl-expired",
|
||||
sendFarewell: true,
|
||||
farewellText: resolveThreadBindingFarewellText({
|
||||
reason: "ttl-expired",
|
||||
sessionTtlMs: ttlFromBinding,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||
if (!channel || typeof channel !== "object") {
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isThreadArchived(channel)) {
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-archived",
|
||||
sendFarewell: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDiscordThreadGoneError(err)) {
|
||||
logVerbose(
|
||||
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-delete",
|
||||
sendFarewell: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, THREAD_BINDINGS_SWEEP_INTERVAL_MS);
|
||||
sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId,
|
||||
bind: async (input) => {
|
||||
if (input.conversation.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const targetSessionKey = input.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const conversationId = input.conversation.conversationId.trim();
|
||||
const metadata = input.metadata ?? {};
|
||||
const label =
|
||||
typeof metadata.label === "string" ? metadata.label.trim() || undefined : undefined;
|
||||
const threadName =
|
||||
typeof metadata.threadName === "string"
|
||||
? metadata.threadName.trim() || undefined
|
||||
: undefined;
|
||||
const introText =
|
||||
typeof metadata.introText === "string" ? metadata.introText.trim() || undefined : undefined;
|
||||
const boundBy =
|
||||
typeof metadata.boundBy === "string" ? metadata.boundBy.trim() || undefined : undefined;
|
||||
const agentId =
|
||||
typeof metadata.agentId === "string" ? metadata.agentId.trim() || undefined : undefined;
|
||||
const bound = await manager.bindTarget({
|
||||
threadId: conversationId || undefined,
|
||||
channelId: input.conversation.parentConversationId?.trim() || undefined,
|
||||
createThread: !conversationId,
|
||||
threadName,
|
||||
targetKind: toThreadBindingTargetKind(input.targetKind),
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
boundBy,
|
||||
introText,
|
||||
});
|
||||
return bound ? toSessionBindingRecord(bound) : null;
|
||||
},
|
||||
listBySession: (targetSessionKey) =>
|
||||
manager.listBySessionKey(targetSessionKey).map(toSessionBindingRecord),
|
||||
resolveByConversation: (ref) => {
|
||||
if (ref.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const binding = manager.getByThreadId(ref.conversationId);
|
||||
return binding ? toSessionBindingRecord(binding) : null;
|
||||
},
|
||||
touch: () => {
|
||||
// Thread bindings are activity-touched by inbound/outbound message flows.
|
||||
},
|
||||
unbind: async (input) => {
|
||||
if (input.targetSessionKey?.trim()) {
|
||||
const removed = manager.unbindBySessionKey({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed.map(toSessionBindingRecord);
|
||||
}
|
||||
const threadId = resolveThreadIdFromBindingId({
|
||||
accountId,
|
||||
bindingId: input.bindingId,
|
||||
});
|
||||
if (!threadId) {
|
||||
return [];
|
||||
}
|
||||
const removed = manager.unbindThread({
|
||||
threadId,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed ? [toSessionBindingRecord(removed)] : [];
|
||||
},
|
||||
});
|
||||
|
||||
registerManager(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
export function createNoopThreadBindingManager(accountId?: string): ThreadBindingManager {
|
||||
return createNoopManager(accountId);
|
||||
}
|
||||
|
||||
export function getThreadBindingManager(accountId?: string): ThreadBindingManager | null {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return MANAGERS_BY_ACCOUNT_ID.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveThreadBindingsPath,
|
||||
resolveThreadBindingThreadName,
|
||||
resetThreadBindingsForTests,
|
||||
};
|
||||
72
src/discord/monitor/thread-bindings.messages.ts
Normal file
72
src/discord/monitor/thread-bindings.messages.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { DEFAULT_FAREWELL_TEXT, type ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
function normalizeThreadBindingMessageTtlMs(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return 0;
|
||||
}
|
||||
const ttlMs = Math.floor(raw);
|
||||
if (ttlMs < 0) {
|
||||
return 0;
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
export function formatThreadBindingTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "disabled";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingThreadName(params: {
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
}): string {
|
||||
const label = params.label?.trim();
|
||||
const base = label || params.agentId?.trim() || "agent";
|
||||
const raw = `🤖 ${base}`.replace(/\s+/g, " ").trim();
|
||||
return raw.slice(0, 100);
|
||||
}
|
||||
|
||||
export function resolveThreadBindingIntroText(params: {
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
sessionTtlMs?: number;
|
||||
}): string {
|
||||
const label = params.label?.trim();
|
||||
const base = label || params.agentId?.trim() || "agent";
|
||||
const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent";
|
||||
const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs);
|
||||
if (ttlMs > 0) {
|
||||
return `🤖 ${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`;
|
||||
}
|
||||
return `🤖 ${normalized} session active. Messages here go directly to this session.`;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingFarewellText(params: {
|
||||
reason?: string;
|
||||
farewellText?: string;
|
||||
sessionTtlMs: number;
|
||||
}): string {
|
||||
const custom = params.farewellText?.trim();
|
||||
if (custom) {
|
||||
return custom;
|
||||
}
|
||||
if (params.reason === "ttl-expired") {
|
||||
return `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`;
|
||||
}
|
||||
return DEFAULT_FAREWELL_TEXT;
|
||||
}
|
||||
|
||||
export function summarizeBindingPersona(record: ThreadBindingRecord): string {
|
||||
const label = record.label?.trim();
|
||||
const base = label || record.agentId;
|
||||
return (`🤖 ${base}`.trim() || "🤖 agent").slice(0, 80);
|
||||
}
|
||||
31
src/discord/monitor/thread-bindings.shared-state.test.ts
Normal file
31
src/discord/monitor/thread-bindings.shared-state.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createJiti } from "jiti";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
__testing as threadBindingsTesting,
|
||||
createThreadBindingManager,
|
||||
getThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
describe("thread binding manager state", () => {
|
||||
beforeEach(() => {
|
||||
threadBindingsTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("shares managers between ESM and Jiti-loaded module instances", () => {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
interopDefault: true,
|
||||
});
|
||||
const viaJiti = jiti("./thread-bindings.ts") as {
|
||||
getThreadBindingManager: typeof getThreadBindingManager;
|
||||
};
|
||||
|
||||
createThreadBindingManager({
|
||||
accountId: "work",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
|
||||
expect(getThreadBindingManager("work")).not.toBeNull();
|
||||
expect(viaJiti.getThreadBindingManager("work")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
444
src/discord/monitor/thread-bindings.state.ts
Normal file
444
src/discord/monitor/thread-bindings.state.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
DEFAULT_THREAD_BINDING_TTL_MS,
|
||||
RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
|
||||
THREAD_BINDINGS_VERSION,
|
||||
type PersistedThreadBindingRecord,
|
||||
type PersistedThreadBindingsPayload,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
type ThreadBindingTargetKind,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
type ThreadBindingsGlobalState = {
|
||||
managersByAccountId: Map<string, ThreadBindingManager>;
|
||||
bindingsByThreadId: Map<string, ThreadBindingRecord>;
|
||||
bindingsBySessionKey: Map<string, Set<string>>;
|
||||
tokensByAccountId: Map<string, string>;
|
||||
recentUnboundWebhookEchoesByBindingKey: Map<string, { webhookId: string; expiresAt: number }>;
|
||||
reusableWebhooksByAccountChannel: Map<string, { webhookId: string; webhookToken: string }>;
|
||||
persistByAccountId: Map<string, boolean>;
|
||||
loadedBindings: boolean;
|
||||
};
|
||||
|
||||
// Plugin hooks can load this module via Jiti while core imports it via ESM.
|
||||
// Store mutable state on globalThis so both loader paths share one registry.
|
||||
const THREAD_BINDINGS_STATE_KEY = "__openclawDiscordThreadBindingsState";
|
||||
|
||||
function createThreadBindingsGlobalState(): ThreadBindingsGlobalState {
|
||||
return {
|
||||
managersByAccountId: new Map<string, ThreadBindingManager>(),
|
||||
bindingsByThreadId: new Map<string, ThreadBindingRecord>(),
|
||||
bindingsBySessionKey: new Map<string, Set<string>>(),
|
||||
tokensByAccountId: new Map<string, string>(),
|
||||
recentUnboundWebhookEchoesByBindingKey: new Map<
|
||||
string,
|
||||
{ webhookId: string; expiresAt: number }
|
||||
>(),
|
||||
reusableWebhooksByAccountChannel: new Map<
|
||||
string,
|
||||
{ webhookId: string; webhookToken: string }
|
||||
>(),
|
||||
persistByAccountId: new Map<string, boolean>(),
|
||||
loadedBindings: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThreadBindingsGlobalState(): ThreadBindingsGlobalState {
|
||||
const runtimeGlobal = globalThis as typeof globalThis & {
|
||||
[THREAD_BINDINGS_STATE_KEY]?: ThreadBindingsGlobalState;
|
||||
};
|
||||
if (!runtimeGlobal[THREAD_BINDINGS_STATE_KEY]) {
|
||||
runtimeGlobal[THREAD_BINDINGS_STATE_KEY] = createThreadBindingsGlobalState();
|
||||
}
|
||||
return runtimeGlobal[THREAD_BINDINGS_STATE_KEY];
|
||||
}
|
||||
|
||||
const THREAD_BINDINGS_STATE = resolveThreadBindingsGlobalState();
|
||||
|
||||
export const MANAGERS_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.managersByAccountId;
|
||||
export const BINDINGS_BY_THREAD_ID = THREAD_BINDINGS_STATE.bindingsByThreadId;
|
||||
export const BINDINGS_BY_SESSION_KEY = THREAD_BINDINGS_STATE.bindingsBySessionKey;
|
||||
export const TOKENS_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.tokensByAccountId;
|
||||
export const RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY =
|
||||
THREAD_BINDINGS_STATE.recentUnboundWebhookEchoesByBindingKey;
|
||||
export const REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL =
|
||||
THREAD_BINDINGS_STATE.reusableWebhooksByAccountChannel;
|
||||
export const PERSIST_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.persistByAccountId;
|
||||
|
||||
export function rememberThreadBindingToken(params: { accountId?: string; token?: string }) {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const token = params.token?.trim();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
TOKENS_BY_ACCOUNT_ID.set(normalizedAccountId, token);
|
||||
}
|
||||
|
||||
export function forgetThreadBindingToken(accountId?: string) {
|
||||
TOKENS_BY_ACCOUNT_ID.delete(normalizeAccountId(accountId));
|
||||
}
|
||||
|
||||
export function getThreadBindingToken(accountId?: string): string | undefined {
|
||||
return TOKENS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId));
|
||||
}
|
||||
|
||||
export function shouldDefaultPersist(): boolean {
|
||||
return !(process.env.VITEST || process.env.NODE_ENV === "test");
|
||||
}
|
||||
|
||||
export function resolveThreadBindingsPath(): string {
|
||||
return path.join(resolveStateDir(process.env), "discord", "thread-bindings.json");
|
||||
}
|
||||
|
||||
export function normalizeTargetKind(
|
||||
raw: unknown,
|
||||
targetSessionKey: string,
|
||||
): ThreadBindingTargetKind {
|
||||
if (raw === "subagent" || raw === "acp") {
|
||||
return raw;
|
||||
}
|
||||
return targetSessionKey.includes(":subagent:") ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
export function normalizeThreadId(raw: unknown): string | undefined {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return String(Math.floor(raw));
|
||||
}
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function toBindingRecordKey(params: { accountId: string; threadId: string }): string {
|
||||
return `${normalizeAccountId(params.accountId)}:${params.threadId.trim()}`;
|
||||
}
|
||||
|
||||
export function resolveBindingRecordKey(params: {
|
||||
accountId?: string;
|
||||
threadId: string;
|
||||
}): string | undefined {
|
||||
const threadId = normalizeThreadId(params.threadId);
|
||||
if (!threadId) {
|
||||
return undefined;
|
||||
}
|
||||
return toBindingRecordKey({
|
||||
accountId: normalizeAccountId(params.accountId),
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBindingRecord | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const value = raw as Partial<PersistedThreadBindingRecord>;
|
||||
const threadId = normalizeThreadId(value.threadId ?? threadIdKey);
|
||||
const channelId = typeof value.channelId === "string" ? value.channelId.trim() : "";
|
||||
const targetSessionKey =
|
||||
typeof value.targetSessionKey === "string"
|
||||
? value.targetSessionKey.trim()
|
||||
: typeof value.sessionKey === "string"
|
||||
? value.sessionKey.trim()
|
||||
: "";
|
||||
if (!threadId || !channelId || !targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeAccountId(value.accountId);
|
||||
const targetKind = normalizeTargetKind(value.targetKind, targetSessionKey);
|
||||
const agentIdRaw = typeof value.agentId === "string" ? value.agentId.trim() : "";
|
||||
const agentId = agentIdRaw || resolveAgentIdFromSessionKey(targetSessionKey);
|
||||
const label = typeof value.label === "string" ? value.label.trim() || undefined : undefined;
|
||||
const webhookId =
|
||||
typeof value.webhookId === "string" ? value.webhookId.trim() || undefined : undefined;
|
||||
const webhookToken =
|
||||
typeof value.webhookToken === "string" ? value.webhookToken.trim() || undefined : undefined;
|
||||
const boundBy = typeof value.boundBy === "string" ? value.boundBy.trim() || "system" : "system";
|
||||
const boundAt =
|
||||
typeof value.boundAt === "number" && Number.isFinite(value.boundAt)
|
||||
? Math.floor(value.boundAt)
|
||||
: Date.now();
|
||||
const expiresAt =
|
||||
typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt)
|
||||
? Math.max(0, Math.floor(value.expiresAt))
|
||||
: undefined;
|
||||
return {
|
||||
accountId,
|
||||
channelId,
|
||||
threadId,
|
||||
targetKind,
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
webhookId,
|
||||
webhookToken,
|
||||
boundBy,
|
||||
boundAt,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeThreadBindingTtlMs(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return DEFAULT_THREAD_BINDING_TTL_MS;
|
||||
}
|
||||
const ttlMs = Math.floor(raw);
|
||||
if (ttlMs < 0) {
|
||||
return DEFAULT_THREAD_BINDING_TTL_MS;
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingExpiresAt(params: {
|
||||
record: Pick<ThreadBindingRecord, "boundAt" | "expiresAt">;
|
||||
sessionTtlMs: number;
|
||||
}): number | undefined {
|
||||
if (typeof params.record.expiresAt === "number" && Number.isFinite(params.record.expiresAt)) {
|
||||
const explicitExpiresAt = Math.floor(params.record.expiresAt);
|
||||
if (explicitExpiresAt <= 0) {
|
||||
// 0 is an explicit per-binding TTL disable sentinel.
|
||||
return undefined;
|
||||
}
|
||||
return explicitExpiresAt;
|
||||
}
|
||||
if (params.sessionTtlMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const boundAt = Math.floor(params.record.boundAt);
|
||||
if (!Number.isFinite(boundAt) || boundAt <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return boundAt + params.sessionTtlMs;
|
||||
}
|
||||
|
||||
function linkSessionBinding(targetSessionKey: string, bindingKey: string) {
|
||||
const key = targetSessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const threads = BINDINGS_BY_SESSION_KEY.get(key) ?? new Set<string>();
|
||||
threads.add(bindingKey);
|
||||
BINDINGS_BY_SESSION_KEY.set(key, threads);
|
||||
}
|
||||
|
||||
function unlinkSessionBinding(targetSessionKey: string, bindingKey: string) {
|
||||
const key = targetSessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const threads = BINDINGS_BY_SESSION_KEY.get(key);
|
||||
if (!threads) {
|
||||
return;
|
||||
}
|
||||
threads.delete(bindingKey);
|
||||
if (threads.size === 0) {
|
||||
BINDINGS_BY_SESSION_KEY.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function toReusableWebhookKey(params: { accountId: string; channelId: string }): string {
|
||||
return `${params.accountId.trim().toLowerCase()}:${params.channelId.trim()}`;
|
||||
}
|
||||
|
||||
export function rememberReusableWebhook(record: ThreadBindingRecord) {
|
||||
const webhookId = record.webhookId?.trim();
|
||||
const webhookToken = record.webhookToken?.trim();
|
||||
if (!webhookId || !webhookToken) {
|
||||
return;
|
||||
}
|
||||
const key = toReusableWebhookKey({
|
||||
accountId: record.accountId,
|
||||
channelId: record.channelId,
|
||||
});
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.set(key, { webhookId, webhookToken });
|
||||
}
|
||||
|
||||
export function rememberRecentUnboundWebhookEcho(record: ThreadBindingRecord) {
|
||||
const webhookId = record.webhookId?.trim();
|
||||
if (!webhookId) {
|
||||
return;
|
||||
}
|
||||
const bindingKey = resolveBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
});
|
||||
if (!bindingKey) {
|
||||
return;
|
||||
}
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.set(bindingKey, {
|
||||
webhookId,
|
||||
expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function clearRecentUnboundWebhookEcho(bindingKeyRaw: string) {
|
||||
const key = bindingKeyRaw.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(key);
|
||||
}
|
||||
|
||||
export function setBindingRecord(record: ThreadBindingRecord) {
|
||||
const bindingKey = toBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
});
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (existing) {
|
||||
unlinkSessionBinding(existing.targetSessionKey, bindingKey);
|
||||
}
|
||||
BINDINGS_BY_THREAD_ID.set(bindingKey, record);
|
||||
linkSessionBinding(record.targetSessionKey, bindingKey);
|
||||
clearRecentUnboundWebhookEcho(bindingKey);
|
||||
rememberReusableWebhook(record);
|
||||
}
|
||||
|
||||
export function removeBindingRecord(bindingKeyRaw: string): ThreadBindingRecord | null {
|
||||
const key = bindingKeyRaw.trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(key);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
BINDINGS_BY_THREAD_ID.delete(key);
|
||||
unlinkSessionBinding(existing.targetSessionKey, key);
|
||||
return existing;
|
||||
}
|
||||
|
||||
export function isRecentlyUnboundThreadWebhookMessage(params: {
|
||||
accountId?: string;
|
||||
threadId: string;
|
||||
webhookId?: string | null;
|
||||
}): boolean {
|
||||
const webhookId = params.webhookId?.trim() || "";
|
||||
if (!webhookId) {
|
||||
return false;
|
||||
}
|
||||
const bindingKey = resolveBindingRecordKey({
|
||||
accountId: params.accountId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
if (!bindingKey) {
|
||||
return false;
|
||||
}
|
||||
const suppressed = RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.get(bindingKey);
|
||||
if (!suppressed) {
|
||||
return false;
|
||||
}
|
||||
if (suppressed.expiresAt <= Date.now()) {
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
|
||||
return false;
|
||||
}
|
||||
return suppressed.webhookId === webhookId;
|
||||
}
|
||||
|
||||
function shouldPersistAnyBindingState(): boolean {
|
||||
for (const value of PERSIST_BY_ACCOUNT_ID.values()) {
|
||||
if (value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldPersistBindingMutations(): boolean {
|
||||
if (shouldPersistAnyBindingState()) {
|
||||
return true;
|
||||
}
|
||||
return fs.existsSync(resolveThreadBindingsPath());
|
||||
}
|
||||
|
||||
export function saveBindingsToDisk(params: { force?: boolean } = {}) {
|
||||
if (!params.force && !shouldPersistAnyBindingState()) {
|
||||
return;
|
||||
}
|
||||
const bindings: Record<string, PersistedThreadBindingRecord> = {};
|
||||
for (const [bindingKey, record] of BINDINGS_BY_THREAD_ID.entries()) {
|
||||
bindings[bindingKey] = { ...record };
|
||||
}
|
||||
const payload: PersistedThreadBindingsPayload = {
|
||||
version: THREAD_BINDINGS_VERSION,
|
||||
bindings,
|
||||
};
|
||||
saveJsonFile(resolveThreadBindingsPath(), payload);
|
||||
}
|
||||
|
||||
export function ensureBindingsLoaded() {
|
||||
if (THREAD_BINDINGS_STATE.loadedBindings) {
|
||||
return;
|
||||
}
|
||||
THREAD_BINDINGS_STATE.loadedBindings = true;
|
||||
BINDINGS_BY_THREAD_ID.clear();
|
||||
BINDINGS_BY_SESSION_KEY.clear();
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.clear();
|
||||
|
||||
const raw = loadJsonFile(resolveThreadBindingsPath());
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return;
|
||||
}
|
||||
const payload = raw as Partial<PersistedThreadBindingsPayload>;
|
||||
if (payload.version !== 1 || !payload.bindings || typeof payload.bindings !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [threadId, entry] of Object.entries(payload.bindings)) {
|
||||
const normalized = normalizePersistedBinding(threadId, entry);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
setBindingRecord(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBindingIdsForSession(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
}): string[] {
|
||||
const key = params.targetSessionKey.trim();
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
const ids = BINDINGS_BY_SESSION_KEY.get(key);
|
||||
if (!ids) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const bindingKey of ids.values()) {
|
||||
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (params.accountId && record.accountId !== params.accountId) {
|
||||
continue;
|
||||
}
|
||||
if (params.targetKind && record.targetKind !== params.targetKind) {
|
||||
continue;
|
||||
}
|
||||
out.push(bindingKey);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resetThreadBindingsForTests() {
|
||||
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
||||
manager.stop();
|
||||
}
|
||||
MANAGERS_BY_ACCOUNT_ID.clear();
|
||||
BINDINGS_BY_THREAD_ID.clear();
|
||||
BINDINGS_BY_SESSION_KEY.clear();
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.clear();
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.clear();
|
||||
TOKENS_BY_ACCOUNT_ID.clear();
|
||||
PERSIST_BY_ACCOUNT_ID.clear();
|
||||
THREAD_BINDINGS_STATE.loadedBindings = false;
|
||||
}
|
||||
28
src/discord/monitor/thread-bindings.ts
Normal file
28
src/discord/monitor/thread-bindings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type {
|
||||
ThreadBindingManager,
|
||||
ThreadBindingRecord,
|
||||
ThreadBindingTargetKind,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
export {
|
||||
formatThreadBindingTtlLabel,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
|
||||
export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js";
|
||||
|
||||
export {
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey,
|
||||
listThreadBindingsForAccount,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} from "./thread-bindings.lifecycle.js";
|
||||
|
||||
export {
|
||||
__testing,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
getThreadBindingManager,
|
||||
} from "./thread-bindings.manager.js";
|
||||
541
src/discord/monitor/thread-bindings.ttl.test.ts
Normal file
541
src/discord/monitor/thread-bindings.ttl.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||
const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
|
||||
const restGet = vi.fn(async () => ({
|
||||
id: "thread-1",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
}));
|
||||
const restPost = vi.fn(async () => ({
|
||||
id: "wh-created",
|
||||
token: "tok-created",
|
||||
}));
|
||||
const createDiscordRestClient = vi.fn((..._args: unknown[]) => ({
|
||||
rest: {
|
||||
get: restGet,
|
||||
post: restPost,
|
||||
},
|
||||
}));
|
||||
const createThreadDiscord = vi.fn(async (..._args: unknown[]) => ({ id: "thread-created" }));
|
||||
return {
|
||||
sendMessageDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
restGet,
|
||||
restPost,
|
||||
createDiscordRestClient,
|
||||
createThreadDiscord,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageDiscord: hoisted.sendMessageDiscord,
|
||||
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
vi.mock("../send.messages.js", () => ({
|
||||
createThreadDiscord: hoisted.createThreadDiscord,
|
||||
}));
|
||||
|
||||
const {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createThreadBindingManager,
|
||||
resolveThreadBindingIntroText,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} = await import("./thread-bindings.js");
|
||||
|
||||
describe("thread binding ttl", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restPost.mockClear();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes ttl in intro text", () => {
|
||||
const intro = resolveThreadBindingIntroText({
|
||||
agentId: "main",
|
||||
label: "worker",
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(intro).toContain("auto-unfocus in 24h");
|
||||
});
|
||||
|
||||
it("auto-unfocuses expired bindings and sends a ttl-expired message", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const binding = await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
introText: "intro",
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
|
||||
const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined;
|
||||
expect(farewell).toContain("Session ended automatically after 1m");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps binding when thread sweep probe fails transiently", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("unbinds when thread sweep probe reports unknown channel", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
hoisted.restGet.mockRejectedValueOnce({
|
||||
status: 404,
|
||||
rawError: { code: 10003, message: "Unknown Channel" },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates ttl by target session key", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z"));
|
||||
|
||||
const updated = setThreadBindingTtlBySessionKey({
|
||||
accountId: "default",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
ttlMs: 2 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
|
||||
expect(updated[0]?.expiresAt).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
|
||||
expect(manager.getByThreadId("thread-1")?.expiresAt).toBe(
|
||||
new Date("2026-02-21T01:15:00.000Z").getTime(),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps binding when ttl is disabled per session key", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 60_000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
const updated = setThreadBindingTtlBySessionKey({
|
||||
accountId: "default",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
ttlMs: 0,
|
||||
});
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0]?.expiresAt).toBe(0);
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses webhook credentials after unbind when rebinding in the same channel", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const first = await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(first).not.toBeNull();
|
||||
expect(hoisted.restPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.unbindThread({
|
||||
threadId: "thread-1",
|
||||
sendFarewell: false,
|
||||
});
|
||||
|
||||
const second = await manager.bindTarget({
|
||||
threadId: "thread-2",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-2",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(second).not.toBeNull();
|
||||
expect(second?.webhookId).toBe("wh-created");
|
||||
expect(second?.webhookToken).toBe("tok-created");
|
||||
expect(hoisted.restPost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates a new thread when spawning from an already bound thread", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:parent",
|
||||
agentId: "main",
|
||||
});
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-2" });
|
||||
|
||||
const childBinding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: "default",
|
||||
channel: "discord",
|
||||
to: "channel:thread-1",
|
||||
threadId: "thread-1",
|
||||
childSessionKey: "agent:main:subagent:child-2",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(childBinding).not.toBeNull();
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
|
||||
"parent-1",
|
||||
expect.objectContaining({ autoArchiveMinutes: 60 }),
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(manager.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:parent");
|
||||
expect(manager.getByThreadId("thread-created-2")?.targetSessionKey).toBe(
|
||||
"agent:main:subagent:child-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves parent channel when thread target is passed via to without threadId", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "thread-lookup",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
});
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-lookup" });
|
||||
|
||||
const childBinding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: "default",
|
||||
channel: "discord",
|
||||
to: "channel:thread-lookup",
|
||||
childSessionKey: "agent:main:subagent:child-lookup",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(childBinding).not.toBeNull();
|
||||
expect(childBinding?.channelId).toBe("parent-1");
|
||||
expect(hoisted.restGet).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
|
||||
"parent-1",
|
||||
expect.objectContaining({ autoArchiveMinutes: 60 }),
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes manager token when resolving parent channels for auto-bind", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "runtime",
|
||||
token: "runtime-token",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "thread-runtime",
|
||||
type: 11,
|
||||
parent_id: "parent-runtime",
|
||||
});
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" });
|
||||
|
||||
const childBinding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: "runtime",
|
||||
channel: "discord",
|
||||
to: "channel:thread-runtime",
|
||||
childSessionKey: "agent:main:subagent:child-runtime",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(childBinding).not.toBeNull();
|
||||
const firstClientArgs = hoisted.createDiscordRestClient.mock.calls[0]?.[0] as
|
||||
| { accountId?: string; token?: string }
|
||||
| undefined;
|
||||
expect(firstClientArgs).toMatchObject({
|
||||
accountId: "runtime",
|
||||
token: "runtime-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes manager token when an existing manager is reused", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "runtime",
|
||||
token: "token-old",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "runtime",
|
||||
token: "token-new",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-token-refresh" });
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
|
||||
const bound = await manager.bindTarget({
|
||||
createThread: true,
|
||||
channelId: "parent-runtime",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:token-refresh",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(bound).not.toBeNull();
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
|
||||
"parent-runtime",
|
||||
expect.objectContaining({ autoArchiveMinutes: 60 }),
|
||||
expect.objectContaining({ accountId: "runtime", token: "token-new" }),
|
||||
);
|
||||
const usedTokenNew = hoisted.createDiscordRestClient.mock.calls.some(
|
||||
(call) => (call?.[0] as { token?: string } | undefined)?.token === "token-new",
|
||||
);
|
||||
expect(usedTokenNew).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps overlapping thread ids isolated per account", async () => {
|
||||
const a = createThreadBindingManager({
|
||||
accountId: "a",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
const b = createThreadBindingManager({
|
||||
accountId: "b",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const aBinding = await a.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:a",
|
||||
agentId: "main",
|
||||
});
|
||||
const bBinding = await b.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:b",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(aBinding?.accountId).toBe("a");
|
||||
expect(bBinding?.accountId).toBe("b");
|
||||
expect(a.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:a");
|
||||
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
|
||||
|
||||
const removedA = a.unbindBySessionKey({
|
||||
targetSessionKey: "agent:main:subagent:a",
|
||||
sendFarewell: false,
|
||||
});
|
||||
expect(removedA).toHaveLength(1);
|
||||
expect(a.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
|
||||
});
|
||||
|
||||
it("persists unbinds even when no manager is active", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
try {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
const bindingsPath = __testing.resolveThreadBindingsPath();
|
||||
fs.mkdirSync(path.dirname(bindingsPath), { recursive: true });
|
||||
const now = Date.now();
|
||||
fs.writeFileSync(
|
||||
bindingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
bindings: {
|
||||
"thread-1": {
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
boundBy: "system",
|
||||
boundAt: now,
|
||||
expiresAt: now + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const removed = unbindThreadBindingsBySessionKey({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
});
|
||||
expect(removed).toHaveLength(1);
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(bindingsPath, "utf-8")) as {
|
||||
bindings?: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(payload.bindings ?? {})).toEqual([]);
|
||||
} finally {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
69
src/discord/monitor/thread-bindings.types.ts
Normal file
69
src/discord/monitor/thread-bindings.types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type ThreadBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
export type ThreadBindingRecord = {
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
threadId: string;
|
||||
targetKind: ThreadBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
boundBy: string;
|
||||
boundAt: number;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingsPayload = {
|
||||
version: 1;
|
||||
bindings: Record<string, PersistedThreadBindingRecord>;
|
||||
};
|
||||
|
||||
export type ThreadBindingManager = {
|
||||
accountId: string;
|
||||
getSessionTtlMs: () => number;
|
||||
getByThreadId: (threadId: string) => ThreadBindingRecord | undefined;
|
||||
getBySessionKey: (targetSessionKey: string) => ThreadBindingRecord | undefined;
|
||||
listBySessionKey: (targetSessionKey: string) => ThreadBindingRecord[];
|
||||
listBindings: () => ThreadBindingRecord[];
|
||||
bindTarget: (params: {
|
||||
threadId?: string | number;
|
||||
channelId?: string;
|
||||
createThread?: boolean;
|
||||
threadName?: string;
|
||||
targetKind: ThreadBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
introText?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
}) => Promise<ThreadBindingRecord | null>;
|
||||
unbindThread: (params: {
|
||||
threadId: string;
|
||||
reason?: string;
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}) => ThreadBindingRecord | null;
|
||||
unbindBySessionKey: (params: {
|
||||
targetSessionKey: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
reason?: string;
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}) => ThreadBindingRecord[];
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export const THREAD_BINDINGS_VERSION = 1 as const;
|
||||
export const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 120_000;
|
||||
export const DEFAULT_THREAD_BINDING_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
export const DEFAULT_FAREWELL_TEXT = "Session ended. Messages here will no longer be routed.";
|
||||
export const DISCORD_UNKNOWN_CHANNEL_ERROR_CODE = 10_003;
|
||||
export const RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS = 30_000;
|
||||
@@ -10,7 +10,7 @@ vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: Parameters<typeof actual.loadConfig>) => loadConfigMock(...args),
|
||||
loadConfig: (..._args: unknown[]) => loadConfigMock(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -295,6 +295,100 @@ export async function sendMessageDiscord(
|
||||
return toDiscordSendResult(result, channelId);
|
||||
}
|
||||
|
||||
type DiscordWebhookSendOpts = {
|
||||
webhookId: string;
|
||||
webhookToken: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
replyTo?: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
wait?: boolean;
|
||||
};
|
||||
|
||||
function resolveWebhookExecutionUrl(params: {
|
||||
webhookId: string;
|
||||
webhookToken: string;
|
||||
threadId?: string | number;
|
||||
wait?: boolean;
|
||||
}) {
|
||||
const baseUrl = new URL(
|
||||
`https://discord.com/api/v10/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}`,
|
||||
);
|
||||
baseUrl.searchParams.set("wait", params.wait === false ? "false" : "true");
|
||||
if (params.threadId !== undefined && params.threadId !== null && params.threadId !== "") {
|
||||
baseUrl.searchParams.set("thread_id", String(params.threadId));
|
||||
}
|
||||
return baseUrl.toString();
|
||||
}
|
||||
|
||||
export async function sendWebhookMessageDiscord(
|
||||
text: string,
|
||||
opts: DiscordWebhookSendOpts,
|
||||
): Promise<DiscordSendResult> {
|
||||
const webhookId = opts.webhookId.trim();
|
||||
const webhookToken = opts.webhookToken.trim();
|
||||
if (!webhookId || !webhookToken) {
|
||||
throw new Error("Discord webhook id/token are required");
|
||||
}
|
||||
|
||||
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
|
||||
const response = await fetch(
|
||||
resolveWebhookExecutionUrl({
|
||||
webhookId,
|
||||
webhookToken,
|
||||
threadId: opts.threadId,
|
||||
wait: opts.wait,
|
||||
}),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
username: opts.username?.trim() || undefined,
|
||||
avatar_url: opts.avatarUrl?.trim() || undefined,
|
||||
...(messageReference ? { message_reference: messageReference } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const raw = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Discord webhook send failed (${response.status}${raw ? `: ${raw.slice(0, 200)}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
id?: string;
|
||||
channel_id?: string;
|
||||
};
|
||||
try {
|
||||
const account = resolveDiscordAccount({
|
||||
cfg: loadConfig(),
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort telemetry only.
|
||||
}
|
||||
return {
|
||||
messageId: payload.id ? String(payload.id) : "unknown",
|
||||
channelId: payload.channel_id
|
||||
? String(payload.channel_id)
|
||||
: opts.threadId
|
||||
? String(opts.threadId)
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendStickerDiscord(
|
||||
to: string,
|
||||
stickerIds: string[],
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
} from "./send.outbound.js";
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
|
||||
50
src/discord/send.webhook-activity.test.ts
Normal file
50
src/discord/send.webhook-activity.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendWebhookMessageDiscord } from "./send.js";
|
||||
|
||||
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../infra/channel-activity.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/channel-activity.js")>();
|
||||
return {
|
||||
...actual,
|
||||
recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("sendWebhookMessageDiscord activity", () => {
|
||||
beforeEach(() => {
|
||||
recordChannelActivityMock.mockReset();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ id: "msg-1", channel_id: "thread-1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("records outbound channel activity for webhook sends", async () => {
|
||||
const result = await sendWebhookMessageDiscord("hello world", {
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
accountId: "runtime",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
messageId: "msg-1",
|
||||
channelId: "thread-1",
|
||||
});
|
||||
expect(recordChannelActivityMock).toHaveBeenCalledWith({
|
||||
channel: "discord",
|
||||
accountId: "runtime",
|
||||
direction: "outbound",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,8 @@ export const SessionsDeleteParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||
// Internal control: when false, still unbind thread bindings but skip hook emission.
|
||||
emitLifecycleHooks: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -12,8 +12,14 @@ import {
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { unbindThreadBindingsBySessionKey } from "../../discord/monitor/thread-bindings.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import {
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -132,6 +138,41 @@ function archiveSessionTranscriptsForSession(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function emitSessionUnboundLifecycleEvent(params: {
|
||||
targetSessionKey: string;
|
||||
reason: "session-reset" | "session-delete";
|
||||
emitHooks?: boolean;
|
||||
}) {
|
||||
const targetKind = isSubagentSessionKey(params.targetSessionKey) ? "subagent" : "acp";
|
||||
unbindThreadBindingsBySessionKey({
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
targetKind,
|
||||
reason: params.reason,
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
if (params.emitHooks === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (!hookRunner?.hasHooks("subagent_ended")) {
|
||||
return;
|
||||
}
|
||||
await hookRunner.runSubagentEnded(
|
||||
{
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
targetKind,
|
||||
reason: params.reason,
|
||||
sendFarewell: true,
|
||||
outcome: params.reason === "session-reset" ? "reset" : "deleted",
|
||||
},
|
||||
{
|
||||
childSessionKey: params.targetSessionKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSessionRuntimeCleanup(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
key: string;
|
||||
@@ -306,6 +347,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const hadExistingEntry = Boolean(entry);
|
||||
const commandReason = p.reason === "new" ? "new" : "reset";
|
||||
const hookEvent = createInternalHookEvent(
|
||||
"command",
|
||||
@@ -367,6 +409,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
agentId: target.agentId,
|
||||
reason: "reset",
|
||||
});
|
||||
if (hadExistingEntry) {
|
||||
await emitSessionUnboundLifecycleEvent({
|
||||
targetSessionKey: target.canonicalKey ?? key,
|
||||
reason: "session-reset",
|
||||
});
|
||||
}
|
||||
respond(true, { ok: true, key: target.canonicalKey, entry: next }, undefined);
|
||||
},
|
||||
"sessions.delete": async ({ params, respond, client, isWebchatConnect }) => {
|
||||
@@ -397,30 +445,40 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const sessionId = entry?.sessionId;
|
||||
const existed = Boolean(entry);
|
||||
const cleanupError = await ensureSessionRuntimeCleanup({ cfg, key, target, sessionId });
|
||||
if (cleanupError) {
|
||||
respond(false, undefined, cleanupError);
|
||||
return;
|
||||
}
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const deleted = await updateSessionStore(storePath, (store) => {
|
||||
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||
if (store[primaryKey]) {
|
||||
const hadEntry = Boolean(store[primaryKey]);
|
||||
if (hadEntry) {
|
||||
delete store[primaryKey];
|
||||
}
|
||||
return hadEntry;
|
||||
});
|
||||
|
||||
const archived = deleteTranscript
|
||||
? archiveSessionTranscriptsForSession({
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
agentId: target.agentId,
|
||||
reason: "deleted",
|
||||
})
|
||||
: [];
|
||||
const archived =
|
||||
deleted && deleteTranscript
|
||||
? archiveSessionTranscriptsForSession({
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
agentId: target.agentId,
|
||||
reason: "deleted",
|
||||
})
|
||||
: [];
|
||||
if (deleted) {
|
||||
const emitLifecycleHooks = p.emitLifecycleHooks !== false;
|
||||
await emitSessionUnboundLifecycleEvent({
|
||||
targetSessionKey: target.canonicalKey ?? key,
|
||||
reason: "session-delete",
|
||||
emitHooks: emitLifecycleHooks,
|
||||
});
|
||||
}
|
||||
|
||||
respond(true, { ok: true, key: target.canonicalKey, deleted: existed, archived }, undefined);
|
||||
respond(true, { ok: true, key: target.canonicalKey, deleted, archived }, undefined);
|
||||
},
|
||||
"sessions.compact": async ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) {
|
||||
|
||||
@@ -26,6 +26,18 @@ const sessionHookMocks = vi.hoisted(() => ({
|
||||
triggerInternalHook: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const subagentLifecycleHookMocks = vi.hoisted(() => ({
|
||||
runSubagentEnded: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const subagentLifecycleHookState = vi.hoisted(() => ({
|
||||
hasSubagentEndedHook: true,
|
||||
}));
|
||||
|
||||
const threadBindingMocks = vi.hoisted(() => ({
|
||||
unbindThreadBindingsBySessionKey: vi.fn((_params?: unknown) => []),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply/queue.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../auto-reply/reply/queue.js")>(
|
||||
"../auto-reply/reply/queue.js",
|
||||
@@ -56,6 +68,27 @@ vi.mock("../hooks/internal-hooks.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/hook-runner-global.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getGlobalHookRunner: vi.fn(() => ({
|
||||
hasHooks: (hookName: string) =>
|
||||
hookName === "subagent_ended" && subagentLifecycleHookState.hasSubagentEndedHook,
|
||||
runSubagentEnded: subagentLifecycleHookMocks.runSubagentEnded,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../discord/monitor/thread-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../discord/monitor/thread-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
unbindThreadBindingsBySessionKey: (params: unknown) =>
|
||||
threadBindingMocks.unbindThreadBindingsBySessionKey(params),
|
||||
};
|
||||
});
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
let harness: GatewayServerHarness;
|
||||
@@ -134,6 +167,9 @@ describe("gateway server sessions", () => {
|
||||
sessionCleanupMocks.clearSessionQueues.mockClear();
|
||||
sessionCleanupMocks.stopSubagentsForRequester.mockClear();
|
||||
sessionHookMocks.triggerInternalHook.mockClear();
|
||||
subagentLifecycleHookMocks.runSubagentEnded.mockClear();
|
||||
subagentLifecycleHookState.hasSubagentEndedHook = true;
|
||||
threadBindingMocks.unbindThreadBindingsBySessionKey.mockClear();
|
||||
});
|
||||
|
||||
test("lists and patches session store via sessions.* RPC", async () => {
|
||||
@@ -605,6 +641,148 @@ describe("gateway server sessions", () => {
|
||||
["discord:group:dev", "agent:main:discord:group:dev", "sess-active"],
|
||||
"sess-active",
|
||||
);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith(
|
||||
{
|
||||
targetSessionKey: "agent:main:discord:group:dev",
|
||||
targetKind: "acp",
|
||||
reason: "session-delete",
|
||||
sendFarewell: true,
|
||||
outcome: "deleted",
|
||||
},
|
||||
{
|
||||
childSessionKey: "agent:main:discord:group:dev",
|
||||
},
|
||||
);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:discord:group:dev",
|
||||
targetKind: "acp",
|
||||
reason: "session-delete",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.delete does not emit lifecycle events when nothing was deleted", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-main", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: { sessionId: "sess-main", updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await openClient();
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
|
||||
key: "agent:main:subagent:missing",
|
||||
});
|
||||
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(false);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).not.toHaveBeenCalled();
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.delete emits subagent targetKind for subagent sessions", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-subagent", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"agent:main:subagent:worker": {
|
||||
sessionId: "sess-subagent",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await openClient();
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
|
||||
key: "agent:main:subagent:worker",
|
||||
});
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
|
||||
const event = (subagentLifecycleHookMocks.runSubagentEnded.mock.calls as unknown[][])[0]?.[0] as
|
||||
| { targetKind?: string; targetSessionKey?: string; reason?: string; outcome?: string }
|
||||
| undefined;
|
||||
expect(event).toMatchObject({
|
||||
targetSessionKey: "agent:main:subagent:worker",
|
||||
targetKind: "subagent",
|
||||
reason: "session-delete",
|
||||
outcome: "deleted",
|
||||
});
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:worker",
|
||||
targetKind: "subagent",
|
||||
reason: "session-delete",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.delete can skip lifecycle hooks while still unbinding thread bindings", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-subagent", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"agent:main:subagent:worker": {
|
||||
sessionId: "sess-subagent",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await openClient();
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
|
||||
key: "agent:main:subagent:worker",
|
||||
emitLifecycleHooks: false,
|
||||
});
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:worker",
|
||||
targetKind: "subagent",
|
||||
reason: "session-delete",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.delete directly unbinds thread bindings when hooks are unavailable", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-subagent", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"agent:main:subagent:worker": {
|
||||
sessionId: "sess-subagent",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
subagentLifecycleHookState.hasSubagentEndedHook = false;
|
||||
|
||||
const { ws } = await openClient();
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(ws, "sessions.delete", {
|
||||
key: "agent:main:subagent:worker",
|
||||
});
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:worker",
|
||||
targetKind: "subagent",
|
||||
reason: "session-delete",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
@@ -632,6 +810,125 @@ describe("gateway server sessions", () => {
|
||||
["main", "agent:main:main", "sess-main"],
|
||||
"sess-main",
|
||||
);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith(
|
||||
{
|
||||
targetSessionKey: "agent:main:main",
|
||||
targetKind: "acp",
|
||||
reason: "session-reset",
|
||||
sendFarewell: true,
|
||||
outcome: "reset",
|
||||
},
|
||||
{
|
||||
childSessionKey: "agent:main:main",
|
||||
},
|
||||
);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:main",
|
||||
targetKind: "acp",
|
||||
reason: "session-reset",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.reset does not emit lifecycle events when key does not exist", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-main", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: { sessionId: "sess-main", updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await openClient();
|
||||
const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string } }>(
|
||||
ws,
|
||||
"sessions.reset",
|
||||
{
|
||||
key: "agent:main:subagent:missing",
|
||||
},
|
||||
);
|
||||
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).not.toHaveBeenCalled();
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.reset emits subagent targetKind for subagent sessions", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-subagent", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"agent:main:subagent:worker": {
|
||||
sessionId: "sess-subagent",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await openClient();
|
||||
const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string } }>(
|
||||
ws,
|
||||
"sessions.reset",
|
||||
{
|
||||
key: "agent:main:subagent:worker",
|
||||
},
|
||||
);
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.key).toBe("agent:main:subagent:worker");
|
||||
expect(reset.payload?.entry.sessionId).not.toBe("sess-subagent");
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
|
||||
const event = (subagentLifecycleHookMocks.runSubagentEnded.mock.calls as unknown[][])[0]?.[0] as
|
||||
| { targetKind?: string; targetSessionKey?: string; reason?: string; outcome?: string }
|
||||
| undefined;
|
||||
expect(event).toMatchObject({
|
||||
targetSessionKey: "agent:main:subagent:worker",
|
||||
targetKind: "subagent",
|
||||
reason: "session-reset",
|
||||
outcome: "reset",
|
||||
});
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:worker",
|
||||
targetKind: "subagent",
|
||||
reason: "session-reset",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("sessions.reset directly unbinds thread bindings when hooks are unavailable", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-main", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
subagentLifecycleHookState.hasSubagentEndedHook = false;
|
||||
|
||||
const { ws } = await openClient();
|
||||
const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", {
|
||||
key: "main",
|
||||
});
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:main",
|
||||
targetKind: "acp",
|
||||
reason: "session-reset",
|
||||
sendFarewell: true,
|
||||
});
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user