diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fa5806aed3..c94d409d467 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index fbeedf16aa9..b69e651eabb 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -685,6 +685,71 @@ Default slash command settings:
+
+ 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.
+
+
+
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
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index 9cbf7ac2910..8f0a70bf478 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -469,6 +469,59 @@ curl "https://api.telegram.org/bot/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..topics..agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups..topics..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..topics..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..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`
diff --git a/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md b/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md
new file mode 100644
index 00000000000..e85ddeaf4a7
--- /dev/null
+++ b/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md
@@ -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`)".
diff --git a/docs/experiments/proposals/acp-bound-command-auth.md b/docs/experiments/proposals/acp-bound-command-auth.md
new file mode 100644
index 00000000000..1d02e9e8469
--- /dev/null
+++ b/docs/experiments/proposals/acp-bound-command-auth.md
@@ -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.
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index d84e3626198..3e9eeb7db35 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -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
diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md
index f6c1d5734cb..2003758cc1d 100644
--- a/docs/tools/acp-agents.md
+++ b/docs/tools/acp-agents.md
@@ -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=""`
+ - Telegram forum topic: `match.channel="telegram"` + `match.peer.id=":topic:"`
+- `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`
diff --git a/src/acp/conversation-id.ts b/src/acp/conversation-id.ts
new file mode 100644
index 00000000000..7281fef4924
--- /dev/null
+++ b/src/acp/conversation-id.ts
@@ -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,
+ };
+}
diff --git a/src/acp/persistent-bindings.lifecycle.ts b/src/acp/persistent-bindings.lifecycle.ts
new file mode 100644
index 00000000000..2a2cf6b9c20
--- /dev/null
+++ b/src/acp/persistent-bindings.lifecycle.ts
@@ -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,
+ };
+ }
+}
diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts
new file mode 100644
index 00000000000..c69f1afe5af
--- /dev/null
+++ b/src/acp/persistent-bindings.resolve.ts
@@ -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;
+}
diff --git a/src/acp/persistent-bindings.route.ts b/src/acp/persistent-bindings.route.ts
new file mode 100644
index 00000000000..9436d930d5b
--- /dev/null
+++ b/src/acp/persistent-bindings.route.ts
@@ -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",
+ };
+}
diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts
new file mode 100644
index 00000000000..deafbc53e15
--- /dev/null
+++ b/src/acp/persistent-bindings.test.ts
@@ -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",
+ }),
+ );
+ });
+});
diff --git a/src/acp/persistent-bindings.ts b/src/acp/persistent-bindings.ts
new file mode 100644
index 00000000000..d5b1f4ce729
--- /dev/null
+++ b/src/acp/persistent-bindings.ts
@@ -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";
diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts
new file mode 100644
index 00000000000..715ae9c70d4
--- /dev/null
+++ b/src/acp/persistent-bindings.types.ts
@@ -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 } : {}),
+ },
+ };
+}
diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts
new file mode 100644
index 00000000000..cf8952cdc4a
--- /dev/null
+++ b/src/auto-reply/reply/acp-reset-target.ts
@@ -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;
+}
diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts
index 92952ad749f..9ba70225de6 100644
--- a/src/auto-reply/reply/commands-acp/context.test.ts
+++ b/src/auto-reply/reply/commands-acp/context.test.ts
@@ -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");
+ });
});
diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts
index f9ac901ec92..78e2e7a32a9 100644
--- a/src/auto-reply/reply/commands-acp/context.ts
+++ b/src/auto-reply/reply/commands-acp/context.ts
@@ -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 } : {}),
};
}
diff --git a/src/auto-reply/reply/commands-acp/targets.ts b/src/auto-reply/reply/commands-acp/targets.ts
index c1f7928b4ca..b517ea19d75 100644
--- a/src/auto-reply/reply/commands-acp/targets.ts
+++ b/src/auto-reply/reply/commands-acp/targets.ts
@@ -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 {
}
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: {
diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts
index 8f64defc5eb..d57d679fdb6 100644
--- a/src/auto-reply/reply/commands-core.ts
+++ b/src/auto-reply/reply/commands-core.ts
@@ -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;
+ 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 {
if (HANDLERS === null) {
HANDLERS = [
@@ -172,6 +209,74 @@ export async function handleCommands(params: HandleCommandsParams): Promise ({
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 => ({
+ ok: false,
+ skipped: true,
+ }),
+ ),
+);
+vi.mock("../../acp/persistent-bindings.js", async () => {
+ const actual = await vi.importActual(
+ "../../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;
+ 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 = {
diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts
index 2b703a399f5..78bace08dbc 100644
--- a/src/auto-reply/reply/dispatch-from-config.test.ts
+++ b/src/auto-reply/reply/dispatch-from-config.test.ts
@@ -178,7 +178,7 @@ function createAcpRuntime(events: Array>) {
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" }]);
diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts
index c727871ca4e..1a968581cf6 100644
--- a/src/auto-reply/reply/dispatch-from-config.ts
+++ b/src/auto-reply/reply/dispatch-from-config.ts
@@ -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;
diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts
index 4abb9a82f82..e133585411a 100644
--- a/src/auto-reply/reply/get-reply-inline-actions.ts
+++ b/src/auto-reply/reply/get-reply-inline-actions.ts
@@ -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,
diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts
index 37a8f1f89c2..8cfb6b5e7d9 100644
--- a/src/auto-reply/reply/session.test.ts
+++ b/src/auto-reply/reply/session.test.ts
@@ -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");
diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts
index e808b1e2800..60bcc78135b 100644
--- a/src/auto-reply/reply/session.ts
+++ b/src/auto-reply/reply/session.ts
@@ -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;
diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts
index f0934279c80..c0ab459bfe9 100644
--- a/src/auto-reply/templating.ts
+++ b/src/auto-reply/templating.ts
@@ -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 ` and `/reset ` 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".
diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts
index ca0c0ee649c..009a1fddac8 100644
--- a/src/commands/agents.bindings.ts
+++ b/src/commands/agents.bindings.ts
@@ -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();
- 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();
- 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>;
-}): 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 };
}
diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts
index 5e1bcce3c50..d392eb5cfcf 100644
--- a/src/commands/agents.commands.bind.ts
+++ b/src/commands/agents.commands.bind.ts
@@ -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>, 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) {
diff --git a/src/commands/agents.commands.list.ts b/src/commands/agents.commands.list.ts
index cb3240f0dcf..5e7eec3da77 100644
--- a/src/commands/agents.commands.list.ts
+++ b/src/commands/agents.commands.list.ts
@@ -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();
- for (const binding of cfg.bindings ?? []) {
+ const bindingMap = new Map();
+ for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
const list = bindingMap.get(agentId) ?? [];
list.push(binding);
diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts
index 1a8c39237c8..8953e360490 100644
--- a/src/commands/agents.config.ts
+++ b/src/commands/agents.config.ts
@@ -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();
- 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);
}
diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts
index 9e95575dcdc..8ae2e8d14b8 100644
--- a/src/commands/doctor-config-flow.ts
+++ b/src/commands/doctor-config-flow.ts
@@ -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)) {
diff --git a/src/config/bindings.ts b/src/config/bindings.ts
new file mode 100644
index 00000000000..b035fa3be15
--- /dev/null
+++ b/src/config/bindings.ts
@@ -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);
+}
diff --git a/src/config/config.acp-binding-cutover.test.ts b/src/config/config.acp-binding-cutover.test.ts
new file mode 100644
index 00000000000..ea9f4d603ea
--- /dev/null
+++ b/src/config/config.acp-binding-cutover.test.ts
@@ -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);
+ });
+});
diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts
index 1f0a77980c7..5b9fda17424 100644
--- a/src/config/schema.help.ts
+++ b/src/config/schema.help.ts
@@ -204,6 +204,20 @@ export const FIELD_HELP: Record = {
"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 = {
"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 = {
"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":
diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts
index 1248f95b275..797b7f8ba67 100644
--- a/src/config/schema.labels.ts
+++ b/src/config/schema.labels.ts
@@ -56,6 +56,13 @@ export const FIELD_LABELS: Record = {
"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 = {
"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 = {
"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",
diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts
index 61883abcc04..a979506a2ab 100644
--- a/src/config/types.agents.ts
+++ b/src/config/types.agents.ts
@@ -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;
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[];
- };
-};
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index 91e07d8b656..227891711bb 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -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();
diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts
index c7c921a5e5a..ed638d9b502 100644
--- a/src/config/zod-schema.agents.ts
+++ b/src/config/zod-schema.agents.ts
@@ -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"]);
diff --git a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts b/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts
new file mode 100644
index 00000000000..1d7344ca15f
--- /dev/null
+++ b/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts
@@ -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) {
+ 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["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[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");
+ });
+});
diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts
index 2aea357d236..d5a536bf661 100644
--- a/src/discord/monitor/message-handler.preflight.ts
+++ b/src/discord/monitor/message-handler.preflight.ts
@@ -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}`,
diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts
index 47de666d399..1e98f349e63 100644
--- a/src/discord/monitor/native-command.plugin-dispatch.test.ts
+++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts
@@ -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(() => null),
+ ensureConfiguredAcpBindingSession: vi.fn(async () => ({
+ ok: true,
+ sessionKey: "agent:codex:acp:binding:discord:default:seed",
+ })),
+}));
+
+vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ 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;
@@ -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 }).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 }).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);
+ });
});
diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts
index 79eda2d9795..652e6f21214 100644
--- a/src/discord/monitor/native-command.ts
+++ b/src/discord/monitor/native-command.ts
@@ -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({
diff --git a/src/i18n/registry.test.ts b/src/i18n/registry.test.ts
index c59ae03fa9a..a06d3720473 100644
--- a/src/i18n/registry.test.ts
+++ b/src/i18n/registry.test.ts
@@ -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();
});
});
diff --git a/src/routing/bindings.ts b/src/routing/bindings.ts
index f6e77503fa6..87882e7795b 100644
--- a/src/routing/bindings.ts
+++ b/src/routing/bindings.ts
@@ -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;
diff --git a/src/telegram/bot-message-context.acp-bindings.test.ts b/src/telegram/bot-message-context.acp-bindings.test.ts
new file mode 100644
index 00000000000..1e073366347
--- /dev/null
+++ b/src/telegram/bot-message-context.acp-bindings.test.ts
@@ -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);
+ });
+});
diff --git a/src/telegram/bot-message-context.test-harness.ts b/src/telegram/bot-message-context.test-harness.ts
index acfb84e6d69..27cf2764028 100644
--- a/src/telegram/bot-message-context.test-harness.ts
+++ b/src/telegram/bot-message-context.test-harness.ts
@@ -16,6 +16,7 @@ type BuildTelegramMessageContextForTestParams = {
allMedia?: TelegramMediaRef[];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record;
+ 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",
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index 3e5d25002de..248a3e1255e 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -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 => {
+ 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",
diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts
index c7405401aaf..cbf6a83be15 100644
--- a/src/telegram/bot-native-commands.session-meta.test.ts
+++ b/src/telegram/bot-native-commands.session-meta.test.ts
@@ -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(() => null),
+ ensureConfiguredAcpBindingSession: vi.fn(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();
+ 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;
+} {
+ const { cfg, allowFrom, groupAllowFrom } = params;
const commandHandlers = new Map();
+ 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[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;
+} {
+ const { commandName, cfg, allowFrom, groupAllowFrom, useAccessGroups } = params;
+ const commandHandlers = new Map();
+ 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[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 }),
+ );
+ });
});
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index efe5821005a..115180c8c4c 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -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;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
- }) => {
+ }): Promise<{
+ chatId: number;
+ threadSpec: ReturnType;
+ route: ReturnType;
+ mediaLocalRoots: readonly string[] | undefined;
+ tableMode: ReturnType;
+ chunkMode: ReturnType;
+ } | 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,