feat: thread-bound subagents on Discord (#21805)

* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Onur
2026-02-21 16:14:55 +01:00
committed by GitHub
parent 166068dfbe
commit 8178ea472d
114 changed files with 12214 additions and 1659 deletions

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View 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

View 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.

View File

@@ -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`)

View File

@@ -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);
},
};

View 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();
});
});

View 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,
},
};
});
}

View File

@@ -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");
});

View File

@@ -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);

View File

@@ -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 : [])];

View 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,
});
});
});

View File

@@ -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 () => {

View File

@@ -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 {

View 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;
}

View 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,
};
}

View 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);
});
});

View 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);
}
}

View 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;
}

View 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;
}

View 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();
});
});

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -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() {

View 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;
};

View File

@@ -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,
};
}

View File

@@ -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,
},

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,

View 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");
});
});

View File

@@ -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;

View 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");
});
});

View File

@@ -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();

View File

@@ -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() } };
};

View 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"));
}

View 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}).`);
}

View 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());
}

View 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"));
}

View 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 };
}

View 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"));
}

View 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"));
}

View 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)}).`,
);
}

View 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}`);
}

View 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.");
}

View 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] };
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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",
});
});
});

View File

@@ -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,
}),
});
},
};

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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":

View File

@@ -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",

View File

@@ -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";

View File

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

View File

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

View File

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

View File

@@ -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 (05). 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;
};

View File

@@ -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. */

View File

@@ -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(),

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -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"),
});
}

View 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);
});
});

View File

@@ -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,
};
}

View File

@@ -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;
};

View File

@@ -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", () => {

View File

@@ -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();
},

View File

@@ -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;
}

View File

@@ -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}`;

View File

@@ -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");
});
});

View File

@@ -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;

View 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,
};
}

View 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);
});
});

View 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();
}
}

View File

@@ -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);
});
});

View 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);
});
});

View File

@@ -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,
};

View File

@@ -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" }),
);
});
});

View File

@@ -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, "", {

View 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");
});
});

View 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;
}
}

View 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;
}

View 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,
};

View 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);
}

View 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();
});
});

View 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;
}

View 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";

View 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 });
}
});
});

View 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;

View File

@@ -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(),
};
});

View File

@@ -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[],

View File

@@ -41,6 +41,7 @@ export {
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendWebhookMessageDiscord,
sendVoiceMessageDiscord,
} from "./send.outbound.js";
export { sendDiscordComponentMessage } from "./send.components.js";

View 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",
});
});
});

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More