ACP: add persistent Discord channel and Telegram topic bindings (#34873)

* docs: add ACP persistent binding experiment plan

* docs: align ACP persistent binding spec to channel-local config

* docs: scope Telegram ACP bindings to forum topics only

* docs: lock bound /new and /reset behavior to in-place ACP reset

* ACP: add persistent discord/telegram conversation bindings

* ACP: fix persistent binding reuse and discord thread parent context

* docs: document channel-specific persistent ACP bindings

* ACP: split persistent bindings and share conversation id helpers

* ACP: defer configured binding init until preflight passes

* ACP: fix discord thread parent fallback and explicit disable inheritance

* ACP: keep bound /new and /reset in-place

* ACP: honor configured bindings in native command flows

* ACP: avoid configured fallback after runtime bind failure

* docs: refine ACP bindings experiment config examples

* acp: cut over to typed top-level persistent bindings

* ACP bindings: harden reset recovery and native command auth

* Docs: add ACP bound command auth proposal

* Tests: normalize i18n registry zh-CN assertion encoding

* ACP bindings: address review findings for reset and fallback routing

* ACP reset: gate hooks on success and preserve /new arguments

* ACP bindings: fix auth and binding-priority review findings

* Telegram ACP: gate ensure on auth and accepted messages

* ACP bindings: fix session-key precedence and unavailable handling

* ACP reset/native commands: honor fallback targets and abort on bootstrap failure

* Config schema: validate ACP binding channel and Telegram topic IDs

* Discord ACP: apply configured DM bindings to native commands

* ACP reset tails: dispatch through ACP after command handling

* ACP tails/native reset auth: fix target dispatch and restore full auth

* ACP reset detection: fallback to active ACP keys for DM contexts

* Tests: type runTurn mock input in ACP dispatch test

* ACP: dedup binding route bootstrap and reset target resolution

* reply: align ACP reset hooks with bound session key

* docs: replace personal discord ids with placeholders

* fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
Bob
2026-03-05 09:38:12 +01:00
committed by GitHub
parent 2c8ee593b9
commit 6a705a37f2
50 changed files with 4830 additions and 186 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.
- Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.
- Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.
- ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob.
- Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.
- Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.
- TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.

View File

@@ -685,6 +685,71 @@ Default slash command settings:
</Accordion>
<Accordion title="Persistent ACP channel bindings">
For stable "always-on" ACP workspaces, configure top-level typed ACP bindings targeting Discord conversations.
Config path:
- `bindings[]` with `type: "acp"` and `match.channel: "discord"`
Example:
```json5
{
agents: {
list: [
{
id: "codex",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
},
],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "222222222222222222" },
},
acp: { label: "codex-main" },
},
],
channels: {
discord: {
guilds: {
"111111111111111111": {
channels: {
"222222222222222222": {
requireMention: false,
},
},
},
},
},
},
}
```
Notes:
- Thread messages can inherit the parent channel ACP binding.
- In a bound channel or thread, `/new` and `/reset` reset the same ACP session in place.
- Temporary thread bindings still work and can override target resolution while active.
See [ACP Agents](/tools/acp-agents) for binding behavior details.
</Accordion>
<Accordion title="Reaction notifications">
Per-guild reaction notification mode:
@@ -1120,7 +1185,7 @@ High-signal Discord fields:
- actions: `actions.*`
- presence: `activity`, `status`, `activityType`, `activityUrl`
- UI: `ui.components.accentColor`
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
- features: `threadBindings`, top-level `bindings[]` (`type: "acp"`), `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
## Safety and operations

View File

@@ -469,6 +469,59 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Each topic then has its own session key: `agent:zu:telegram:group:-1001234567890:topic:3`
**Persistent ACP topic binding**: Forum topics can pin ACP harness sessions through top-level typed ACP bindings:
- `bindings[]` with `type: "acp"` and `match.channel: "telegram"`
Example:
```json5
{
agents: {
list: [
{
id: "codex",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
},
],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "-1001234567890:topic:42" },
},
},
],
channels: {
telegram: {
groups: {
"-1001234567890": {
topics: {
"42": {
requireMention: false,
},
},
},
},
},
},
}
```
This is currently scoped to forum topics in groups and supergroups.
Template context includes:
- `MessageThreadId`
@@ -778,6 +831,7 @@ Primary reference:
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
@@ -809,7 +863,7 @@ Primary reference:
Telegram-specific high-signal fields:
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `blockStreaming`

View File

@@ -0,0 +1,375 @@
# ACP Persistent Bindings for Discord Channels and Telegram Topics
Status: Draft
## Summary
Introduce persistent ACP bindings that map:
- Discord channels (and existing threads, where needed), and
- Telegram forum topics in groups/supergroups (`chatId:topic:topicId`)
to long-lived ACP sessions, with binding state stored in top-level `bindings[]` entries using explicit binding types.
This makes ACP usage in high-traffic messaging channels predictable and durable, so users can create dedicated channels/topics such as `codex`, `claude-1`, or `claude-myrepo`.
## Why
Current thread-bound ACP behavior is optimized for ephemeral Discord thread workflows. Telegram does not have the same thread model; it has forum topics in groups/supergroups. Users want stable, always-on ACP “workspaces” in chat surfaces, not only temporary thread sessions.
## Goals
- Support durable ACP binding for:
- Discord channels/threads
- Telegram forum topics (groups/supergroups)
- Make binding source-of-truth config-driven.
- Keep `/acp`, `/new`, `/reset`, `/focus`, and delivery behavior consistent across Discord and Telegram.
- Preserve existing temporary binding flows for ad-hoc usage.
## Non-Goals
- Full redesign of ACP runtime/session internals.
- Removing existing ephemeral binding flows.
- Expanding to every channel in the first iteration.
- Implementing Telegram channel direct-messages topics (`direct_messages_topic_id`) in this phase.
- Implementing Telegram private-chat topic variants in this phase.
## UX Direction
### 1) Two binding types
- **Persistent binding**: saved in config, reconciled on startup, intended for “named workspace” channels/topics.
- **Temporary binding**: runtime-only, expires by idle/max-age policy.
### 2) Command behavior
- `/acp spawn ... --thread here|auto|off` remains available.
- Add explicit bind lifecycle controls:
- `/acp bind [session|agent] [--persist]`
- `/acp unbind [--persist]`
- `/acp status` includes whether binding is `persistent` or `temporary`.
- In bound conversations, `/new` and `/reset` reset the bound ACP session in place and keep the binding attached.
### 3) Conversation identity
- Use canonical conversation IDs:
- Discord: channel/thread ID.
- Telegram topic: `chatId:topic:topicId`.
- Never key Telegram bindings by bare topic ID alone.
## Config Model (Proposed)
Unify routing and persistent ACP binding configuration in top-level `bindings[]` with explicit `type` discriminator:
```jsonc
{
"agents": {
"list": [
{
"id": "main",
"default": true,
"workspace": "~/.openclaw/workspace-main",
"runtime": { "type": "embedded" },
},
{
"id": "codex",
"workspace": "~/.openclaw/workspace-codex",
"runtime": {
"type": "acp",
"acp": {
"agent": "codex",
"backend": "acpx",
"mode": "persistent",
"cwd": "/workspace/repo-a",
},
},
},
{
"id": "claude",
"workspace": "~/.openclaw/workspace-claude",
"runtime": {
"type": "acp",
"acp": {
"agent": "claude",
"backend": "acpx",
"mode": "persistent",
"cwd": "/workspace/repo-b",
},
},
},
],
},
"acp": {
"enabled": true,
"backend": "acpx",
"allowedAgents": ["codex", "claude"],
},
"bindings": [
// Route bindings (existing behavior)
{
"type": "route",
"agentId": "main",
"match": { "channel": "discord", "accountId": "default" },
},
{
"type": "route",
"agentId": "main",
"match": { "channel": "telegram", "accountId": "default" },
},
// Persistent ACP conversation bindings
{
"type": "acp",
"agentId": "codex",
"match": {
"channel": "discord",
"accountId": "default",
"peer": { "kind": "channel", "id": "222222222222222222" },
},
"acp": {
"label": "codex-main",
"mode": "persistent",
"cwd": "/workspace/repo-a",
"backend": "acpx",
},
},
{
"type": "acp",
"agentId": "claude",
"match": {
"channel": "discord",
"accountId": "default",
"peer": { "kind": "channel", "id": "333333333333333333" },
},
"acp": {
"label": "claude-repo-b",
"mode": "persistent",
"cwd": "/workspace/repo-b",
},
},
{
"type": "acp",
"agentId": "codex",
"match": {
"channel": "telegram",
"accountId": "default",
"peer": { "kind": "group", "id": "-1001234567890:topic:42" },
},
"acp": {
"label": "tg-codex-42",
"mode": "persistent",
},
},
],
"channels": {
"discord": {
"guilds": {
"111111111111111111": {
"channels": {
"222222222222222222": {
"enabled": true,
"requireMention": false,
},
"333333333333333333": {
"enabled": true,
"requireMention": false,
},
},
},
},
},
"telegram": {
"groups": {
"-1001234567890": {
"topics": {
"42": {
"requireMention": false,
},
},
},
},
},
},
}
```
### Minimal Example (No Per-Binding ACP Overrides)
```jsonc
{
"agents": {
"list": [
{ "id": "main", "default": true, "runtime": { "type": "embedded" } },
{
"id": "codex",
"runtime": {
"type": "acp",
"acp": { "agent": "codex", "backend": "acpx", "mode": "persistent" },
},
},
{
"id": "claude",
"runtime": {
"type": "acp",
"acp": { "agent": "claude", "backend": "acpx", "mode": "persistent" },
},
},
],
},
"acp": { "enabled": true, "backend": "acpx" },
"bindings": [
{
"type": "route",
"agentId": "main",
"match": { "channel": "discord", "accountId": "default" },
},
{
"type": "route",
"agentId": "main",
"match": { "channel": "telegram", "accountId": "default" },
},
{
"type": "acp",
"agentId": "codex",
"match": {
"channel": "discord",
"accountId": "default",
"peer": { "kind": "channel", "id": "222222222222222222" },
},
},
{
"type": "acp",
"agentId": "claude",
"match": {
"channel": "discord",
"accountId": "default",
"peer": { "kind": "channel", "id": "333333333333333333" },
},
},
{
"type": "acp",
"agentId": "codex",
"match": {
"channel": "telegram",
"accountId": "default",
"peer": { "kind": "group", "id": "-1009876543210:topic:5" },
},
},
],
}
```
Notes:
- `bindings[].type` is explicit:
- `route`: normal agent routing.
- `acp`: persistent ACP harness binding for a matched conversation.
- For `type: "acp"`, `match.peer.id` is the canonical conversation key:
- Discord channel/thread: raw channel/thread ID.
- Telegram topic: `chatId:topic:topicId`.
- `bindings[].acp.backend` is optional. Backend fallback order:
1. `bindings[].acp.backend`
2. `agents.list[].runtime.acp.backend`
3. global `acp.backend`
- `mode`, `cwd`, and `label` follow the same override pattern (`binding override -> agent runtime default -> global/default behavior`).
- Keep existing `session.threadBindings.*` and `channels.discord.threadBindings.*` for temporary binding policies.
- Persistent entries declare desired state; runtime reconciles to actual ACP sessions/bindings.
- One active ACP binding per conversation node is the intended model.
- Backward compatibility: missing `type` is interpreted as `route` for legacy entries.
### Backend Selection
- ACP session initialization already uses configured backend selection during spawn (`acp.backend` today).
- This proposal extends spawn/reconcile logic to prefer typed ACP binding overrides:
- `bindings[].acp.backend` for conversation-local override.
- `agents.list[].runtime.acp.backend` for per-agent defaults.
- If no override exists, keep current behavior (`acp.backend` default).
## Architecture Fit in Current System
### Reuse existing components
- `SessionBindingService` already supports channel-agnostic conversation references.
- ACP spawn/bind flows already support binding through service APIs.
- Telegram already carries topic/thread context via `MessageThreadId` and `chatId`.
### New/extended components
- **Telegram binding adapter** (parallel to Discord adapter):
- register adapter per Telegram account,
- resolve/list/bind/unbind/touch by canonical conversation ID.
- **Typed binding resolver/index**:
- split `bindings[]` into `route` and `acp` views,
- keep `resolveAgentRoute` on `route` bindings only,
- resolve persistent ACP intent from `acp` bindings only.
- **Inbound binding resolution for Telegram**:
- resolve bound session before route finalization (Discord already does this).
- **Persistent binding reconciler**:
- on startup: load configured top-level `type: "acp"` bindings, ensure ACP sessions exist, ensure bindings exist.
- on config change: apply deltas safely.
- **Cutover model**:
- no channel-local ACP binding fallback is read,
- persistent ACP bindings are sourced only from top-level `bindings[].type="acp"` entries.
## Phased Delivery
### Phase 1: Typed binding schema foundation
- Extend config schema to support `bindings[].type` discriminator:
- `route`,
- `acp` with optional `acp` override object (`mode`, `backend`, `cwd`, `label`).
- Extend agent schema with runtime descriptor to mark ACP-native agents (`agents.list[].runtime.type`).
- Add parser/indexer split for route vs ACP bindings.
### Phase 2: Runtime resolution + Discord/Telegram parity
- Resolve persistent ACP bindings from top-level `type: "acp"` entries for:
- Discord channels/threads,
- Telegram forum topics (`chatId:topic:topicId` canonical IDs).
- Implement Telegram binding adapter and inbound bound-session override parity with Discord.
- Do not include Telegram direct/private topic variants in this phase.
### Phase 3: Command parity and resets
- Align `/acp`, `/new`, `/reset`, and `/focus` behavior in bound Telegram/Discord conversations.
- Ensure binding survives reset flows as configured.
### Phase 4: Hardening
- Better diagnostics (`/acp status`, startup reconciliation logs).
- Conflict handling and health checks.
## Guardrails and Policy
- Respect ACP enablement and sandbox restrictions exactly as today.
- Keep explicit account scoping (`accountId`) to avoid cross-account bleed.
- Fail closed on ambiguous routing.
- Keep mention/access policy behavior explicit per channel config.
## Testing Plan
- Unit:
- conversation ID normalization (especially Telegram topic IDs),
- reconciler create/update/delete paths,
- `/acp bind --persist` and unbind flows.
- Integration:
- inbound Telegram topic -> bound ACP session resolution,
- inbound Discord channel/thread -> persistent binding precedence.
- Regression:
- temporary bindings continue to work,
- unbound channels/topics keep current routing behavior.
## Open Questions
- Should `/acp spawn --thread auto` in Telegram topic default to `here`?
- Should persistent bindings always bypass mention-gating in bound conversations, or require explicit `requireMention=false`?
- Should `/focus` gain `--persist` as an alias for `/acp bind --persist`?
## Rollout
- Ship as opt-in per conversation (`bindings[].type="acp"` entry present).
- Start with Discord + Telegram only.
- Add docs with examples for:
- “one channel/topic per agent”
- “multiple channels/topics per same agent with different `cwd`
- “team naming patterns (`codex-1`, `claude-repo-x`)".

View File

@@ -0,0 +1,89 @@
---
summary: "Proposal: long-term command authorization model for ACP-bound conversations"
read_when:
- Designing native command auth behavior in Telegram/Discord ACP-bound channels/topics
title: "ACP Bound Command Authorization (Proposal)"
---
# ACP Bound Command Authorization (Proposal)
Status: Proposed, **not implemented yet**.
This document describes a long-term authorization model for native commands in
ACP-bound conversations. It is an experiments proposal and does not replace
current production behavior.
For implemented behavior, read source and tests in:
- `src/telegram/bot-native-commands.ts`
- `src/discord/monitor/native-command.ts`
- `src/auto-reply/reply/commands-core.ts`
## Problem
Today we have command-specific checks (for example `/new` and `/reset`) that
need to work inside ACP-bound channels/topics even when allowlists are empty.
This solves immediate UX pain, but command-name-based exceptions do not scale.
## Long-term shape
Move command authorization from ad-hoc handler logic to command metadata plus a
shared policy evaluator.
### 1) Add auth policy metadata to command definitions
Each command definition should declare an auth policy. Example shape:
```ts
type CommandAuthPolicy =
| { mode: "owner_or_allowlist" } // default, current strict behavior
| { mode: "bound_acp_or_owner_or_allowlist" } // allow in explicitly bound ACP conversations
| { mode: "owner_only" };
```
`/new` and `/reset` would use `bound_acp_or_owner_or_allowlist`.
Most other commands would remain `owner_or_allowlist`.
### 2) Share one evaluator across channels
Introduce one helper that evaluates command auth using:
- command policy metadata
- sender authorization state
- resolved conversation binding state
Both Telegram and Discord native handlers should call the same helper to avoid
behavior drift.
### 3) Use binding-match as the bypass boundary
When policy allows bound ACP bypass, authorize only if a configured binding
match was resolved for the current conversation (not just because current
session key looks ACP-like).
This keeps the boundary explicit and minimizes accidental widening.
## Why this is better
- Scales to future commands without adding more command-name conditionals.
- Keeps behavior consistent across channels.
- Preserves current security model by requiring explicit binding match.
- Keeps allowlists optional hardening instead of a universal requirement.
## Rollout plan (future)
1. Add command auth policy field to command registry types and command data.
2. Implement shared evaluator and migrate Telegram + Discord native handlers.
3. Move `/new` and `/reset` to metadata-driven policy.
4. Add tests per policy mode and channel surface.
## Non-goals
- This proposal does not change ACP session lifecycle behavior.
- This proposal does not require allowlists for all ACP-bound commands.
- This proposal does not change existing route binding semantics.
## Note
This proposal is intentionally additive and does not delete or replace existing
experiments documents.

View File

@@ -207,6 +207,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for forum topics (use canonical `chatId:topic:topicId` in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings).
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
- Retry policy: see [Retry policy](/concepts/retry).
@@ -314,6 +315,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings).
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default).
@@ -1271,6 +1273,15 @@ scripts/sandbox-browser-setup.sh # optional browser image
},
groupChat: { mentionPatterns: ["@openclaw"] },
sandbox: { mode: "off" },
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
subagents: { allowAgents: ["*"] },
tools: {
profile: "coding",
@@ -1288,6 +1299,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
- `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only).
@@ -1316,10 +1328,12 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul
### Binding match fields
- `type` (optional): `route` for normal routing (missing type defaults to route), `acp` for persistent ACP conversation bindings.
- `match.channel` (required)
- `match.accountId` (optional; `*` = any account; omitted = default account)
- `match.peer` (optional; `{ kind: direct|group|channel, id }`)
- `match.guildId` / `match.teamId` (optional; channel-specific)
- `acp` (optional; only for `type: "acp"`): `{ mode, label, cwd, backend }`
**Deterministic match order:**
@@ -1332,6 +1346,8 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul
Within each tier, the first matching `bindings` entry wins.
For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`match.channel` + account + `match.peer.id`) and does not use the route binding tier order above.
### Per-agent access profiles
<Accordion title="Full access (no sandbox)">

View File

@@ -3,6 +3,7 @@ summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini
read_when:
- Running coding harnesses through ACP
- Setting up thread-bound ACP sessions on thread-capable channels
- Binding Discord channels or Telegram forum topics to persistent ACP sessions
- Troubleshooting ACP backend and plugin wiring
- Operating /acp commands from chat
title: "ACP Agents"
@@ -85,6 +86,126 @@ Required feature flags for thread-bound ACP:
- Current built-in support: Discord.
- Plugin channels can add support through the same binding interface.
## Channel specific settings
For non-ephemeral workflows, configure persistent ACP bindings in top-level `bindings[]` entries.
### Binding model
- `bindings[].type="acp"` marks a persistent ACP conversation binding.
- `bindings[].match` identifies the target conversation:
- Discord channel or thread: `match.channel="discord"` + `match.peer.id="<channelOrThreadId>"`
- Telegram forum topic: `match.channel="telegram"` + `match.peer.id="<chatId>:topic:<topicId>"`
- `bindings[].agentId` is the owning OpenClaw agent id.
- Optional ACP overrides live under `bindings[].acp`:
- `mode` (`persistent` or `oneshot`)
- `label`
- `cwd`
- `backend`
### Runtime defaults per agent
Use `agents.list[].runtime` to define ACP defaults once per agent:
- `agents.list[].runtime.type="acp"`
- `agents.list[].runtime.acp.agent` (harness id, for example `codex` or `claude`)
- `agents.list[].runtime.acp.backend`
- `agents.list[].runtime.acp.mode`
- `agents.list[].runtime.acp.cwd`
Override precedence for ACP bound sessions:
1. `bindings[].acp.*`
2. `agents.list[].runtime.acp.*`
3. global ACP defaults (for example `acp.backend`)
Example:
```json5
{
agents: {
list: [
{
id: "codex",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
},
{
id: "claude",
runtime: {
type: "acp",
acp: { agent: "claude", backend: "acpx", mode: "persistent" },
},
},
],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "222222222222222222" },
},
acp: { label: "codex-main" },
},
{
type: "acp",
agentId: "claude",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "-1001234567890:topic:42" },
},
acp: { cwd: "/workspace/repo-b" },
},
{
type: "route",
agentId: "main",
match: { channel: "discord", accountId: "default" },
},
{
type: "route",
agentId: "main",
match: { channel: "telegram", accountId: "default" },
},
],
channels: {
discord: {
guilds: {
"111111111111111111": {
channels: {
"222222222222222222": { requireMention: false },
},
},
},
},
telegram: {
groups: {
"-1001234567890": {
topics: { "42": { requireMention: false } },
},
},
},
},
}
```
Behavior:
- OpenClaw ensures the configured ACP session exists before use.
- Messages in that channel or topic route to the configured ACP session.
- In bound conversations, `/new` and `/reset` reset the same ACP session key in place.
- Temporary runtime bindings (for example created by thread-focus flows) still apply where present.
## Start ACP sessions (interfaces)
### From `sessions_spawn`

View File

@@ -0,0 +1,80 @@
export type ParsedTelegramTopicConversation = {
chatId: string;
topicId: string;
canonicalConversationId: string;
};
function normalizeText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
return `${value}`.trim();
}
return "";
}
export function parseTelegramChatIdFromTarget(raw: unknown): string | undefined {
const text = normalizeText(raw);
if (!text) {
return undefined;
}
const match = text.match(/^telegram:(-?\d+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
export function buildTelegramTopicConversationId(params: {
chatId: string;
topicId: string;
}): string | null {
const chatId = params.chatId.trim();
const topicId = params.topicId.trim();
if (!/^-?\d+$/.test(chatId) || !/^\d+$/.test(topicId)) {
return null;
}
return `${chatId}:topic:${topicId}`;
}
export function parseTelegramTopicConversation(params: {
conversationId: string;
parentConversationId?: string;
}): ParsedTelegramTopicConversation | null {
const conversation = params.conversationId.trim();
const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/);
if (directMatch?.[1] && directMatch[2]) {
const canonicalConversationId = buildTelegramTopicConversationId({
chatId: directMatch[1],
topicId: directMatch[2],
});
if (!canonicalConversationId) {
return null;
}
return {
chatId: directMatch[1],
topicId: directMatch[2],
canonicalConversationId,
};
}
if (!/^\d+$/.test(conversation)) {
return null;
}
const parent = params.parentConversationId?.trim();
if (!parent || !/^-?\d+$/.test(parent)) {
return null;
}
const canonicalConversationId = buildTelegramTopicConversationId({
chatId: parent,
topicId: conversation,
});
if (!canonicalConversationId) {
return null;
}
return {
chatId: parent,
topicId: conversation,
canonicalConversationId,
};
}

View File

@@ -0,0 +1,198 @@
import type { OpenClawConfig } from "../config/config.js";
import type { SessionAcpMeta } from "../config/sessions/types.js";
import { logVerbose } from "../globals.js";
import { getAcpSessionManager } from "./control-plane/manager.js";
import { resolveAcpAgentFromSessionKey } from "./control-plane/manager.utils.js";
import { resolveConfiguredAcpBindingSpecBySessionKey } from "./persistent-bindings.resolve.js";
import {
buildConfiguredAcpSessionKey,
normalizeText,
type ConfiguredAcpBindingSpec,
} from "./persistent-bindings.types.js";
import { readAcpSessionEntry } from "./runtime/session-meta.js";
function sessionMatchesConfiguredBinding(params: {
cfg: OpenClawConfig;
spec: ConfiguredAcpBindingSpec;
meta: SessionAcpMeta;
}): boolean {
const desiredAgent = (params.spec.acpAgentId ?? params.spec.agentId).trim().toLowerCase();
const currentAgent = (params.meta.agent ?? "").trim().toLowerCase();
if (!currentAgent || currentAgent !== desiredAgent) {
return false;
}
if (params.meta.mode !== params.spec.mode) {
return false;
}
const desiredBackend = params.spec.backend?.trim() || params.cfg.acp?.backend?.trim() || "";
if (desiredBackend) {
const currentBackend = (params.meta.backend ?? "").trim();
if (!currentBackend || currentBackend !== desiredBackend) {
return false;
}
}
const desiredCwd = params.spec.cwd?.trim();
if (desiredCwd !== undefined) {
const currentCwd = (params.meta.runtimeOptions?.cwd ?? params.meta.cwd ?? "").trim();
if (desiredCwd !== currentCwd) {
return false;
}
}
return true;
}
export async function ensureConfiguredAcpBindingSession(params: {
cfg: OpenClawConfig;
spec: ConfiguredAcpBindingSpec;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
const acpManager = getAcpSessionManager();
try {
const resolution = acpManager.resolveSession({
cfg: params.cfg,
sessionKey,
});
if (
resolution.kind === "ready" &&
sessionMatchesConfiguredBinding({
cfg: params.cfg,
spec: params.spec,
meta: resolution.meta,
})
) {
return {
ok: true,
sessionKey,
};
}
if (resolution.kind !== "none") {
await acpManager.closeSession({
cfg: params.cfg,
sessionKey,
reason: "config-binding-reconfigure",
clearMeta: false,
allowBackendUnavailable: true,
requireAcpSession: false,
});
}
await acpManager.initializeSession({
cfg: params.cfg,
sessionKey,
agent: params.spec.acpAgentId ?? params.spec.agentId,
mode: params.spec.mode,
cwd: params.spec.cwd,
backendId: params.spec.backend,
});
return {
ok: true,
sessionKey,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
);
return {
ok: false,
sessionKey,
error: message,
};
}
}
export async function resetAcpSessionInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
reason: "new" | "reset";
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return {
ok: false,
skipped: true,
};
}
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
cfg: params.cfg,
sessionKey,
});
const meta = readAcpSessionEntry({
cfg: params.cfg,
sessionKey,
})?.acp;
if (!meta) {
if (configuredBinding) {
const ensured = await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec: configuredBinding,
});
if (ensured.ok) {
return { ok: true };
}
return {
ok: false,
error: ensured.error,
};
}
return {
ok: false,
skipped: true,
};
}
const acpManager = getAcpSessionManager();
const agent =
normalizeText(meta.agent) ??
configuredBinding?.acpAgentId ??
configuredBinding?.agentId ??
resolveAcpAgentFromSessionKey(sessionKey, "main");
const mode = meta.mode === "oneshot" ? "oneshot" : "persistent";
const runtimeOptions = { ...meta.runtimeOptions };
const cwd = normalizeText(runtimeOptions.cwd ?? meta.cwd);
try {
await acpManager.closeSession({
cfg: params.cfg,
sessionKey,
reason: `${params.reason}-in-place-reset`,
clearMeta: false,
allowBackendUnavailable: true,
requireAcpSession: false,
});
await acpManager.initializeSession({
cfg: params.cfg,
sessionKey,
agent,
mode,
cwd,
backendId: normalizeText(meta.backend) ?? normalizeText(params.cfg.acp?.backend),
});
const runtimeOptionsPatch = Object.fromEntries(
Object.entries(runtimeOptions).filter(([, value]) => value !== undefined),
) as SessionAcpMeta["runtimeOptions"];
if (runtimeOptionsPatch && Object.keys(runtimeOptionsPatch).length > 0) {
await acpManager.updateSessionRuntimeOptions({
cfg: params.cfg,
sessionKey,
patch: runtimeOptionsPatch,
});
}
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`);
return {
ok: false,
error: message,
};
}
}

View File

@@ -0,0 +1,341 @@
import { listAcpBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentAcpBinding } from "../config/types.js";
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { parseTelegramTopicConversation } from "./conversation-id.js";
import {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
toConfiguredAcpBindingRecord,
type ConfiguredAcpBindingChannel,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "discord" || normalized === "telegram") {
return normalized;
}
return null;
}
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
}
if (trimmed === "*") {
return 1;
}
return normalizeAccountId(trimmed) === actual ? 2 : 0;
}
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
const id = binding.match.peer?.id?.trim();
return id ? id : null;
}
function parseConfiguredBindingSessionKey(params: {
sessionKey: string;
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
const parsed = parseAgentSessionKey(params.sessionKey);
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
if (!rest) {
return null;
}
const tokens = rest.split(":");
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
return null;
}
const channel = normalizeBindingChannel(tokens[2]);
if (!channel) {
return null;
}
const accountId = normalizeAccountId(tokens[3]);
return {
channel,
accountId,
};
}
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
cwd?: string;
backend?: string;
} {
const agent = params.cfg.agents?.list?.find(
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
);
if (!agent || agent.runtime?.type !== "acp") {
return {};
}
return {
acpAgentId: normalizeText(agent.runtime.acp?.agent),
mode: normalizeText(agent.runtime.acp?.mode),
cwd: normalizeText(agent.runtime.acp?.cwd),
backend: normalizeText(agent.runtime.acp?.backend),
};
}
function toConfiguredBindingSpec(params: {
cfg: OpenClawConfig;
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
binding: AgentAcpBinding;
}): ConfiguredAcpBindingSpec {
const accountId = normalizeAccountId(params.accountId);
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
cfg: params.cfg,
ownerAgentId: agentId,
});
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
return {
channel: params.channel,
accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
agentId,
acpAgentId,
mode,
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
label: bindingOverrides.label,
};
}
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): ConfiguredAcpBindingSpec | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
if (!parsedSessionKey) {
return null;
}
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
for (const binding of listAcpBindings(params.cfg)) {
const channel = normalizeBindingChannel(binding.match.channel);
if (!channel || channel !== parsedSessionKey.channel) {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
binding.match.accountId,
parsedSessionKey.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const targetConversationId = resolveBindingConversationId(binding);
if (!targetConversationId) {
continue;
}
if (channel === "discord") {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "discord",
accountId: parsedSessionKey.accountId,
conversationId: targetConversationId,
binding,
});
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
if (accountMatchPriority === 2) {
return spec;
}
if (!wildcardMatch) {
wildcardMatch = spec;
}
}
continue;
}
const parsedTopic = parseTelegramTopicConversation({
conversationId: targetConversationId,
});
if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) {
continue;
}
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "telegram",
accountId: parsedSessionKey.accountId,
conversationId: parsedTopic.canonicalConversationId,
parentConversationId: parsedTopic.chatId,
binding,
});
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
if (accountMatchPriority === 2) {
return spec;
}
if (!wildcardMatch) {
wildcardMatch = spec;
}
}
}
return wildcardMatch;
}
export function resolveConfiguredAcpBindingRecord(params: {
cfg: OpenClawConfig;
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): ResolvedConfiguredAcpBinding | null {
const channel = params.channel.trim().toLowerCase();
const accountId = normalizeAccountId(params.accountId);
const conversationId = params.conversationId.trim();
const parentConversationId = params.parentConversationId?.trim() || undefined;
if (!conversationId) {
return null;
}
if (channel === "discord") {
const bindings = listAcpBindings(params.cfg);
const resolveDiscordBindingForConversation = (
targetConversationId: string,
): ResolvedConfiguredAcpBinding | null => {
let wildcardMatch: AgentAcpBinding | null = null;
for (const binding of bindings) {
if (normalizeBindingChannel(binding.match.channel) !== "discord") {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
binding.match.accountId,
accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
continue;
}
if (accountMatchPriority === 2) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "discord",
accountId,
conversationId: targetConversationId,
binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
if (!wildcardMatch) {
wildcardMatch = binding;
}
}
if (wildcardMatch) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "discord",
accountId,
conversationId: targetConversationId,
binding: wildcardMatch,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
return null;
};
const directMatch = resolveDiscordBindingForConversation(conversationId);
if (directMatch) {
return directMatch;
}
if (parentConversationId && parentConversationId !== conversationId) {
const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId);
if (inheritedMatch) {
return inheritedMatch;
}
}
return null;
}
if (channel === "telegram") {
const parsed = parseTelegramTopicConversation({
conversationId,
parentConversationId,
});
if (!parsed || !parsed.chatId.startsWith("-")) {
return null;
}
let wildcardMatch: AgentAcpBinding | null = null;
for (const binding of listAcpBindings(params.cfg)) {
if (normalizeBindingChannel(binding.match.channel) !== "telegram") {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(binding.match.accountId, accountId);
if (accountMatchPriority === 0) {
continue;
}
const targetConversationId = resolveBindingConversationId(binding);
if (!targetConversationId) {
continue;
}
const targetParsed = parseTelegramTopicConversation({
conversationId: targetConversationId,
});
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
continue;
}
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
continue;
}
if (accountMatchPriority === 2) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "telegram",
accountId,
conversationId: parsed.canonicalConversationId,
parentConversationId: parsed.chatId,
binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
if (!wildcardMatch) {
wildcardMatch = binding;
}
}
if (wildcardMatch) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: "telegram",
accountId,
conversationId: parsed.canonicalConversationId,
parentConversationId: parsed.chatId,
binding: wildcardMatch,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
return null;
}
return null;
}

View File

@@ -0,0 +1,76 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
ensureConfiguredAcpBindingSession,
resolveConfiguredAcpBindingRecord,
type ConfiguredAcpBindingChannel,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.js";
export function resolveConfiguredAcpRoute(params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): {
configuredBinding: ResolvedConfiguredAcpBinding | null;
route: ResolvedAgentRoute;
boundSessionKey?: string;
boundAgentId?: string;
} {
const configuredBinding = resolveConfiguredAcpBindingRecord({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (!configuredBinding) {
return {
configuredBinding: null,
route: params.route,
};
}
const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? "";
if (!boundSessionKey) {
return {
configuredBinding,
route: params.route,
};
}
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
return {
configuredBinding,
boundSessionKey,
boundAgentId,
route: {
...params.route,
sessionKey: boundSessionKey,
agentId: boundAgentId,
matchedBy: "binding.channel",
},
};
}
export async function ensureConfiguredAcpRouteReady(params: {
cfg: OpenClawConfig;
configuredBinding: ResolvedConfiguredAcpBinding | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (!params.configuredBinding) {
return { ok: true };
}
const ensured = await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec: params.configuredBinding.spec,
});
if (ensured.ok) {
return { ok: true };
}
return {
ok: false,
error: ensured.error ?? "unknown error",
};
}

View File

@@ -0,0 +1,639 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const managerMocks = vi.hoisted(() => ({
resolveSession: vi.fn(),
closeSession: vi.fn(),
initializeSession: vi.fn(),
updateSessionRuntimeOptions: vi.fn(),
}));
const sessionMetaMocks = vi.hoisted(() => ({
readAcpSessionEntry: vi.fn(),
}));
vi.mock("./control-plane/manager.js", () => ({
getAcpSessionManager: () => ({
resolveSession: managerMocks.resolveSession,
closeSession: managerMocks.closeSession,
initializeSession: managerMocks.initializeSession,
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
}),
}));
vi.mock("./runtime/session-meta.js", () => ({
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
}));
import {
buildConfiguredAcpSessionKey,
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey,
} from "./persistent-bindings.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "codex" }, { id: "claude" }],
},
} satisfies OpenClawConfig;
beforeEach(() => {
managerMocks.resolveSession.mockReset();
managerMocks.closeSession.mockReset().mockResolvedValue({
runtimeClosed: true,
metaCleared: true,
});
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
});
describe("resolveConfiguredAcpBindingRecord", () => {
it("resolves discord channel ACP binding from top-level typed bindings", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
cwd: "/repo/openclaw",
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
expect(resolved?.spec.channel).toBe("discord");
expect(resolved?.spec.conversationId).toBe("1478836151241412759");
expect(resolved?.spec.agentId).toBe("codex");
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:");
expect(resolved?.record.metadata?.source).toBe("config");
});
it("falls back to parent discord channel when conversation is a thread id", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "channel-parent-1" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "thread-123",
parentConversationId: "channel-parent-1",
});
expect(resolved?.spec.conversationId).toBe("channel-parent-1");
expect(resolved?.record.conversation.conversationId).toBe("channel-parent-1");
});
it("prefers direct discord thread binding over parent channel fallback", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "channel-parent-1" },
},
},
{
type: "acp",
agentId: "claude",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "thread-123" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "thread-123",
parentConversationId: "channel-parent-1",
});
expect(resolved?.spec.conversationId).toBe("thread-123");
expect(resolved?.spec.agentId).toBe("claude");
});
it("prefers exact account binding over wildcard for the same discord conversation", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "*",
peer: { kind: "channel", id: "1478836151241412759" },
},
},
{
type: "acp",
agentId: "claude",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
expect(resolved?.spec.agentId).toBe("claude");
});
it("returns null when no top-level ACP binding matches the conversation", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "different-channel" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "thread-123",
parentConversationId: "channel-parent-1",
});
expect(resolved).toBeNull();
});
it("resolves telegram forum topic bindings using canonical conversation ids", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "claude",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "-1001234567890:topic:42" },
},
acp: {
backend: "acpx",
},
},
],
} satisfies OpenClawConfig;
const canonical = resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
});
const splitIds = resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "42",
parentConversationId: "-1001234567890",
});
expect(canonical?.spec.conversationId).toBe("-1001234567890:topic:42");
expect(splitIds?.spec.conversationId).toBe("-1001234567890:topic:42");
expect(canonical?.spec.agentId).toBe("claude");
expect(canonical?.spec.backend).toBe("acpx");
expect(splitIds?.record.targetSessionKey).toBe(canonical?.record.targetSessionKey);
});
it("skips telegram non-group topic configs", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "claude",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "123456789:topic:42" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "123456789:topic:42",
});
expect(resolved).toBeNull();
});
it("applies agent runtime ACP defaults for bound conversations", () => {
const cfg = {
...baseCfg,
agents: {
list: [
{ id: "main" },
{
id: "coding",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "oneshot",
cwd: "/workspace/repo-a",
},
},
},
],
},
bindings: [
{
type: "acp",
agentId: "coding",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
expect(resolved?.spec.agentId).toBe("coding");
expect(resolved?.spec.acpAgentId).toBe("codex");
expect(resolved?.spec.mode).toBe("oneshot");
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
expect(resolved?.spec.backend).toBe("acpx");
});
});
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
it("maps a configured discord binding session key back to its spec", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
backend: "acpx",
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
expect(spec?.channel).toBe("discord");
expect(spec?.conversationId).toBe("1478836151241412759");
expect(spec?.agentId).toBe("codex");
expect(spec?.backend).toBe("acpx");
});
it("returns null for unknown session keys", () => {
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg: baseCfg,
sessionKey: "agent:main:acp:binding:discord:default:notfound",
});
expect(spec).toBeNull();
});
it("prefers exact account ACP settings over wildcard when session keys collide", () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "*",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
backend: "wild",
},
},
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
backend: "exact",
},
},
],
} satisfies OpenClawConfig;
const resolved = resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
});
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
expect(spec?.backend).toBe("exact");
});
});
describe("buildConfiguredAcpSessionKey", () => {
it("is deterministic for the same conversation binding", () => {
const sessionKeyA = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent",
});
const sessionKeyB = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent",
});
expect(sessionKeyA).toBe(sessionKeyB);
});
});
describe("ensureConfiguredAcpBindingSession", () => {
it("keeps an existing ready session when configured binding omits cwd", async () => {
const spec = {
channel: "discord" as const,
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent" as const,
};
const sessionKey = buildConfiguredAcpSessionKey(spec);
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey,
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "existing",
mode: "persistent",
runtimeOptions: { cwd: "/workspace/openclaw" },
state: "idle",
lastActivityAt: Date.now(),
},
});
const ensured = await ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured).toEqual({ ok: true, sessionKey });
expect(managerMocks.closeSession).not.toHaveBeenCalled();
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
});
it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => {
const spec = {
channel: "discord" as const,
accountId: "default",
conversationId: "1478836151241412759",
agentId: "codex",
mode: "persistent" as const,
cwd: "/workspace/repo-a",
};
const sessionKey = buildConfiguredAcpSessionKey(spec);
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey,
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "existing",
mode: "persistent",
runtimeOptions: { cwd: "/workspace/other-repo" },
state: "idle",
lastActivityAt: Date.now(),
},
});
const ensured = await ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured).toEqual({ ok: true, sessionKey });
expect(managerMocks.closeSession).toHaveBeenCalledTimes(1);
expect(managerMocks.closeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
clearMeta: false,
}),
);
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
});
it("initializes ACP session with runtime agent override when provided", async () => {
const spec = {
channel: "discord" as const,
accountId: "default",
conversationId: "1478836151241412759",
agentId: "coding",
acpAgentId: "codex",
mode: "persistent" as const,
};
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
const ensured = await ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured.ok).toBe(true);
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
agent: "codex",
}),
);
});
});
describe("resetAcpSessionInPlace", () => {
it("reinitializes from configured binding when ACP metadata is missing", async () => {
const cfg = {
...baseCfg,
bindings: [
{
type: "acp",
agentId: "claude",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478844424791396446" },
},
acp: {
mode: "persistent",
backend: "acpx",
},
},
],
} satisfies OpenClawConfig;
const sessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478844424791396446",
agentId: "claude",
mode: "persistent",
backend: "acpx",
});
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
const result = await resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "new",
});
expect(result).toEqual({ ok: true });
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "claude",
mode: "persistent",
backendId: "acpx",
}),
);
});
it("does not clear ACP metadata before reinitialize succeeds", async () => {
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
agent: "claude",
mode: "persistent",
backend: "acpx",
runtimeOptions: { cwd: "/home/bob/clawd" },
},
});
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
const result = await resetAcpSessionInPlace({
cfg: baseCfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: false, error: "backend unavailable" });
expect(managerMocks.closeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
clearMeta: false,
}),
);
});
it("preserves harness agent ids during in-place reset even when not in agents.list", async () => {
const cfg = {
...baseCfg,
agents: {
list: [{ id: "main" }, { id: "coding" }],
},
} satisfies OpenClawConfig;
const sessionKey = "agent:coding:acp:binding:discord:default:9373ab192b2317f4";
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
agent: "codex",
mode: "persistent",
backend: "acpx",
},
});
const result = await resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: true });
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "codex",
}),
);
});
});

View File

@@ -0,0 +1,19 @@
export {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
toConfiguredAcpBindingRecord,
type AcpBindingConfigShape,
type ConfiguredAcpBindingChannel,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
export {
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
} from "./persistent-bindings.lifecycle.js";
export {
resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey,
} from "./persistent-bindings.resolve.js";

View File

@@ -0,0 +1,105 @@
import { createHash } from "node:crypto";
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
import { sanitizeAgentId } from "../routing/session-key.js";
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
export type ConfiguredAcpBindingChannel = "discord" | "telegram";
export type ConfiguredAcpBindingSpec = {
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
/** Owning OpenClaw agent id (used for session identity/storage). */
agentId: string;
/** ACP harness agent id override (falls back to agentId when omitted). */
acpAgentId?: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
backend?: string;
label?: string;
};
export type ResolvedConfiguredAcpBinding = {
spec: ConfiguredAcpBindingSpec;
record: SessionBindingRecord;
};
export type AcpBindingConfigShape = {
mode?: string;
cwd?: string;
backend?: string;
label?: string;
};
export function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function normalizeMode(value: unknown): AcpRuntimeSessionMode {
const raw = normalizeText(value)?.toLowerCase();
return raw === "oneshot" ? "oneshot" : "persistent";
}
export function normalizeBindingConfig(raw: unknown): AcpBindingConfigShape {
if (!raw || typeof raw !== "object") {
return {};
}
const shape = raw as AcpBindingConfigShape;
const mode = normalizeText(shape.mode);
return {
mode: mode ? normalizeMode(mode) : undefined,
cwd: normalizeText(shape.cwd),
backend: normalizeText(shape.backend),
label: normalizeText(shape.label),
};
}
function buildBindingHash(params: {
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
}): string {
return createHash("sha256")
.update(`${params.channel}:${params.accountId}:${params.conversationId}`)
.digest("hex")
.slice(0, 16);
}
export function buildConfiguredAcpSessionKey(spec: ConfiguredAcpBindingSpec): string {
const hash = buildBindingHash({
channel: spec.channel,
accountId: spec.accountId,
conversationId: spec.conversationId,
});
return `agent:${sanitizeAgentId(spec.agentId)}:acp:binding:${spec.channel}:${spec.accountId}:${hash}`;
}
export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): SessionBindingRecord {
return {
bindingId: `config:acp:${spec.channel}:${spec.accountId}:${spec.conversationId}`,
targetSessionKey: buildConfiguredAcpSessionKey(spec),
targetKind: "session",
conversation: {
channel: spec.channel,
accountId: spec.accountId,
conversationId: spec.conversationId,
parentConversationId: spec.parentConversationId,
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: spec.mode,
agentId: spec.agentId,
...(spec.acpAgentId ? { acpAgentId: spec.acpAgentId } : {}),
label: spec.label,
...(spec.backend ? { backend: spec.backend } : {}),
...(spec.cwd ? { cwd: spec.cwd } : {}),
},
};
}

View File

@@ -0,0 +1,75 @@
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
function normalizeText(value: string | undefined | null): string {
return value?.trim() ?? "";
}
export function resolveEffectiveResetTargetSessionKey(params: {
cfg: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
conversationId?: string | null;
parentConversationId?: string | null;
activeSessionKey?: string | null;
allowNonAcpBindingSessionKey?: boolean;
skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
fallbackToActiveAcpWhenUnbound?: boolean;
}): string | undefined {
const activeSessionKey = normalizeText(params.activeSessionKey);
const activeAcpSessionKey =
activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
const channel = normalizeText(params.channel).toLowerCase();
const conversationId = normalizeText(params.conversationId);
if (!channel || !conversationId) {
return activeAcpSessionKey;
}
const accountId = normalizeText(params.accountId) || DEFAULT_ACCOUNT_ID;
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
const serviceBinding = getSessionBindingService().resolveByConversation({
channel,
accountId,
conversationId,
parentConversationId,
});
const serviceSessionKey =
serviceBinding?.targetKind === "session" ? serviceBinding.targetSessionKey.trim() : "";
if (serviceSessionKey) {
if (allowNonAcpBindingSessionKey) {
return serviceSessionKey;
}
return isAcpSessionKey(serviceSessionKey) ? serviceSessionKey : undefined;
}
if (activeIsNonAcp && params.skipConfiguredFallbackWhenActiveSessionNonAcp) {
return undefined;
}
const configuredBinding = resolveConfiguredAcpBindingRecord({
cfg: params.cfg,
channel,
accountId,
conversationId,
parentConversationId,
});
const configuredSessionKey =
configuredBinding?.record.targetKind === "session"
? configuredBinding.record.targetSessionKey.trim()
: "";
if (configuredSessionKey) {
if (allowNonAcpBindingSessionKey) {
return configuredSessionKey;
}
return isAcpSessionKey(configuredSessionKey) ? configuredSessionKey : undefined;
}
if (params.fallbackToActiveAcpWhenUnbound === false) {
return undefined;
}
return activeAcpSessionKey;
}

View File

@@ -27,10 +27,51 @@ describe("commands-acp context", () => {
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-1",
});
expect(isAcpCommandDiscordChannel(params)).toBe(true);
});
it("resolves discord thread parent from ParentSessionKey when targets point at the thread", () => {
const params = buildCommandTestParams("/acp sessions", baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-42",
AccountId: "work",
MessageThreadId: "thread-42",
ParentSessionKey: "agent:codex:discord:channel:parent-9",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "discord",
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-9",
});
});
it("resolves discord thread parent from native context when ParentSessionKey is absent", () => {
const params = buildCommandTestParams("/acp sessions", baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-42",
AccountId: "work",
MessageThreadId: "thread-42",
ThreadParentId: "parent-11",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "discord",
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-11",
});
});
it("falls back to default account and target-derived conversation id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "slack",
@@ -48,4 +89,23 @@ describe("commands-acp context", () => {
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
expect(isAcpCommandDiscordChannel(params)).toBe(false);
});
it("builds canonical telegram topic conversation ids from originating chat + thread", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1001234567890",
MessageThreadId: "42",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "telegram",
accountId: "default",
threadId: "42",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
});
expect(resolveAcpCommandConversationId(params)).toBe("-1001234567890:topic:42");
});
});

View File

@@ -1,5 +1,10 @@
import {
buildTelegramTopicConversationId,
parseTelegramChatIdFromTarget,
} from "../../../acp/conversation-id.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
function normalizeString(value: unknown): string {
@@ -33,12 +38,84 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
}
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
const threadId = resolveAcpCommandThreadId(params);
const parentConversationId = resolveAcpCommandParentConversationId(params);
if (threadId && parentConversationId) {
const canonical = buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: threadId,
});
if (canonical) {
return canonical;
}
}
if (threadId) {
return threadId;
}
}
return resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
}
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeString(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
const parentId = normalizeString(raw);
if (!parentId) {
return undefined;
}
return parentId;
}
export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
return (
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??
parseTelegramChatIdFromTarget(params.command.to) ??
parseTelegramChatIdFromTarget(params.ctx.To)
);
}
if (channel === DISCORD_THREAD_BINDING_CHANNEL) {
const threadId = resolveAcpCommandThreadId(params);
if (!threadId) {
return undefined;
}
const fromContext = parseDiscordParentChannelFromContext(params.ctx.ThreadParentId);
if (fromContext && fromContext !== threadId) {
return fromContext;
}
const fromParentSession = parseDiscordParentChannelFromSessionKey(params.ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== threadId) {
return fromParentSession;
}
const fromTargets = resolveConversationIdFromTargets({
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
if (fromTargets && fromTargets !== threadId) {
return fromTargets;
}
}
return undefined;
}
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
}
@@ -48,11 +125,14 @@ export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
accountId: string;
threadId?: string;
conversationId?: string;
parentConversationId?: string;
} {
const parentConversationId = resolveAcpCommandParentConversationId(params);
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
conversationId: resolveAcpCommandConversationId(params),
...(parentConversationId ? { parentConversationId } : {}),
};
}

View File

@@ -1,5 +1,5 @@
import { callGateway } from "../../../gateway/call.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { resolveEffectiveResetTargetSessionKey } from "../acp-reset-target.js";
import { resolveRequesterSessionKey } from "../commands-subagents/shared.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
@@ -35,19 +35,22 @@ async function resolveSessionKeyByToken(token: string): Promise<string | null> {
}
export function resolveBoundAcpThreadSessionKey(params: HandleCommandsParams): string | undefined {
const commandTargetSessionKey =
typeof params.ctx.CommandTargetSessionKey === "string"
? params.ctx.CommandTargetSessionKey.trim()
: "";
const activeSessionKey = commandTargetSessionKey || params.sessionKey.trim();
const bindingContext = resolveAcpCommandBindingContext(params);
if (!bindingContext.channel || !bindingContext.conversationId) {
return undefined;
}
const binding = getSessionBindingService().resolveByConversation({
return resolveEffectiveResetTargetSessionKey({
cfg: params.cfg,
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
parentConversationId: bindingContext.parentConversationId,
activeSessionKey,
allowNonAcpBindingSessionKey: true,
skipConfiguredFallbackWhenActiveSessionNonAcp: false,
});
if (!binding || binding.targetKind !== "session") {
return undefined;
}
return binding.targetSessionKey.trim() || undefined;
}
export async function resolveAcpTargetSessionKey(params: {

View File

@@ -1,10 +1,13 @@
import fs from "node:fs/promises";
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { handleAcpCommand } from "./commands-acp.js";
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
@@ -130,6 +133,40 @@ export async function emitResetCommandHooks(params: {
}
}
function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void {
const mutableCtx = ctx as Record<string, unknown>;
mutableCtx.Body = resetTail;
mutableCtx.RawBody = resetTail;
mutableCtx.CommandBody = resetTail;
mutableCtx.BodyForCommands = resetTail;
mutableCtx.BodyForAgent = resetTail;
mutableCtx.BodyStripped = resetTail;
mutableCtx.AcpDispatchTailAfterReset = true;
}
function resolveSessionEntryForHookSessionKey(
sessionStore: HandleCommandsParams["sessionStore"] | undefined,
sessionKey: string,
): HandleCommandsParams["sessionEntry"] | undefined {
if (!sessionStore) {
return undefined;
}
const directEntry = sessionStore[sessionKey];
if (directEntry) {
return directEntry;
}
const normalizedTarget = sessionKey.trim().toLowerCase();
if (!normalizedTarget) {
return undefined;
}
for (const [candidateKey, candidateEntry] of Object.entries(sessionStore)) {
if (candidateKey.trim().toLowerCase() === normalizedTarget) {
return candidateEntry;
}
}
return undefined;
}
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
if (HANDLERS === null) {
HANDLERS = [
@@ -172,6 +209,74 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new";
const resetTail =
resetMatch != null
? params.command.commandBodyNormalized.slice(resetMatch[0].length).trimStart()
: "";
const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params);
const boundAcpKey =
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
? boundAcpSessionKey.trim()
: undefined;
if (boundAcpKey) {
const resetResult = await resetAcpSessionInPlace({
cfg: params.cfg,
sessionKey: boundAcpKey,
reason: commandAction,
});
if (!resetResult.ok && !resetResult.skipped) {
logVerbose(
`acp reset-in-place failed for ${boundAcpKey}: ${resetResult.error ?? "unknown error"}`,
);
}
if (resetResult.ok) {
const hookSessionEntry =
boundAcpKey === params.sessionKey
? params.sessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
const hookPreviousSessionEntry =
boundAcpKey === params.sessionKey
? params.previousSessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: boundAcpKey,
sessionEntry: hookSessionEntry,
previousSessionEntry: hookPreviousSessionEntry,
workspaceDir: params.workspaceDir,
});
if (resetTail) {
applyAcpResetTailContext(params.ctx, resetTail);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyAcpResetTailContext(params.rootCtx, resetTail);
}
return {
shouldContinue: false,
};
}
return {
shouldContinue: false,
reply: { text: "✅ ACP session reset in place." },
};
}
if (resetResult.skipped) {
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset unavailable for this bound conversation. Rebind with /acp bind or /acp spawn.",
},
};
}
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset failed. Check /acp status and try again.",
},
};
}
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,

View File

@@ -26,6 +26,7 @@ export type CommandContext = {
export type HandleCommandsParams = {
ctx: MsgContext;
rootCtx?: MsgContext;
cfg: OpenClawConfig;
command: CommandContext;
agentId?: string;

View File

@@ -104,6 +104,27 @@ vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string };
const resetAcpSessionInPlaceMock = vi.hoisted(() =>
vi.fn(
async (_params: unknown): Promise<ResetAcpSessionInPlaceResult> => ({
ok: false,
skipped: true,
}),
),
);
vi.mock("../../acp/persistent-bindings.js", async () => {
const actual = await vi.importActual<typeof import("../../acp/persistent-bindings.js")>(
"../../acp/persistent-bindings.js",
);
return {
...actual,
resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params),
};
});
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -136,6 +157,11 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir });
}
beforeEach(() => {
resetAcpSessionInPlaceMock.mockReset();
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const);
});
describe("handleCommands gating", () => {
it("blocks gated commands when disabled or not elevated-allowlisted", async () => {
const cases = typedCases<{
@@ -973,6 +999,226 @@ describe("handleCommands hooks", () => {
});
});
describe("handleCommands ACP-bound /new and /reset", () => {
const discordChannelId = "1478836151241412759";
const buildDiscordBoundConfig = (): OpenClawConfig =>
({
commands: { text: true },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: {
kind: "channel",
id: discordChannelId,
},
},
acp: {
mode: "persistent",
},
},
],
channels: {
discord: {
allowFrom: ["*"],
guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } },
},
},
}) as OpenClawConfig;
const buildDiscordBoundParams = (body: string) => {
const params = buildParams(body, buildDiscordBoundConfig(), {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: "default",
SenderId: "12345",
From: "discord:12345",
To: discordChannelId,
OriginatingTo: discordChannelId,
SessionKey: "agent:main:acp:binding:discord:default:feedface",
});
params.sessionKey = "agent:main:acp:binding:discord:default:feedface";
return params;
};
it("handles /new as ACP in-place reset for bound conversations", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const result = await handleCommands(buildDiscordBoundParams("/new"));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
reason: "new",
});
});
it("continues with trailing prompt text after successful ACP-bound /new", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const params = buildDiscordBoundParams("/new continue with deployment");
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply).toBeUndefined();
const mutableCtx = params.ctx as Record<string, unknown>;
expect(mutableCtx.BodyStripped).toBe("continue with deployment");
expect(mutableCtx.CommandBody).toBe("continue with deployment");
expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true);
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
});
it("handles /reset failures without falling back to normal session reset flow", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
const result = await handleCommands(buildDiscordBoundParams("/reset"));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset failed");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
reason: "reset",
});
});
it("does not emit reset hooks when ACP reset fails", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
const result = await handleCommands(buildDiscordBoundParams("/reset"));
expect(result.shouldContinue).toBe(false);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("keeps existing /new behavior for non-ACP sessions", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
const result = await handleCommands(buildParams("/new", cfg));
expect(result.shouldContinue).toBe(true);
expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled();
});
it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => {
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: discordChannelId,
agentId: "codex",
mode: "persistent",
});
const params = buildDiscordBoundParams("/new");
params.sessionKey = fallbackSessionKey;
params.ctx.SessionKey = fallbackSessionKey;
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset unavailable");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
sessionKey: configuredAcpSessionKey,
reason: "new",
});
});
it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: discordChannelId,
agentId: "codex",
mode: "persistent",
});
const fallbackEntry = {
sessionId: "fallback-session-id",
sessionFile: "/tmp/fallback-session.jsonl",
} as SessionEntry;
const configuredEntry = {
sessionId: "configured-acp-session-id",
sessionFile: "/tmp/configured-acp-session.jsonl",
} as SessionEntry;
const params = buildDiscordBoundParams("/new");
params.sessionKey = fallbackSessionKey;
params.ctx.SessionKey = fallbackSessionKey;
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
params.sessionEntry = fallbackEntry;
params.previousSessionEntry = fallbackEntry;
params.sessionStore = {
[fallbackSessionKey]: fallbackEntry,
[configuredAcpSessionKey]: configuredEntry,
};
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "command",
action: "new",
sessionKey: configuredAcpSessionKey,
context: expect.objectContaining({
sessionEntry: configuredEntry,
previousSessionEntry: configuredEntry,
}),
}),
);
hookSpy.mockRestore();
});
it("uses active ACP command target when conversation binding context is missing", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface";
const params = buildParams(
"/new",
{
commands: { text: true },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig,
{
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: "default",
SenderId: "12345",
From: "discord:12345",
},
);
params.sessionKey = "discord:slash:12345";
params.ctx.SessionKey = "discord:slash:12345";
params.ctx.CommandSource = "native";
params.ctx.CommandTargetSessionKey = activeAcpTarget;
params.ctx.To = "user:12345";
params.ctx.OriginatingTo = "user:12345";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
sessionKey: activeAcpTarget,
reason: "new",
});
});
});
describe("handleCommands context", () => {
it("returns expected details for /context commands", async () => {
const cfg = {

View File

@@ -178,7 +178,7 @@ function createAcpRuntime(events: Array<Record<string, unknown>>) {
runtimeSessionName: `${input.sessionKey}:${input.mode}`,
}) as { sessionKey: string; backend: string; runtimeSessionName: string },
),
runTurn: vi.fn(async function* () {
runTurn: vi.fn(async function* (_params: { text?: string }) {
for (const event of events) {
yield event;
}
@@ -912,6 +912,73 @@ describe("dispatchReplyFromConfig", () => {
});
});
it("routes ACP reset tails through ACP after command handling", async () => {
setNoAbort();
const runtime = createAcpRuntime([
{ type: "text_delta", text: "tail accepted" },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
session: {
sendPolicy: {
default: "deny",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
CommandSource: "native",
SessionKey: "discord:slash:owner",
CommandTargetSessionKey: "agent:codex-acp:session-1",
CommandBody: "/new continue with deployment",
BodyForCommands: "/new continue with deployment",
BodyForAgent: "/new continue with deployment",
});
const replyResolver = vi.fn(async (resolverCtx: MsgContext) => {
resolverCtx.Body = "continue with deployment";
resolverCtx.RawBody = "continue with deployment";
resolverCtx.CommandBody = "continue with deployment";
resolverCtx.BodyForCommands = "continue with deployment";
resolverCtx.BodyForAgent = "continue with deployment";
resolverCtx.AcpDispatchTailAfterReset = true;
return undefined;
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(runtime.runTurn).toHaveBeenCalledTimes(1);
expect(runtime.runTurn.mock.calls[0]?.[0]).toMatchObject({
text: "continue with deployment",
});
});
it("does not bypass ACP slash aliases when text commands are disabled on native surfaces", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);

View File

@@ -165,6 +165,7 @@ export async function dispatchReplyFromConfig(params: {
}
const sessionStoreEntry = resolveSessionStoreEntry(ctx, cfg);
const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey;
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto);
const hookRunner = getGlobalHookRunner();
@@ -328,7 +329,7 @@ export async function dispatchReplyFromConfig(params: {
ctx,
cfg,
dispatcher,
sessionKey,
sessionKey: acpDispatchSessionKey,
inboundAudio,
sessionTtsAuto,
ttsChannel,
@@ -434,6 +435,32 @@ export async function dispatchReplyFromConfig(params: {
cfg,
);
if (ctx.AcpDispatchTailAfterReset === true) {
// Command handling prepared a trailing prompt after ACP in-place reset.
// Route that tail through ACP now (same turn) instead of embedded dispatch.
ctx.AcpDispatchTailAfterReset = false;
const acpTailDispatch = await tryDispatchAcpReply({
ctx,
cfg,
dispatcher,
sessionKey: acpDispatchSessionKey,
inboundAudio,
sessionTtsAuto,
ttsChannel,
shouldRouteToOriginating,
originatingChannel,
originatingTo,
shouldSendToolSummaries,
bypassForCommand: false,
onReplyStart: params.replyOptions?.onReplyStart,
recordProcessed,
markIdle,
});
if (acpTailDispatch) {
return acpTailDispatch;
}
}
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false;

View File

@@ -330,7 +330,10 @@ export async function handleInlineActions(params: {
const runCommands = (commandInput: typeof command) =>
handleCommands({
ctx,
// Pass sessionCtx so command handlers can mutate stripped body for same-turn continuation.
ctx: sessionCtx,
// Keep original finalized context in sync when command handlers need outer-dispatch side effects.
rootCtx: ctx,
cfg,
command: commandInput,
agentId,

View File

@@ -6,6 +6,10 @@ import { buildModelAliasIndex } from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../infra/outbound/session-binding-service.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { applyResetModelOverride } from "./session-reset-model.js";
import { drainFormattedSystemEvents } from "./session-updates.js";
@@ -456,6 +460,353 @@ describe("initSessionState RawBody", () => {
expect(result.triggerBodyNormalized).toBe("/NEW KeepThisCase");
});
it("does not rotate local session state for /new on bound ACP sessions", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-reset-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "1478836151241412759",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
});
it("does not rotate local session state for ACP /new when conversation IDs are unavailable", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-reset-no-conversation-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "user:12345",
OriginatingTo: "user:12345",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
expect(result.isNewSession).toBe(false);
});
it("keeps custom reset triggers working on bound ACP sessions", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-custom-reset-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: {
store: storePath,
resetTriggers: ["/fresh"],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/fresh",
CommandBody: "/fresh",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "1478836151241412759",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("keeps normal /new behavior for unbound ACP-shaped session keys", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-unbound-reset-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: "1478836151241412759",
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("does not suppress /new when active conversation binding points to a non-ACP session", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-nonacp-binding-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
const existingSessionId = "session-existing";
const now = Date.now();
const channelId = "1478836151241412759";
const nonAcpFocusSessionKey = "agent:main:discord:channel:focus-target";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
sessionBindingTesting.resetSessionBindingAdaptersForTests();
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
capabilities: { bindSupported: false, unbindSupported: false, placements: ["current"] },
listBySession: () => [],
resolveByConversation: (ref) => {
if (ref.conversationId !== channelId) {
return null;
}
return {
bindingId: "focus-binding",
targetSessionKey: nonAcpFocusSessionKey,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: channelId,
},
status: "active",
boundAt: now,
};
},
});
try {
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: channelId,
SessionKey: sessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
}
});
it("does not suppress /new when active target session key is non-ACP even with configured ACP binding", async () => {
const root = await makeCaseDir("openclaw-rawbody-acp-configured-fallback-target-");
const storePath = path.join(root, "sessions.json");
const channelId = "1478836151241412759";
const fallbackSessionKey = "agent:main:discord:channel:focus-target";
const existingSessionId = "session-existing";
const now = Date.now();
await writeSessionStoreFast(storePath, {
[fallbackSessionKey]: {
sessionId: existingSessionId,
updatedAt: now,
systemSent: true,
},
});
const cfg = {
session: { store: storePath },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: { mode: "persistent" },
},
],
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
RawBody: "/new",
CommandBody: "/new",
Provider: "discord",
Surface: "discord",
SenderId: "12345",
From: "discord:12345",
To: channelId,
SessionKey: fallbackSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.resetTriggered).toBe(true);
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("uses the default per-agent sessions store when config store is unset", async () => {
const root = await makeCaseDir("openclaw-session-store-default-");
const stateDir = path.join(root, ".openclaw");

View File

@@ -1,5 +1,9 @@
import crypto from "node:crypto";
import path from "node:path";
import {
buildTelegramTopicConversationId,
parseTelegramChatIdFromTarget,
} from "../../acp/conversation-id.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -24,13 +28,15 @@ import {
} from "../../config/sessions.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { archiveSessionTranscripts } from "../../gateway/session-utils.fs.js";
import { resolveConversationIdFromTargets } from "../../infra/outbound/conversation-id.js";
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { normalizeMainKey, parseAgentSessionKey } from "../../routing/session-key.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
import { normalizeInboundTextNewlines } from "./inbound-text.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import {
@@ -62,6 +68,124 @@ export type SessionInitResult = {
triggerBodyNormalized: string;
};
function normalizeSessionText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
return `${value}`.trim();
}
return "";
}
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeSessionText(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
function resolveAcpResetBindingContext(ctx: MsgContext): {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const channelRaw = normalizeSessionText(
ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "",
).toLowerCase();
if (!channelRaw) {
return null;
}
const accountId = normalizeSessionText(ctx.AccountId) || "default";
const normalizedThreadId =
ctx.MessageThreadId != null ? normalizeSessionText(String(ctx.MessageThreadId)) : "";
if (channelRaw === "telegram") {
const parentConversationId =
parseTelegramChatIdFromTarget(ctx.OriginatingTo) ?? parseTelegramChatIdFromTarget(ctx.To);
let conversationId =
resolveConversationIdFromTargets({
threadId: normalizedThreadId || undefined,
targets: [ctx.OriginatingTo, ctx.To],
}) ?? "";
if (normalizedThreadId && parentConversationId) {
conversationId =
buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: normalizedThreadId,
}) ?? conversationId;
}
if (!conversationId) {
return null;
}
return {
channel: channelRaw,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
const conversationId = resolveConversationIdFromTargets({
threadId: normalizedThreadId || undefined,
targets: [ctx.OriginatingTo, ctx.To],
});
if (!conversationId) {
return null;
}
let parentConversationId: string | undefined;
if (channelRaw === "discord" && normalizedThreadId) {
const fromContext = normalizeSessionText(ctx.ThreadParentId);
if (fromContext && fromContext !== conversationId) {
parentConversationId = fromContext;
} else {
const fromParentSession = parseDiscordParentChannelFromSessionKey(ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== conversationId) {
parentConversationId = fromParentSession;
} else {
const fromTargets = resolveConversationIdFromTargets({
targets: [ctx.OriginatingTo, ctx.To],
});
if (fromTargets && fromTargets !== conversationId) {
parentConversationId = fromTargets;
}
}
}
}
return {
channel: channelRaw,
accountId,
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
function resolveBoundAcpSessionForReset(params: {
cfg: OpenClawConfig;
ctx: MsgContext;
}): string | undefined {
const activeSessionKey = normalizeSessionText(params.ctx.SessionKey);
const bindingContext = resolveAcpResetBindingContext(params.ctx);
return resolveEffectiveResetTargetSessionKey({
cfg: params.cfg,
channel: bindingContext?.channel,
accountId: bindingContext?.accountId,
conversationId: bindingContext?.conversationId,
parentConversationId: bindingContext?.parentConversationId,
activeSessionKey,
allowNonAcpBindingSessionKey: false,
skipConfiguredFallbackWhenActiveSessionNonAcp: true,
fallbackToActiveAcpWhenUnbound: false,
});
}
export async function initSessionState(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
@@ -140,6 +264,15 @@ export async function initSessionState(params: {
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
const shouldUseAcpInPlaceReset = Boolean(
resolveBoundAcpSessionForReset({
cfg,
ctx: sessionCtxForState,
}),
);
const shouldBypassAcpResetForTrigger = (triggerLower: string): boolean =>
shouldUseAcpInPlaceReset &&
DEFAULT_RESET_TRIGGERS.some((defaultTrigger) => defaultTrigger.toLowerCase() === triggerLower);
// Reset triggers are configured as lowercased commands (e.g. "/new"), but users may type
// "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body.
@@ -155,6 +288,12 @@ export async function initSessionState(params: {
}
const triggerLower = trigger.toLowerCase();
if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) {
if (shouldBypassAcpResetForTrigger(triggerLower)) {
// ACP-bound conversations handle /new and /reset in command handling
// so the bound ACP runtime can be reset in place without rotating the
// normal OpenClaw session/transcript.
break;
}
isNewSession = true;
bodyStripped = "";
resetTriggered = true;
@@ -165,6 +304,9 @@ export async function initSessionState(params: {
trimmedBodyLower.startsWith(triggerPrefixLower) ||
strippedForResetLower.startsWith(triggerPrefixLower)
) {
if (shouldBypassAcpResetForTrigger(triggerLower)) {
break;
}
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
resetTriggered = true;

View File

@@ -133,6 +133,11 @@ export type MsgContext = {
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
/**
* Internal flag: command handling prepared trailing prompt text for ACP dispatch.
* Used for `/new <prompt>` and `/reset <prompt>` on ACP-bound sessions.
*/
AcpDispatchTailAfterReset?: boolean;
/** Gateway client scopes when the message originates from the gateway. */
GatewayClientScopes?: string[];
/** Thread identifier (Telegram topic id or Matrix thread event id). */
@@ -152,6 +157,11 @@ export type MsgContext = {
* The chat/channel/user ID where the reply should be sent.
*/
OriginatingTo?: string;
/**
* Provider-specific parent conversation id for threaded contexts.
* For Discord threads, this is the parent channel id.
*/
ThreadParentId?: string;
/**
* Messages from hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".

View File

@@ -1,18 +1,19 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js";
import type { AgentRouteBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { ChannelChoice } from "./onboard-types.js";
function bindingMatchKey(match: AgentBinding["match"]) {
function bindingMatchKey(match: AgentRouteBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const identityKey = bindingMatchIdentityKey(match);
return [identityKey, accountId].join("|");
}
function bindingMatchIdentityKey(match: AgentBinding["match"]) {
function bindingMatchIdentityKey(match: AgentRouteBinding["match"]) {
const roles = Array.isArray(match.roles)
? Array.from(
new Set(
@@ -34,8 +35,8 @@ function bindingMatchIdentityKey(match: AgentBinding["match"]) {
}
function canUpgradeBindingAccountScope(params: {
existing: AgentBinding;
incoming: AgentBinding;
existing: AgentRouteBinding;
incoming: AgentRouteBinding;
normalizedIncomingAgentId: string;
}): boolean {
if (!params.incoming.match.accountId?.trim()) {
@@ -53,7 +54,7 @@ function canUpgradeBindingAccountScope(params: {
);
}
export function describeBinding(binding: AgentBinding) {
export function describeBinding(binding: AgentRouteBinding) {
const match = binding.match;
const parts = [match.channel];
if (match.accountId) {
@@ -73,27 +74,28 @@ export function describeBinding(binding: AgentBinding) {
export function applyAgentBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
bindings: AgentRouteBinding[],
): {
config: OpenClawConfig;
added: AgentBinding[];
updated: AgentBinding[];
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
added: AgentRouteBinding[];
updated: AgentRouteBinding[];
skipped: AgentRouteBinding[];
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>;
} {
const existing = [...(cfg.bindings ?? [])];
const existingRoutes = [...listRouteBindings(cfg)];
const nonRouteBindings = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
for (const binding of existingRoutes) {
const key = bindingMatchKey(binding.match);
if (!existingMatchMap.has(key)) {
existingMatchMap.set(key, normalizeAgentId(binding.agentId));
}
}
const added: AgentBinding[] = [];
const updated: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
const added: AgentRouteBinding[] = [];
const updated: AgentRouteBinding[] = [];
const skipped: AgentRouteBinding[] = [];
const conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const agentId = normalizeAgentId(binding.agentId);
@@ -108,7 +110,7 @@ export function applyAgentBindings(
continue;
}
const upgradeIndex = existing.findIndex((candidate) =>
const upgradeIndex = existingRoutes.findIndex((candidate) =>
canUpgradeBindingAccountScope({
existing: candidate,
incoming: binding,
@@ -116,12 +118,12 @@ export function applyAgentBindings(
}),
);
if (upgradeIndex >= 0) {
const current = existing[upgradeIndex];
const current = existingRoutes[upgradeIndex];
if (!current) {
continue;
}
const previousKey = bindingMatchKey(current.match);
const upgradedBinding: AgentBinding = {
const upgradedBinding: AgentRouteBinding = {
...current,
agentId,
match: {
@@ -129,7 +131,7 @@ export function applyAgentBindings(
accountId: binding.match.accountId?.trim(),
},
};
existing[upgradeIndex] = upgradedBinding;
existingRoutes[upgradeIndex] = upgradedBinding;
existingMatchMap.delete(previousKey);
existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId);
updated.push(upgradedBinding);
@@ -147,7 +149,7 @@ export function applyAgentBindings(
return {
config: {
...cfg,
bindings: [...existing, ...added],
bindings: [...existingRoutes, ...added, ...nonRouteBindings],
},
added,
updated,
@@ -158,29 +160,30 @@ export function applyAgentBindings(
export function removeAgentBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
bindings: AgentRouteBinding[],
): {
config: OpenClawConfig;
removed: AgentBinding[];
missing: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
removed: AgentRouteBinding[];
missing: AgentRouteBinding[];
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>;
} {
const existing = cfg.bindings ?? [];
const existingRoutes = listRouteBindings(cfg);
const nonRouteBindings = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
const removeIndexes = new Set<number>();
const removed: AgentBinding[] = [];
const missing: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
const removed: AgentRouteBinding[] = [];
const missing: AgentRouteBinding[] = [];
const conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const desiredAgentId = normalizeAgentId(binding.agentId);
const key = bindingMatchKey(binding.match);
let matchedIndex = -1;
let conflictingAgentId: string | null = null;
for (let i = 0; i < existing.length; i += 1) {
for (let i = 0; i < existingRoutes.length; i += 1) {
if (removeIndexes.has(i)) {
continue;
}
const current = existing[i];
const current = existingRoutes[i];
if (!current || bindingMatchKey(current.match) !== key) {
continue;
}
@@ -192,7 +195,7 @@ export function removeAgentBindings(
conflictingAgentId = currentAgentId;
}
if (matchedIndex >= 0) {
const matched = existing[matchedIndex];
const matched = existingRoutes[matchedIndex];
if (matched) {
removeIndexes.add(matchedIndex);
removed.push(matched);
@@ -210,7 +213,8 @@ export function removeAgentBindings(
return { config: cfg, removed, missing, conflicts };
}
const nextBindings = existing.filter((_, index) => !removeIndexes.has(index));
const nextRouteBindings = existingRoutes.filter((_, index) => !removeIndexes.has(index));
const nextBindings = [...nextRouteBindings, ...nonRouteBindings];
return {
config: {
...cfg,
@@ -262,11 +266,11 @@ export function buildChannelBindings(params: {
selection: ChannelChoice[];
config: OpenClawConfig;
accountIds?: Partial<Record<ChannelChoice, string>>;
}): AgentBinding[] {
const bindings: AgentBinding[] = [];
}): AgentRouteBinding[] {
const bindings: AgentRouteBinding[] = [];
const agentId = normalizeAgentId(params.agentId);
for (const channel of params.selection) {
const match: AgentBinding["match"] = { channel };
const match: AgentRouteBinding["match"] = { channel };
const accountId = resolveBindingAccountId({
channel,
config: params.config,
@@ -276,7 +280,7 @@ export function buildChannelBindings(params: {
if (accountId) {
match.accountId = accountId;
}
bindings.push({ agentId, match });
bindings.push({ type: "route", agentId, match });
}
return bindings;
}
@@ -285,8 +289,8 @@ export function parseBindingSpecs(params: {
agentId: string;
specs?: string[];
config: OpenClawConfig;
}): { bindings: AgentBinding[]; errors: string[] } {
const bindings: AgentBinding[] = [];
}): { bindings: AgentRouteBinding[]; errors: string[] } {
const bindings: AgentRouteBinding[] = [];
const errors: string[] = [];
const specs = params.specs ?? [];
const agentId = normalizeAgentId(params.agentId);
@@ -312,11 +316,11 @@ export function parseBindingSpecs(params: {
agentId,
explicitAccountId: accountId,
});
const match: AgentBinding["match"] = { channel };
const match: AgentRouteBinding["match"] = { channel };
if (accountId) {
match.accountId = accountId;
}
bindings.push({ agentId, match });
bindings.push({ type: "route", agentId, match });
}
return { bindings, errors };
}

View File

@@ -1,7 +1,8 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
import { writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import type { AgentBinding } from "../config/types.js";
import type { AgentRouteBinding } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -56,7 +57,7 @@ function hasAgent(cfg: Awaited<ReturnType<typeof requireValidConfig>>, agentId:
return buildAgentSummaries(cfg).some((summary) => summary.id === agentId);
}
function formatBindingOwnerLine(binding: AgentBinding): string {
function formatBindingOwnerLine(binding: AgentRouteBinding): string {
return `${normalizeAgentId(binding.agentId)} <- ${describeBinding(binding)}`;
}
@@ -82,7 +83,7 @@ function resolveTargetAgentIdOrExit(params: {
}
function formatBindingConflicts(
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>,
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>,
): string[] {
return conflicts.map(
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
@@ -171,7 +172,7 @@ export async function agentsBindingsCommand(
return;
}
const filtered = (cfg.bindings ?? []).filter(
const filtered = listRouteBindings(cfg).filter(
(binding) => !filterAgentId || normalizeAgentId(binding.agentId) === filterAgentId,
);
if (opts.json) {
@@ -300,16 +301,18 @@ export async function agentsUnbindCommand(
}
if (opts.all) {
const existing = cfg.bindings ?? [];
const existing = listRouteBindings(cfg);
const removed = existing.filter((binding) => normalizeAgentId(binding.agentId) === agentId);
const kept = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
const keptRoutes = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
const nonRoutes = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
if (removed.length === 0) {
runtime.log(`No bindings to remove for agent "${agentId}".`);
return;
}
const next = {
...cfg,
bindings: kept.length > 0 ? kept : undefined,
bindings:
[...keptRoutes, ...nonRoutes].length > 0 ? [...keptRoutes, ...nonRoutes] : undefined,
};
await writeConfigFile(next);
if (!opts.json) {

View File

@@ -1,5 +1,6 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { AgentBinding } from "../config/types.js";
import { listRouteBindings } from "../config/bindings.js";
import type { AgentRouteBinding } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -81,8 +82,8 @@ export async function agentsListCommand(
}
const summaries = buildAgentSummaries(cfg);
const bindingMap = new Map<string, AgentBinding[]>();
for (const binding of cfg.bindings ?? []) {
const bindingMap = new Map<string, AgentRouteBinding[]>();
for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
const list = bindingMap.get(agentId) ?? [];
list.push(binding);

View File

@@ -10,6 +10,7 @@ import {
loadAgentIdentityFromWorkspace,
parseIdentityMarkdown as parseIdentityMarkdownFile,
} from "../agents/identity-file.js";
import { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -88,7 +89,7 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] {
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
: [defaultAgentId];
const bindingCounts = new Map<string, number>();
for (const binding of cfg.bindings ?? []) {
for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}

View File

@@ -8,6 +8,7 @@ import {
} from "../channels/telegram/allow-from.js";
import { fetchTelegramChatId } from "../channels/telegram/api.js";
import { formatCliCommand } from "../cli/command-format.js";
import { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
@@ -265,7 +266,7 @@ function collectChannelsMissingDefaultAccount(
}
export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] {
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
const bindings = listRouteBindings(cfg);
const warnings: string[] = [];
for (const { channelKey, normalizedAccountIds } of collectChannelsMissingDefaultAccount(cfg)) {

26
src/config/bindings.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { OpenClawConfig } from "./config.js";
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
return binding.type === "acp" ? "acp" : "route";
}
export function isRouteBinding(binding: AgentBinding): binding is AgentRouteBinding {
return normalizeBindingType(binding) === "route";
}
export function isAcpBinding(binding: AgentBinding): binding is AgentAcpBinding {
return normalizeBindingType(binding) === "acp";
}
export function listConfiguredBindings(cfg: OpenClawConfig): AgentBinding[] {
return Array.isArray(cfg.bindings) ? cfg.bindings : [];
}
export function listRouteBindings(cfg: OpenClawConfig): AgentRouteBinding[] {
return listConfiguredBindings(cfg).filter(isRouteBinding);
}
export function listAcpBindings(cfg: OpenClawConfig): AgentAcpBinding[] {
return listConfiguredBindings(cfg).filter(isAcpBinding);
}

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { OpenClawSchema } from "./zod-schema.js";
describe("ACP binding cutover schema", () => {
it("accepts top-level typed ACP bindings with per-agent runtime defaults", () => {
const parsed = OpenClawSchema.safeParse({
agents: {
list: [
{ id: "main", default: true, runtime: { type: "embedded" } },
{
id: "coding",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
},
],
},
bindings: [
{
type: "route",
agentId: "main",
match: { channel: "discord", accountId: "default" },
},
{
type: "acp",
agentId: "coding",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "1478836151241412759" },
},
acp: {
label: "codex-main",
backend: "acpx",
},
},
],
});
expect(parsed.success).toBe(true);
});
it("rejects legacy Discord channel-local ACP binding fields", () => {
const parsed = OpenClawSchema.safeParse({
channels: {
discord: {
guilds: {
"1459246755253325866": {
channels: {
"1478836151241412759": {
bindings: {
acp: {
agentId: "codex",
mode: "persistent",
},
},
},
},
},
},
},
},
});
expect(parsed.success).toBe(false);
});
it("rejects legacy Telegram topic-local ACP binding fields", () => {
const parsed = OpenClawSchema.safeParse({
channels: {
telegram: {
groups: {
"-1001234567890": {
topics: {
"42": {
bindings: {
acp: {
agentId: "codex",
},
},
},
},
},
},
},
},
});
expect(parsed.success).toBe(false);
});
it("rejects ACP bindings without a peer conversation target", () => {
const parsed = OpenClawSchema.safeParse({
bindings: [
{
type: "acp",
agentId: "codex",
match: { channel: "discord", accountId: "default" },
},
],
});
expect(parsed.success).toBe(false);
});
it("rejects ACP bindings on unsupported channels", () => {
const parsed = OpenClawSchema.safeParse({
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "slack",
accountId: "default",
peer: { kind: "channel", id: "C123456" },
},
},
],
});
expect(parsed.success).toBe(false);
});
it("rejects non-canonical Telegram ACP topic peer IDs", () => {
const parsed = OpenClawSchema.safeParse({
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "telegram",
accountId: "default",
peer: { kind: "group", id: "42" },
},
},
],
});
expect(parsed.success).toBe(false);
});
});

View File

@@ -204,6 +204,20 @@ export const FIELD_HELP: Record<string, string> = {
"Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.",
"agents.list":
"Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.",
"agents.list[].runtime":
"Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.",
"agents.list[].runtime.type":
'Runtime type for this agent: "embedded" (default OpenClaw runtime) or "acp" (ACP harness defaults).',
"agents.list[].runtime.acp":
"ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.",
"agents.list[].runtime.acp.agent":
"Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).",
"agents.list[].runtime.acp.backend":
"Optional ACP backend override for this agent's ACP sessions (falls back to global acp.backend).",
"agents.list[].runtime.acp.mode":
"Optional ACP session mode default for this agent (persistent or oneshot).",
"agents.list[].runtime.acp.cwd":
"Optional default working directory for this agent's ACP sessions.",
"agents.list[].identity.avatar":
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
"agents.defaults.heartbeat.suppressToolErrorWarnings":
@@ -397,7 +411,9 @@ export const FIELD_HELP: Record<string, string> = {
"audio.transcription.timeoutSeconds":
"Maximum time allowed for the transcription command to finish before it is aborted. Increase this for longer recordings, and keep it tight in latency-sensitive deployments.",
bindings:
"Static routing bindings that pin inbound conversations to specific agent IDs by match rules. Use bindings for deterministic ownership when dynamic routing should not decide.",
"Top-level binding rules for routing and persistent ACP conversation ownership. Use type=route for normal routing and type=acp for persistent ACP harness bindings.",
"bindings[].type":
'Binding kind. Use "route" (or omit for legacy route entries) for normal routing, and "acp" for persistent ACP conversation bindings.',
"bindings[].agentId":
"Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.",
"bindings[].match":
@@ -418,6 +434,14 @@ export const FIELD_HELP: Record<string, string> = {
"Optional team/workspace ID constraint used by providers that scope chats under teams. Add this when you need bindings isolated to one workspace context.",
"bindings[].match.roles":
"Optional role-based filter list used by providers that attach roles to chat context. Use this to route privileged or operational role traffic to specialized agents.",
"bindings[].acp":
"Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.list[].runtime.acp defaults for the matched conversation.",
"bindings[].acp.mode": "ACP session mode override for this binding (persistent or oneshot).",
"bindings[].acp.label":
"Human-friendly label for ACP status/diagnostics in this bound conversation.",
"bindings[].acp.cwd": "Working directory override for ACP sessions created from this binding.",
"bindings[].acp.backend":
"ACP backend override for this binding (falls back to agent runtime ACP backend, then global acp.backend).",
broadcast:
"Broadcast routing map for sending the same outbound message to multiple peer IDs per source conversation. Keep this minimal and audited because one source can fan out to many destinations.",
"broadcast.strategy":

View File

@@ -56,6 +56,13 @@ export const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar",
"agents.list.*.skills": "Agent Skill Filter",
"agents.list[].runtime": "Agent Runtime",
"agents.list[].runtime.type": "Agent Runtime Type",
"agents.list[].runtime.acp": "Agent ACP Runtime",
"agents.list[].runtime.acp.agent": "Agent ACP Harness Agent",
"agents.list[].runtime.acp.backend": "Agent ACP Backend",
"agents.list[].runtime.acp.mode": "Agent ACP Mode",
"agents.list[].runtime.acp.cwd": "Agent ACP Working Directory",
agents: "Agents",
"agents.defaults": "Agent Defaults",
"agents.list": "Agent List",
@@ -259,6 +266,7 @@ export const FIELD_LABELS: Record<string, string> = {
"audio.transcription.command": "Audio Transcription Command",
"audio.transcription.timeoutSeconds": "Audio Transcription Timeout (sec)",
bindings: "Bindings",
"bindings[].type": "Binding Type",
"bindings[].agentId": "Binding Agent ID",
"bindings[].match": "Binding Match Rule",
"bindings[].match.channel": "Binding Channel",
@@ -269,6 +277,11 @@ export const FIELD_LABELS: Record<string, string> = {
"bindings[].match.guildId": "Binding Guild ID",
"bindings[].match.teamId": "Binding Team ID",
"bindings[].match.roles": "Binding Roles",
"bindings[].acp": "ACP Binding Overrides",
"bindings[].acp.mode": "ACP Binding Mode",
"bindings[].acp.label": "ACP Binding Label",
"bindings[].acp.cwd": "ACP Binding Working Directory",
"bindings[].acp.backend": "ACP Binding Backend",
broadcast: "Broadcast",
"broadcast.strategy": "Broadcast Strategy",
"broadcast.*": "Broadcast Destination List",

View File

@@ -5,6 +5,59 @@ import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
import type { GroupChatConfig } from "./types.messages.js";
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
export type AgentRuntimeAcpConfig = {
/** ACP harness adapter id (for example codex, claude). */
agent?: string;
/** Optional ACP backend override for this agent runtime. */
backend?: string;
/** Optional ACP session mode override. */
mode?: "persistent" | "oneshot";
/** Optional runtime working directory override. */
cwd?: string;
};
export type AgentRuntimeConfig =
| {
type: "embedded";
}
| {
type: "acp";
acp?: AgentRuntimeAcpConfig;
};
export type AgentBindingMatch = {
channel: string;
accountId?: string;
peer?: { kind: ChatType; id: string };
guildId?: string;
teamId?: string;
/** Discord role IDs used for role-based routing. */
roles?: string[];
};
export type AgentRouteBinding = {
/** Missing type is interpreted as route for backward compatibility. */
type?: "route";
agentId: string;
comment?: string;
match: AgentBindingMatch;
};
export type AgentAcpBinding = {
type: "acp";
agentId: string;
comment?: string;
match: AgentBindingMatch;
acp?: {
mode?: "persistent" | "oneshot";
label?: string;
cwd?: string;
backend?: string;
};
};
export type AgentBinding = AgentRouteBinding | AgentAcpBinding;
export type AgentConfig = {
id: string;
default?: boolean;
@@ -32,23 +85,11 @@ export type AgentConfig = {
/** Optional per-agent stream params (e.g. cacheRetention, temperature). */
params?: Record<string, unknown>;
tools?: AgentToolsConfig;
/** Optional runtime descriptor for this agent. */
runtime?: AgentRuntimeConfig;
};
export type AgentsConfig = {
defaults?: AgentDefaultsConfig;
list?: AgentConfig[];
};
export type AgentBinding = {
agentId: string;
comment?: string;
match: {
channel: string;
accountId?: string;
peer?: { kind: ChatType; id: string };
guildId?: string;
teamId?: string;
/** Discord role IDs used for role-based routing. */
roles?: string[];
};
};

View File

@@ -679,6 +679,33 @@ export const MemorySearchSchema = z
.strict()
.optional();
export { AgentModelSchema };
const AgentRuntimeAcpSchema = z
.object({
agent: z.string().optional(),
backend: z.string().optional(),
mode: z.enum(["persistent", "oneshot"]).optional(),
cwd: z.string().optional(),
})
.strict()
.optional();
const AgentRuntimeSchema = z
.union([
z
.object({
type: z.literal("embedded"),
})
.strict(),
z
.object({
type: z.literal("acp"),
acp: AgentRuntimeAcpSchema,
})
.strict(),
])
.optional();
export const AgentEntrySchema = z
.object({
id: z.string(),
@@ -713,6 +740,7 @@ export const AgentEntrySchema = z
.optional(),
sandbox: AgentSandboxSchema,
tools: AgentToolsSchema,
runtime: AgentRuntimeSchema,
})
.strict();

View File

@@ -11,38 +11,85 @@ export const AgentsSchema = z
.strict()
.optional();
export const BindingsSchema = z
.array(
z
const BindingMatchSchema = z
.object({
channel: z.string(),
accountId: z.string().optional(),
peer: z
.object({
agentId: z.string(),
comment: z.string().optional(),
match: z
.object({
channel: z.string(),
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("direct"),
z.literal("group"),
z.literal("channel"),
/** @deprecated Use `direct` instead. Kept for backward compatibility. */
z.literal("dm"),
]),
id: z.string(),
})
.strict()
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
roles: z.array(z.string()).optional(),
})
.strict(),
kind: z.union([
z.literal("direct"),
z.literal("group"),
z.literal("channel"),
/** @deprecated Use `direct` instead. Kept for backward compatibility. */
z.literal("dm"),
]),
id: z.string(),
})
.strict(),
)
.optional();
.strict()
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
roles: z.array(z.string()).optional(),
})
.strict();
const RouteBindingSchema = z
.object({
type: z.literal("route").optional(),
agentId: z.string(),
comment: z.string().optional(),
match: BindingMatchSchema,
})
.strict();
const AcpBindingSchema = z
.object({
type: z.literal("acp"),
agentId: z.string(),
comment: z.string().optional(),
match: BindingMatchSchema,
acp: z
.object({
mode: z.enum(["persistent", "oneshot"]).optional(),
label: z.string().optional(),
cwd: z.string().optional(),
backend: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.superRefine((value, ctx) => {
const peerId = value.match.peer?.id?.trim() ?? "";
if (!peerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match", "peer"],
message: "ACP bindings require match.peer.id to target a concrete conversation.",
});
return;
}
const channel = value.match.channel.trim().toLowerCase();
if (channel !== "discord" && channel !== "telegram") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match", "channel"],
message: 'ACP bindings currently support only "discord" and "telegram" channels.',
});
return;
}
if (channel === "telegram" && !/^-\d+:topic:\d+$/.test(peerId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match", "peer", "id"],
message:
"Telegram ACP bindings require canonical topic IDs in the form -1001234567890:topic:42.",
});
}
});
export const BindingsSchema = z.array(z.union([RouteBindingSchema, AcpBindingSchema])).optional();
export const BroadcastStrategySchema = z.enum(["parallel", "sequential"]);

View File

@@ -0,0 +1,176 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
vi.mock("../../acp/persistent-bindings.js", () => ({
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
ensureConfiguredAcpBindingSessionMock(...args),
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
resolveConfiguredAcpBindingRecordMock(...args),
}));
import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js";
import { preflightDiscordMessage } from "./message-handler.preflight.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
const GUILD_ID = "guild-1";
const CHANNEL_ID = "channel-1";
function createConfiguredDiscordBinding() {
return {
spec: {
channel: "discord",
accountId: "default",
conversationId: CHANNEL_ID,
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:discord:default:channel-1",
targetSessionKey: "agent:codex:acp:binding:discord:default:abc123",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: CHANNEL_ID,
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: "persistent",
agentId: "codex",
},
},
} as const;
}
function createBasePreflightParams(overrides?: Record<string, unknown>) {
const message = {
id: "m-1",
content: "<@bot-1> hello",
timestamp: new Date().toISOString(),
channelId: CHANNEL_ID,
attachments: [],
mentionedUsers: [{ id: "bot-1" }],
mentionedRoles: [],
mentionedEveryone: false,
author: {
id: "user-1",
bot: false,
username: "alice",
},
} as unknown as import("@buape/carbon").Message;
const client = {
fetchChannel: async (channelId: string) => {
if (channelId === CHANNEL_ID) {
return {
id: CHANNEL_ID,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
return {
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: "bot-1",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
data: {
channel_id: CHANNEL_ID,
guild_id: GUILD_ID,
guild: {
id: GUILD_ID,
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
...overrides,
} satisfies Parameters<typeof preflightDiscordMessage>[0];
}
describe("preflightDiscordMessage configured ACP bindings", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
ensureConfiguredAcpBindingSessionMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding());
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:discord:default:abc123",
});
});
it("does not initialize configured ACP bindings for rejected messages", async () => {
const result = await preflightDiscordMessage(
createBasePreflightParams({
guildEntries: {
[GUILD_ID]: {
id: GUILD_ID,
channels: {
[CHANNEL_ID]: {
allow: true,
enabled: false,
},
},
},
},
}),
);
expect(result).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("initializes configured ACP bindings only after preflight accepts the message", async () => {
const result = await preflightDiscordMessage(
createBasePreflightParams({
guildEntries: {
[GUILD_ID]: {
id: GUILD_ID,
channels: {
[CHANNEL_ID]: {
allow: true,
enabled: true,
requireMention: false,
},
},
},
},
}),
);
expect(result).not.toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
});
});

View File

@@ -1,4 +1,8 @@
import { ChannelType, MessageType, type User } from "@buape/carbon";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../../acp/persistent-bindings.route.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import {
@@ -328,8 +332,9 @@ export async function preflightDiscordMessage(
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
? params.data.rawMember.roles.map((roleId: string) => String(roleId))
: [];
const freshCfg = loadConfig();
const route = resolveAgentRoute({
cfg: loadConfig(),
cfg: freshCfg,
channel: "discord",
accountId: params.accountId,
guildId: params.data.guild_id ?? undefined,
@@ -342,13 +347,27 @@ export async function preflightDiscordMessage(
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
});
let threadBinding: SessionBindingRecord | undefined;
if (earlyThreadChannel) {
threadBinding =
getSessionBindingService().resolveByConversation({
channel: "discord",
accountId: params.accountId,
conversationId: messageChannelId,
}) ?? undefined;
threadBinding =
getSessionBindingService().resolveByConversation({
channel: "discord",
accountId: params.accountId,
conversationId: messageChannelId,
parentConversationId: earlyThreadParentId,
}) ?? undefined;
const configuredRoute =
threadBinding == null
? resolveConfiguredAcpRoute({
cfg: freshCfg,
route,
channel: "discord",
accountId: params.accountId,
conversationId: messageChannelId,
parentConversationId: earlyThreadParentId,
})
: null;
const configuredBinding = configuredRoute?.configuredBinding ?? null;
if (!threadBinding && configuredBinding) {
threadBinding = configuredBinding.record;
}
if (
shouldIgnoreBoundThreadWebhookMessage({
@@ -368,8 +387,9 @@ export async function preflightDiscordMessage(
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
matchedBy: "binding.channel" as const,
}
: route;
: (configuredRoute?.route ?? route);
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
if (
isBoundThreadBotSystemMessage({
@@ -739,6 +759,18 @@ export async function preflightDiscordMessage(
logVerbose(`discord: drop message ${message.id} (empty content)`);
return null;
}
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg: freshCfg,
configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`discord: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
return null;
}
}
logDebug(
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,

View File

@@ -7,10 +7,32 @@ import * as pluginCommandsModule from "../../plugins/commands.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ResolveConfiguredAcpBindingRecordFn =
typeof import("../../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
ok: true,
sessionKey: "agent:codex:acp:binding:discord:default:seed",
})),
}));
vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../acp/persistent-bindings.js")>();
return {
...actual,
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
};
});
type MockCommandInteraction = {
user: { id: string; username: string; globalName: string };
channel: { type: ChannelType; id: string };
guild: null;
guild: { id: string; name?: string } | null;
rawData: { id: string; member: { roles: string[] } };
options: {
getString: ReturnType<typeof vi.fn>;
@@ -22,7 +44,13 @@ type MockCommandInteraction = {
client: object;
};
function createInteraction(): MockCommandInteraction {
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
guildId?: string;
guildName?: string;
}): MockCommandInteraction {
const guild = params?.guildId ? { id: params.guildId, name: params.guildName } : null;
return {
user: {
id: "owner",
@@ -30,10 +58,10 @@ function createInteraction(): MockCommandInteraction {
globalName: "Tester",
},
channel: {
type: ChannelType.DM,
id: "dm-1",
type: params?.channelType ?? ChannelType.DM,
id: params?.channelId ?? "dm-1",
},
guild: null,
guild,
rawData: {
id: "interaction-1",
member: { roles: [] },
@@ -62,6 +90,13 @@ function createConfig(): OpenClawConfig {
describe("Discord native plugin command dispatch", () => {
beforeEach(() => {
vi.restoreAllMocks();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:discord:default:seed",
});
});
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
@@ -110,4 +145,192 @@ describe("Discord native plugin command dispatch", () => {
expect.objectContaining({ content: "direct plugin output" }),
);
});
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
const guildId = "1459246755253325866";
const channelId = "1478836151241412759";
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
const cfg = {
commands: {
useAccessGroups: false,
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: {
mode: "persistent",
},
},
],
} as OpenClawConfig;
const commandSpec: NativeCommandSpec = {
name: "status",
description: "Status",
acceptsArgs: false,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId,
guildId,
guildName: "Ops",
});
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "discord",
accountId: "default",
conversationId: channelId,
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:discord:default:1478836151241412759",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: channelId,
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({
counts: {
final: 1,
block: 0,
tool: 0,
},
} as never);
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
const channelId = "dm-1";
const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface";
const cfg = {
commands: {
useAccessGroups: false,
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "direct", id: channelId },
},
acp: {
mode: "persistent",
},
},
],
channels: {
discord: {
dm: { enabled: true, policy: "open" },
},
},
} as OpenClawConfig;
const commandSpec: NativeCommandSpec = {
name: "status",
description: "Status",
acceptsArgs: false,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const interaction = createInteraction({
channelType: ChannelType.DM,
channelId,
});
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "discord",
accountId: "default",
conversationId: channelId,
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:discord:default:dm-1",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: channelId,
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({
counts: {
final: 1,
block: 0,
tool: 0,
},
} as never);
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
});
});

View File

@@ -14,6 +14,10 @@ import {
type StringSelectMenuInteraction,
} from "@buape/carbon";
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../../acp/persistent-bindings.route.js";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import type {
@@ -1542,15 +1546,42 @@ async function dispatchDiscordCommandInteraction(params: {
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const configuredRoute =
threadBinding == null
? resolveConfiguredAcpRoute({
cfg,
route,
channel: "discord",
accountId,
conversationId: channelId,
parentConversationId: threadParentId,
})
: null;
const configuredBinding = configuredRoute?.configuredBinding ?? null;
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg,
configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
await respond("Configured ACP binding is unavailable right now. Please try again.");
return;
}
}
const configuredBoundSessionKey = configuredRoute?.boundSessionKey ?? "";
const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey;
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
...(configuredBinding ? { matchedBy: "binding.channel" as const } : {}),
}
: route;
: (configuredRoute?.route ?? route);
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
@@ -1614,6 +1645,7 @@ async function dispatchDiscordCommandInteraction(params: {
// preserve the real Discord target separately.
OriginatingChannel: "discord" as const,
OriginatingTo: isDirectMessage ? `user:${user.id}` : `channel:${channelId}`,
ThreadParentId: isThreadChannel ? threadParentId : undefined,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({

View File

@@ -43,7 +43,7 @@ describe("ui i18n locale registry", () => {
expect(getNestedTranslation(es, "common", "health")).toBe("Estado");
expect(getNestedTranslation(es, "languages", "de")).toBe("Deutsch (Alemán)");
expect(getNestedTranslation(ptBR, "languages", "es")).toBe("Español (Espanhol)");
expect(getNestedTranslation(zhCN, "common", "health")).toBe("健康状况");
expect(getNestedTranslation(zhCN, "common", "health")).toBe("\u5065\u5eb7\u72b6\u51b5");
expect(await loadLazyLocaleTranslation("en")).toBeNull();
});
});

View File

@@ -1,7 +1,8 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { normalizeChatChannelId } from "../channels/registry.js";
import { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.agents.js";
import type { AgentRouteBinding } from "../config/types.agents.js";
import { normalizeAccountId, normalizeAgentId } from "./session-key.js";
function normalizeBindingChannelId(raw?: string | null): string | null {
@@ -13,11 +14,11 @@ function normalizeBindingChannelId(raw?: string | null): string | null {
return fallback || null;
}
export function listBindings(cfg: OpenClawConfig): AgentBinding[] {
return Array.isArray(cfg.bindings) ? cfg.bindings : [];
export function listBindings(cfg: OpenClawConfig): AgentRouteBinding[] {
return listRouteBindings(cfg);
}
function resolveNormalizedBindingMatch(binding: AgentBinding): {
function resolveNormalizedBindingMatch(binding: AgentRouteBinding): {
agentId: string;
accountId: string;
channelId: string;

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
vi.mock("../acp/persistent-bindings.js", () => ({
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
ensureConfiguredAcpBindingSessionMock(...args),
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
resolveConfiguredAcpBindingRecordMock(...args),
}));
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
function createConfiguredTelegramBinding() {
return {
spec: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:work:-1001234567890:topic:42",
targetSessionKey: "agent:codex:acp:binding:telegram:work:abc123",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: "persistent",
agentId: "codex",
},
},
} as const;
}
describe("buildTelegramMessageContext ACP configured bindings", () => {
beforeEach(() => {
ensureConfiguredAcpBindingSessionMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReset();
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding());
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
});
});
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).not.toBeNull();
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
});
it("skips ACP session initialization when topic access is denied", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { enabled: false },
}),
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("defers ACP session initialization for unauthorized control commands", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "/new",
},
cfg: {
channels: {
telegram: {},
},
commands: {
useAccessGroups: true,
},
},
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
});
it("drops inbound processing when configured ACP binding initialization fails", async () => {
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
ok: false,
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
error: "gateway unavailable",
});
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).toBeNull();
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -16,6 +16,7 @@ type BuildTelegramMessageContextForTestParams = {
allMedia?: TelegramMediaRef[];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
@@ -45,7 +46,7 @@ export async function buildTelegramMessageContextForTest(
},
} as never,
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
account: { accountId: "default" } as never,
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",

View File

@@ -1,4 +1,8 @@
import type { Bot } from "grammy";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../acp/persistent-bindings.route.js";
import { resolveAckReaction } from "../agents/identity.js";
import {
findModelInCatalog,
@@ -245,9 +249,22 @@ export const buildTelegramMessageContext = async ({
`telegram: per-topic agent override: topic=${resolvedThreadId ?? dmThreadId} agent=${topicAgentId} sessionKey=${overrideSessionKey}`,
);
}
const configuredRoute = resolveConfiguredAcpRoute({
cfg: freshCfg,
route,
channel: "telegram",
accountId: account.accountId,
conversationId: peerId,
parentConversationId: isGroup ? String(chatId) : undefined,
});
const configuredBinding = configuredRoute.configuredBinding;
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
route = configuredRoute.route;
const requiresExplicitAccountBinding = (candidate: ResolvedAgentRoute): boolean =>
candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default";
// Fail closed for named Telegram accounts when route resolution falls back to
// default-agent routing. This prevents cross-account DM/session contamination.
if (route.accountId !== DEFAULT_ACCOUNT_ID && route.matchedBy === "default") {
if (requiresExplicitAccountBinding(route)) {
logInboundDrop({
log: logVerbose,
channel: "telegram",
@@ -256,14 +273,6 @@ export const buildTelegramMessageContext = async ({
});
return null;
}
const baseSessionKey = route.sessionKey;
// DMs: use thread suffix for session isolation (works regardless of dmScope)
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
@@ -307,21 +316,6 @@ export const buildTelegramMessageContext = async ({
return null;
}
// Compute requireMention early for preflight transcription gating
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
baseRequireMention,
);
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
if (topicRequiredButMissing) {
@@ -371,6 +365,54 @@ export const buildTelegramMessageContext = async ({
) {
return null;
}
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
return true;
}
const ensured = await ensureConfiguredAcpRouteReady({
cfg: freshCfg,
configuredBinding,
});
if (ensured.ok) {
logVerbose(
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
);
return true;
}
logVerbose(
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "configured ACP binding unavailable",
target: configuredBinding.spec.conversationId,
});
return false;
};
const baseSessionKey = route.sessionKey;
// DMs: use thread suffix for session isolation (works regardless of dmScope)
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
// Compute requireMention after access checks and final route selection.
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
baseRequireMention,
);
recordChannelActivity({
channel: "telegram",
@@ -553,6 +595,10 @@ export const buildTelegramMessageContext = async ({
}
}
if (!(await ensureConfiguredBindingReady())) {
return null;
}
// ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "telegram",

View File

@@ -5,6 +5,18 @@ import { createNativeCommandTestParams } from "./bot-native-commands.test-helper
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
type ResolveConfiguredAcpBindingRecordFn =
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
})),
}));
const sessionMocks = vi.hoisted(() => ({
recordSessionMetaFromInbound: vi.fn(),
resolveStorePath: vi.fn(),
@@ -13,6 +25,14 @@ const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined),
}));
vi.mock("../acp/persistent-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../acp/persistent-bindings.js")>();
return {
...actual,
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
};
});
vi.mock("../config/sessions.js", () => ({
recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound,
resolveStorePath: sessionMocks.resolveStorePath,
@@ -64,31 +84,102 @@ function buildStatusCommandContext() {
};
}
function registerAndResolveStatusHandler(cfg: OpenClawConfig): TelegramCommandHandler {
function buildStatusTopicCommandContext() {
return {
match: "",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: {
id: -1001234567890,
type: "supergroup" as const,
title: "OpenClaw",
is_forum: true,
},
message_thread_id: 42,
from: { id: 200, username: "bob" },
},
};
}
function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { cfg, allowFrom, groupAllowFrom } = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
registerTelegramNativeCommands({
...createNativeCommandTestParams({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
allowFrom: ["*"],
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
}),
});
const handler = commandHandlers.get("status");
expect(handler).toBeTruthy();
return handler as TelegramCommandHandler;
return { handler: handler as TelegramCommandHandler, sendMessage };
}
function registerAndResolveCommandHandler(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { commandName, cfg, allowFrom, groupAllowFrom, useAccessGroups } = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
registerTelegramNativeCommands({
...createNativeCommandTestParams({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg,
allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: useAccessGroups ?? true,
}),
});
const handler = commandHandlers.get(commandName);
expect(handler).toBeTruthy();
return { handler: handler as TelegramCommandHandler, sendMessage };
}
describe("registerTelegramNativeCommands — session metadata", () => {
beforeEach(() => {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined);
@@ -96,7 +187,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
const cfg: OpenClawConfig = {};
const handler = registerAndResolveStatusHandler(cfg);
const { handler } = registerAndResolveStatusHandler({ cfg });
await handler(buildStatusCommandContext());
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
@@ -115,7 +206,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise);
const cfg: OpenClawConfig = {};
const handler = registerAndResolveStatusHandler(cfg);
const { handler } = registerAndResolveStatusHandler({ cfg });
const runPromise = handler(buildStatusCommandContext());
await vi.waitFor(() => {
@@ -128,4 +219,168 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
});
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: false,
sessionKey: boundSessionKey,
error: "gateway unavailable",
});
const { handler, sendMessage } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"Configured ACP binding is unavailable right now. Please try again.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
sessionKey: boundSessionKey,
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(buildStatusTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
});

View File

@@ -1,4 +1,8 @@
import type { Bot, Context } from "grammy";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../acp/persistent-bindings.route.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import type { CommandArgs } from "../auto-reply/commands-registry.js";
import {
@@ -170,6 +174,11 @@ async function resolveTelegramCommandAuth(params: {
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
@@ -205,9 +214,10 @@ async function resolveTelegramCommandAuth(params: {
const senderUsername = msg.from?.username ?? "";
const sendAuthMessage = async (text: string) => {
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text),
fn: () => bot.api.sendMessage(chatId, text, threadParams),
});
return null;
};
@@ -409,12 +419,19 @@ export const registerTelegramNativeCommands = ({
botIdentity: opts.token,
});
const resolveCommandRuntimeContext = (params: {
const resolveCommandRuntimeContext = async (params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
}) => {
}): Promise<{
chatId: number;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
route: ReturnType<typeof resolveAgentRoute>;
mediaLocalRoots: readonly string[] | undefined;
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: ReturnType<typeof resolveChunkMode>;
} | null> => {
const { msg, isGroup, isForum, resolvedThreadId } = params;
const chatId = msg.chat.id;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
@@ -424,16 +441,49 @@ export const registerTelegramNativeCommands = ({
messageThreadId,
});
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
const route = resolveAgentRoute({
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
let route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
id: peerId,
},
parentPeer,
});
const configuredRoute = resolveConfiguredAcpRoute({
cfg,
route,
channel: "telegram",
accountId,
conversationId: peerId,
parentConversationId: isGroup ? String(chatId) : undefined,
});
const configuredBinding = configuredRoute.configuredBinding;
route = configuredRoute.route;
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg,
configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(
chatId,
"Configured ACP binding is unavailable right now. Please try again.",
buildTelegramThreadParams(threadSpec) ?? {},
),
});
return null;
}
}
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const tableMode = resolveMarkdownTableMode({
cfg,
@@ -504,15 +554,19 @@ export const registerTelegramNativeCommands = ({
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
commandAuthorized: initialCommandAuthorized,
} = auth;
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
let commandAuthorized = initialCommandAuthorized;
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
if (!runtimeContext) {
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
chatId,
accountId: route.accountId,
@@ -729,13 +783,16 @@ export const registerTelegramNativeCommands = ({
return;
}
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
isForum,
resolvedThreadId,
});
if (!runtimeContext) {
return;
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
chatId,
accountId: route.accountId,