ACP: add persistent Discord channel and Telegram topic bindings (#34873)
* docs: add ACP persistent binding experiment plan * docs: align ACP persistent binding spec to channel-local config * docs: scope Telegram ACP bindings to forum topics only * docs: lock bound /new and /reset behavior to in-place ACP reset * ACP: add persistent discord/telegram conversation bindings * ACP: fix persistent binding reuse and discord thread parent context * docs: document channel-specific persistent ACP bindings * ACP: split persistent bindings and share conversation id helpers * ACP: defer configured binding init until preflight passes * ACP: fix discord thread parent fallback and explicit disable inheritance * ACP: keep bound /new and /reset in-place * ACP: honor configured bindings in native command flows * ACP: avoid configured fallback after runtime bind failure * docs: refine ACP bindings experiment config examples * acp: cut over to typed top-level persistent bindings * ACP bindings: harden reset recovery and native command auth * Docs: add ACP bound command auth proposal * Tests: normalize i18n registry zh-CN assertion encoding * ACP bindings: address review findings for reset and fallback routing * ACP reset: gate hooks on success and preserve /new arguments * ACP bindings: fix auth and binding-priority review findings * Telegram ACP: gate ensure on auth and accepted messages * ACP bindings: fix session-key precedence and unavailable handling * ACP reset/native commands: honor fallback targets and abort on bootstrap failure * Config schema: validate ACP binding channel and Telegram topic IDs * Discord ACP: apply configured DM bindings to native commands * ACP reset tails: dispatch through ACP after command handling * ACP tails/native reset auth: fix target dispatch and restore full auth * ACP reset detection: fallback to active ACP keys for DM contexts * Tests: type runTurn mock input in ACP dispatch test * ACP: dedup binding route bootstrap and reset target resolution * reply: align ACP reset hooks with bound session key * docs: replace personal discord ids with placeholders * fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob) --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -685,6 +685,71 @@ Default slash command settings:
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Persistent ACP channel bindings">
|
||||
For stable "always-on" ACP workspaces, configure top-level typed ACP bindings targeting Discord conversations.
|
||||
|
||||
Config path:
|
||||
|
||||
- `bindings[]` with `type: "acp"` and `match.channel: "discord"`
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "222222222222222222" },
|
||||
},
|
||||
acp: { label: "codex-main" },
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"111111111111111111": {
|
||||
channels: {
|
||||
"222222222222222222": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Thread messages can inherit the parent channel ACP binding.
|
||||
- In a bound channel or thread, `/new` and `/reset` reset the same ACP session in place.
|
||||
- Temporary thread bindings still work and can override target resolution while active.
|
||||
|
||||
See [ACP Agents](/tools/acp-agents) for binding behavior details.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reaction notifications">
|
||||
Per-guild reaction notification mode:
|
||||
|
||||
@@ -1120,7 +1185,7 @@ High-signal Discord fields:
|
||||
- actions: `actions.*`
|
||||
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
||||
- UI: `ui.components.accentColor`
|
||||
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
||||
- features: `threadBindings`, top-level `bindings[]` (`type: "acp"`), `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
||||
|
||||
## Safety and operations
|
||||
|
||||
|
||||
@@ -469,6 +469,59 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
Each topic then has its own session key: `agent:zu:telegram:group:-1001234567890:topic:3`
|
||||
|
||||
**Persistent ACP topic binding**: Forum topics can pin ACP harness sessions through top-level typed ACP bindings:
|
||||
|
||||
- `bindings[]` with `type: "acp"` and `match.channel: "telegram"`
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "-1001234567890:topic:42" },
|
||||
},
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
topics: {
|
||||
"42": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This is currently scoped to forum topics in groups and supergroups.
|
||||
|
||||
Template context includes:
|
||||
|
||||
- `MessageThreadId`
|
||||
@@ -778,6 +831,7 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
@@ -809,7 +863,7 @@ Primary reference:
|
||||
Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
|
||||
@@ -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`)".
|
||||
89
docs/experiments/proposals/acp-bound-command-auth.md
Normal file
89
docs/experiments/proposals/acp-bound-command-auth.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
summary: "Proposal: long-term command authorization model for ACP-bound conversations"
|
||||
read_when:
|
||||
- Designing native command auth behavior in Telegram/Discord ACP-bound channels/topics
|
||||
title: "ACP Bound Command Authorization (Proposal)"
|
||||
---
|
||||
|
||||
# ACP Bound Command Authorization (Proposal)
|
||||
|
||||
Status: Proposed, **not implemented yet**.
|
||||
|
||||
This document describes a long-term authorization model for native commands in
|
||||
ACP-bound conversations. It is an experiments proposal and does not replace
|
||||
current production behavior.
|
||||
|
||||
For implemented behavior, read source and tests in:
|
||||
|
||||
- `src/telegram/bot-native-commands.ts`
|
||||
- `src/discord/monitor/native-command.ts`
|
||||
- `src/auto-reply/reply/commands-core.ts`
|
||||
|
||||
## Problem
|
||||
|
||||
Today we have command-specific checks (for example `/new` and `/reset`) that
|
||||
need to work inside ACP-bound channels/topics even when allowlists are empty.
|
||||
This solves immediate UX pain, but command-name-based exceptions do not scale.
|
||||
|
||||
## Long-term shape
|
||||
|
||||
Move command authorization from ad-hoc handler logic to command metadata plus a
|
||||
shared policy evaluator.
|
||||
|
||||
### 1) Add auth policy metadata to command definitions
|
||||
|
||||
Each command definition should declare an auth policy. Example shape:
|
||||
|
||||
```ts
|
||||
type CommandAuthPolicy =
|
||||
| { mode: "owner_or_allowlist" } // default, current strict behavior
|
||||
| { mode: "bound_acp_or_owner_or_allowlist" } // allow in explicitly bound ACP conversations
|
||||
| { mode: "owner_only" };
|
||||
```
|
||||
|
||||
`/new` and `/reset` would use `bound_acp_or_owner_or_allowlist`.
|
||||
Most other commands would remain `owner_or_allowlist`.
|
||||
|
||||
### 2) Share one evaluator across channels
|
||||
|
||||
Introduce one helper that evaluates command auth using:
|
||||
|
||||
- command policy metadata
|
||||
- sender authorization state
|
||||
- resolved conversation binding state
|
||||
|
||||
Both Telegram and Discord native handlers should call the same helper to avoid
|
||||
behavior drift.
|
||||
|
||||
### 3) Use binding-match as the bypass boundary
|
||||
|
||||
When policy allows bound ACP bypass, authorize only if a configured binding
|
||||
match was resolved for the current conversation (not just because current
|
||||
session key looks ACP-like).
|
||||
|
||||
This keeps the boundary explicit and minimizes accidental widening.
|
||||
|
||||
## Why this is better
|
||||
|
||||
- Scales to future commands without adding more command-name conditionals.
|
||||
- Keeps behavior consistent across channels.
|
||||
- Preserves current security model by requiring explicit binding match.
|
||||
- Keeps allowlists optional hardening instead of a universal requirement.
|
||||
|
||||
## Rollout plan (future)
|
||||
|
||||
1. Add command auth policy field to command registry types and command data.
|
||||
2. Implement shared evaluator and migrate Telegram + Discord native handlers.
|
||||
3. Move `/new` and `/reset` to metadata-driven policy.
|
||||
4. Add tests per policy mode and channel surface.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- This proposal does not change ACP session lifecycle behavior.
|
||||
- This proposal does not require allowlists for all ACP-bound commands.
|
||||
- This proposal does not change existing route binding semantics.
|
||||
|
||||
## Note
|
||||
|
||||
This proposal is intentionally additive and does not delete or replace existing
|
||||
experiments documents.
|
||||
@@ -207,6 +207,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
|
||||
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
|
||||
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for forum topics (use canonical `chatId:topic:topicId` in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings).
|
||||
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
|
||||
- Retry policy: see [Retry policy](/concepts/retry).
|
||||
|
||||
@@ -314,6 +315,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
|
||||
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
|
||||
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
|
||||
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings).
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
||||
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
|
||||
- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default).
|
||||
@@ -1271,6 +1273,15 @@ scripts/sandbox-browser-setup.sh # optional browser image
|
||||
},
|
||||
groupChat: { mentionPatterns: ["@openclaw"] },
|
||||
sandbox: { mode: "off" },
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
subagents: { allowAgents: ["*"] },
|
||||
tools: {
|
||||
profile: "coding",
|
||||
@@ -1288,6 +1299,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
|
||||
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
|
||||
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
|
||||
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
|
||||
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
|
||||
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
|
||||
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
|
||||
- `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only).
|
||||
@@ -1316,10 +1328,12 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul
|
||||
|
||||
### Binding match fields
|
||||
|
||||
- `type` (optional): `route` for normal routing (missing type defaults to route), `acp` for persistent ACP conversation bindings.
|
||||
- `match.channel` (required)
|
||||
- `match.accountId` (optional; `*` = any account; omitted = default account)
|
||||
- `match.peer` (optional; `{ kind: direct|group|channel, id }`)
|
||||
- `match.guildId` / `match.teamId` (optional; channel-specific)
|
||||
- `acp` (optional; only for `type: "acp"`): `{ mode, label, cwd, backend }`
|
||||
|
||||
**Deterministic match order:**
|
||||
|
||||
@@ -1332,6 +1346,8 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul
|
||||
|
||||
Within each tier, the first matching `bindings` entry wins.
|
||||
|
||||
For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`match.channel` + account + `match.peer.id`) and does not use the route binding tier order above.
|
||||
|
||||
### Per-agent access profiles
|
||||
|
||||
<Accordion title="Full access (no sandbox)">
|
||||
|
||||
@@ -3,6 +3,7 @@ summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini
|
||||
read_when:
|
||||
- Running coding harnesses through ACP
|
||||
- Setting up thread-bound ACP sessions on thread-capable channels
|
||||
- Binding Discord channels or Telegram forum topics to persistent ACP sessions
|
||||
- Troubleshooting ACP backend and plugin wiring
|
||||
- Operating /acp commands from chat
|
||||
title: "ACP Agents"
|
||||
@@ -85,6 +86,126 @@ Required feature flags for thread-bound ACP:
|
||||
- Current built-in support: Discord.
|
||||
- Plugin channels can add support through the same binding interface.
|
||||
|
||||
## Channel specific settings
|
||||
|
||||
For non-ephemeral workflows, configure persistent ACP bindings in top-level `bindings[]` entries.
|
||||
|
||||
### Binding model
|
||||
|
||||
- `bindings[].type="acp"` marks a persistent ACP conversation binding.
|
||||
- `bindings[].match` identifies the target conversation:
|
||||
- Discord channel or thread: `match.channel="discord"` + `match.peer.id="<channelOrThreadId>"`
|
||||
- Telegram forum topic: `match.channel="telegram"` + `match.peer.id="<chatId>:topic:<topicId>"`
|
||||
- `bindings[].agentId` is the owning OpenClaw agent id.
|
||||
- Optional ACP overrides live under `bindings[].acp`:
|
||||
- `mode` (`persistent` or `oneshot`)
|
||||
- `label`
|
||||
- `cwd`
|
||||
- `backend`
|
||||
|
||||
### Runtime defaults per agent
|
||||
|
||||
Use `agents.list[].runtime` to define ACP defaults once per agent:
|
||||
|
||||
- `agents.list[].runtime.type="acp"`
|
||||
- `agents.list[].runtime.acp.agent` (harness id, for example `codex` or `claude`)
|
||||
- `agents.list[].runtime.acp.backend`
|
||||
- `agents.list[].runtime.acp.mode`
|
||||
- `agents.list[].runtime.acp.cwd`
|
||||
|
||||
Override precedence for ACP bound sessions:
|
||||
|
||||
1. `bindings[].acp.*`
|
||||
2. `agents.list[].runtime.acp.*`
|
||||
3. global ACP defaults (for example `acp.backend`)
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: { agent: "claude", backend: "acpx", mode: "persistent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "222222222222222222" },
|
||||
},
|
||||
acp: { label: "codex-main" },
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "-1001234567890:topic:42" },
|
||||
},
|
||||
acp: { cwd: "/workspace/repo-b" },
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
agentId: "main",
|
||||
match: { channel: "discord", accountId: "default" },
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
agentId: "main",
|
||||
match: { channel: "telegram", accountId: "default" },
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"111111111111111111": {
|
||||
channels: {
|
||||
"222222222222222222": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
topics: { "42": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- OpenClaw ensures the configured ACP session exists before use.
|
||||
- Messages in that channel or topic route to the configured ACP session.
|
||||
- In bound conversations, `/new` and `/reset` reset the same ACP session key in place.
|
||||
- Temporary runtime bindings (for example created by thread-focus flows) still apply where present.
|
||||
|
||||
## Start ACP sessions (interfaces)
|
||||
|
||||
### From `sessions_spawn`
|
||||
|
||||
80
src/acp/conversation-id.ts
Normal file
80
src/acp/conversation-id.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type ParsedTelegramTopicConversation = {
|
||||
chatId: string;
|
||||
topicId: string;
|
||||
canonicalConversationId: string;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
||||
return `${value}`.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseTelegramChatIdFromTarget(raw: unknown): string | undefined {
|
||||
const text = normalizeText(raw);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const match = text.match(/^telegram:(-?\d+)$/);
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function buildTelegramTopicConversationId(params: {
|
||||
chatId: string;
|
||||
topicId: string;
|
||||
}): string | null {
|
||||
const chatId = params.chatId.trim();
|
||||
const topicId = params.topicId.trim();
|
||||
if (!/^-?\d+$/.test(chatId) || !/^\d+$/.test(topicId)) {
|
||||
return null;
|
||||
}
|
||||
return `${chatId}:topic:${topicId}`;
|
||||
}
|
||||
|
||||
export function parseTelegramTopicConversation(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ParsedTelegramTopicConversation | null {
|
||||
const conversation = params.conversationId.trim();
|
||||
const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/);
|
||||
if (directMatch?.[1] && directMatch[2]) {
|
||||
const canonicalConversationId = buildTelegramTopicConversationId({
|
||||
chatId: directMatch[1],
|
||||
topicId: directMatch[2],
|
||||
});
|
||||
if (!canonicalConversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
chatId: directMatch[1],
|
||||
topicId: directMatch[2],
|
||||
canonicalConversationId,
|
||||
};
|
||||
}
|
||||
if (!/^\d+$/.test(conversation)) {
|
||||
return null;
|
||||
}
|
||||
const parent = params.parentConversationId?.trim();
|
||||
if (!parent || !/^-?\d+$/.test(parent)) {
|
||||
return null;
|
||||
}
|
||||
const canonicalConversationId = buildTelegramTopicConversationId({
|
||||
chatId: parent,
|
||||
topicId: conversation,
|
||||
});
|
||||
if (!canonicalConversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
chatId: parent,
|
||||
topicId: conversation,
|
||||
canonicalConversationId,
|
||||
};
|
||||
}
|
||||
198
src/acp/persistent-bindings.lifecycle.ts
Normal file
198
src/acp/persistent-bindings.lifecycle.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionAcpMeta } from "../config/sessions/types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { getAcpSessionManager } from "./control-plane/manager.js";
|
||||
import { resolveAcpAgentFromSessionKey } from "./control-plane/manager.utils.js";
|
||||
import { resolveConfiguredAcpBindingSpecBySessionKey } from "./persistent-bindings.resolve.js";
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeText,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
} from "./persistent-bindings.types.js";
|
||||
import { readAcpSessionEntry } from "./runtime/session-meta.js";
|
||||
|
||||
function sessionMatchesConfiguredBinding(params: {
|
||||
cfg: OpenClawConfig;
|
||||
spec: ConfiguredAcpBindingSpec;
|
||||
meta: SessionAcpMeta;
|
||||
}): boolean {
|
||||
const desiredAgent = (params.spec.acpAgentId ?? params.spec.agentId).trim().toLowerCase();
|
||||
const currentAgent = (params.meta.agent ?? "").trim().toLowerCase();
|
||||
if (!currentAgent || currentAgent !== desiredAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.meta.mode !== params.spec.mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desiredBackend = params.spec.backend?.trim() || params.cfg.acp?.backend?.trim() || "";
|
||||
if (desiredBackend) {
|
||||
const currentBackend = (params.meta.backend ?? "").trim();
|
||||
if (!currentBackend || currentBackend !== desiredBackend) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const desiredCwd = params.spec.cwd?.trim();
|
||||
if (desiredCwd !== undefined) {
|
||||
const currentCwd = (params.meta.runtimeOptions?.cwd ?? params.meta.cwd ?? "").trim();
|
||||
if (desiredCwd !== currentCwd) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureConfiguredAcpBindingSession(params: {
|
||||
cfg: OpenClawConfig;
|
||||
spec: ConfiguredAcpBindingSpec;
|
||||
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
|
||||
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
|
||||
const acpManager = getAcpSessionManager();
|
||||
try {
|
||||
const resolution = acpManager.resolveSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (
|
||||
resolution.kind === "ready" &&
|
||||
sessionMatchesConfiguredBinding({
|
||||
cfg: params.cfg,
|
||||
spec: params.spec,
|
||||
meta: resolution.meta,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (resolution.kind !== "none") {
|
||||
await acpManager.closeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
reason: "config-binding-reconfigure",
|
||||
clearMeta: false,
|
||||
allowBackendUnavailable: true,
|
||||
requireAcpSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
await acpManager.initializeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
agent: params.spec.acpAgentId ?? params.spec.agentId,
|
||||
mode: params.spec.mode,
|
||||
cwd: params.spec.cwd,
|
||||
backendId: params.spec.backend,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sessionKey,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(
|
||||
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
sessionKey,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetAcpSessionInPlace(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
reason: "new" | "reset";
|
||||
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
const meta = readAcpSessionEntry({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
})?.acp;
|
||||
if (!meta) {
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: params.cfg,
|
||||
spec: configuredBinding,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: ensured.error,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const acpManager = getAcpSessionManager();
|
||||
const agent =
|
||||
normalizeText(meta.agent) ??
|
||||
configuredBinding?.acpAgentId ??
|
||||
configuredBinding?.agentId ??
|
||||
resolveAcpAgentFromSessionKey(sessionKey, "main");
|
||||
const mode = meta.mode === "oneshot" ? "oneshot" : "persistent";
|
||||
const runtimeOptions = { ...meta.runtimeOptions };
|
||||
const cwd = normalizeText(runtimeOptions.cwd ?? meta.cwd);
|
||||
|
||||
try {
|
||||
await acpManager.closeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
reason: `${params.reason}-in-place-reset`,
|
||||
clearMeta: false,
|
||||
allowBackendUnavailable: true,
|
||||
requireAcpSession: false,
|
||||
});
|
||||
|
||||
await acpManager.initializeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
agent,
|
||||
mode,
|
||||
cwd,
|
||||
backendId: normalizeText(meta.backend) ?? normalizeText(params.cfg.acp?.backend),
|
||||
});
|
||||
|
||||
const runtimeOptionsPatch = Object.fromEntries(
|
||||
Object.entries(runtimeOptions).filter(([, value]) => value !== undefined),
|
||||
) as SessionAcpMeta["runtimeOptions"];
|
||||
if (runtimeOptionsPatch && Object.keys(runtimeOptionsPatch).length > 0) {
|
||||
await acpManager.updateSessionRuntimeOptions({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
patch: runtimeOptionsPatch,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`);
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
341
src/acp/persistent-bindings.resolve.ts
Normal file
341
src/acp/persistent-bindings.resolve.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { listAcpBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentAcpBinding } from "../config/types.js";
|
||||
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { parseTelegramTopicConversation } from "./conversation-id.js";
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
normalizeMode,
|
||||
normalizeText,
|
||||
toConfiguredAcpBindingRecord,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.types.js";
|
||||
|
||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "discord" || normalized === "telegram") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||
const trimmed = (match ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return 1;
|
||||
}
|
||||
return normalizeAccountId(trimmed) === actual ? 2 : 0;
|
||||
}
|
||||
|
||||
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
|
||||
const id = binding.match.peer?.id?.trim();
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
function parseConfiguredBindingSessionKey(params: {
|
||||
sessionKey: string;
|
||||
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
|
||||
const parsed = parseAgentSessionKey(params.sessionKey);
|
||||
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
|
||||
if (!rest) {
|
||||
return null;
|
||||
}
|
||||
const tokens = rest.split(":");
|
||||
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
|
||||
return null;
|
||||
}
|
||||
const channel = normalizeBindingChannel(tokens[2]);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeAccountId(tokens[3]);
|
||||
return {
|
||||
channel,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
|
||||
acpAgentId?: string;
|
||||
mode?: string;
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
} {
|
||||
const agent = params.cfg.agents?.list?.find(
|
||||
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
|
||||
);
|
||||
if (!agent || agent.runtime?.type !== "acp") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
acpAgentId: normalizeText(agent.runtime.acp?.agent),
|
||||
mode: normalizeText(agent.runtime.acp?.mode),
|
||||
cwd: normalizeText(agent.runtime.acp?.cwd),
|
||||
backend: normalizeText(agent.runtime.acp?.backend),
|
||||
};
|
||||
}
|
||||
|
||||
function toConfiguredBindingSpec(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
binding: AgentAcpBinding;
|
||||
}): ConfiguredAcpBindingSpec {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
|
||||
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
|
||||
cfg: params.cfg,
|
||||
ownerAgentId: agentId,
|
||||
});
|
||||
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
|
||||
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
|
||||
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
agentId,
|
||||
acpAgentId,
|
||||
mode,
|
||||
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
|
||||
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
|
||||
label: bindingOverrides.label,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): ConfiguredAcpBindingSpec | null {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
|
||||
if (!parsedSessionKey) {
|
||||
return null;
|
||||
}
|
||||
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
|
||||
for (const binding of listAcpBindings(params.cfg)) {
|
||||
const channel = normalizeBindingChannel(binding.match.channel);
|
||||
if (!channel || channel !== parsedSessionKey.channel) {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
binding.match.accountId,
|
||||
parsedSessionKey.accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (channel === "discord") {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: targetConversationId,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const parsedTopic = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: parsedTopic.canonicalConversationId,
|
||||
parentConversationId: parsedTopic.chatId,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
}
|
||||
return wildcardMatch;
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
const channel = params.channel.trim().toLowerCase();
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const conversationId = params.conversationId.trim();
|
||||
const parentConversationId = params.parentConversationId?.trim() || undefined;
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channel === "discord") {
|
||||
const bindings = listAcpBindings(params.cfg);
|
||||
const resolveDiscordBindingForConversation = (
|
||||
targetConversationId: string,
|
||||
): ResolvedConfiguredAcpBinding | null => {
|
||||
let wildcardMatch: AgentAcpBinding | null = null;
|
||||
for (const binding of bindings) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== "discord") {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
binding.match.accountId,
|
||||
accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (accountMatchPriority === 2) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: targetConversationId,
|
||||
binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = binding;
|
||||
}
|
||||
}
|
||||
if (wildcardMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: targetConversationId,
|
||||
binding: wildcardMatch,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const directMatch = resolveDiscordBindingForConversation(conversationId);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
if (parentConversationId && parentConversationId !== conversationId) {
|
||||
const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId);
|
||||
if (inheritedMatch) {
|
||||
return inheritedMatch;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channel === "telegram") {
|
||||
const parsed = parseTelegramTopicConversation({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
if (!parsed || !parsed.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
let wildcardMatch: AgentAcpBinding | null = null;
|
||||
for (const binding of listAcpBindings(params.cfg)) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== "telegram") {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(binding.match.accountId, accountId);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
const targetParsed = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (accountMatchPriority === 2) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId: parsed.chatId,
|
||||
binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = binding;
|
||||
}
|
||||
}
|
||||
if (wildcardMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId: parsed.chatId,
|
||||
binding: wildcardMatch,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
76
src/acp/persistent-bindings.route.ts
Normal file
76
src/acp/persistent-bindings.route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.js";
|
||||
|
||||
export function resolveConfiguredAcpRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: ResolvedAgentRoute;
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): {
|
||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
||||
route: ResolvedAgentRoute;
|
||||
boundSessionKey?: string;
|
||||
boundAgentId?: string;
|
||||
} {
|
||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (!configuredBinding) {
|
||||
return {
|
||||
configuredBinding: null,
|
||||
route: params.route,
|
||||
};
|
||||
}
|
||||
const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? "";
|
||||
if (!boundSessionKey) {
|
||||
return {
|
||||
configuredBinding,
|
||||
route: params.route,
|
||||
};
|
||||
}
|
||||
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
|
||||
return {
|
||||
configuredBinding,
|
||||
boundSessionKey,
|
||||
boundAgentId,
|
||||
route: {
|
||||
...params.route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId,
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureConfiguredAcpRouteReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (!params.configuredBinding) {
|
||||
return { ok: true };
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: params.cfg,
|
||||
spec: params.configuredBinding.spec,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: ensured.error ?? "unknown error",
|
||||
};
|
||||
}
|
||||
639
src/acp/persistent-bindings.test.ts
Normal file
639
src/acp/persistent-bindings.test.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
const managerMocks = vi.hoisted(() => ({
|
||||
resolveSession: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
initializeSession: vi.fn(),
|
||||
updateSessionRuntimeOptions: vi.fn(),
|
||||
}));
|
||||
const sessionMetaMocks = vi.hoisted(() => ({
|
||||
readAcpSessionEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./control-plane/manager.js", () => ({
|
||||
getAcpSessionManager: () => ({
|
||||
resolveSession: managerMocks.resolveSession,
|
||||
closeSession: managerMocks.closeSession,
|
||||
initializeSession: managerMocks.initializeSession,
|
||||
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./runtime/session-meta.js", () => ({
|
||||
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
} from "./persistent-bindings.js";
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "codex" }, { id: "claude" }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
managerMocks.resolveSession.mockReset();
|
||||
managerMocks.closeSession.mockReset().mockResolvedValue({
|
||||
runtimeClosed: true,
|
||||
metaCleared: true,
|
||||
});
|
||||
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
|
||||
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
cwd: "/repo/openclaw",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.channel).toBe("discord");
|
||||
expect(resolved?.spec.conversationId).toBe("1478836151241412759");
|
||||
expect(resolved?.spec.agentId).toBe("codex");
|
||||
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:");
|
||||
expect(resolved?.record.metadata?.source).toBe("config");
|
||||
});
|
||||
|
||||
it("falls back to parent discord channel when conversation is a thread id", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "channel-parent-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-123",
|
||||
parentConversationId: "channel-parent-1",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe("channel-parent-1");
|
||||
expect(resolved?.record.conversation.conversationId).toBe("channel-parent-1");
|
||||
});
|
||||
|
||||
it("prefers direct discord thread binding over parent channel fallback", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "channel-parent-1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "thread-123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-123",
|
||||
parentConversationId: "channel-parent-1",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe("thread-123");
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("prefers exact account binding over wildcard for the same discord conversation", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "*",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("returns null when no top-level ACP binding matches the conversation", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "different-channel" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-123",
|
||||
parentConversationId: "channel-parent-1",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves telegram forum topic bindings using canonical conversation ids", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "-1001234567890:topic:42" },
|
||||
},
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const canonical = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
});
|
||||
const splitIds = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "42",
|
||||
parentConversationId: "-1001234567890",
|
||||
});
|
||||
|
||||
expect(canonical?.spec.conversationId).toBe("-1001234567890:topic:42");
|
||||
expect(splitIds?.spec.conversationId).toBe("-1001234567890:topic:42");
|
||||
expect(canonical?.spec.agentId).toBe("claude");
|
||||
expect(canonical?.spec.backend).toBe("acpx");
|
||||
expect(splitIds?.record.targetSessionKey).toBe(canonical?.record.targetSessionKey);
|
||||
});
|
||||
|
||||
it("skips telegram non-group topic configs", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "123456789:topic:42" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "123456789:topic:42",
|
||||
});
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("applies agent runtime ACP defaults for bound conversations", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main" },
|
||||
{
|
||||
id: "coding",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "oneshot",
|
||||
cwd: "/workspace/repo-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "coding",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.agentId).toBe("coding");
|
||||
expect(resolved?.spec.acpAgentId).toBe("codex");
|
||||
expect(resolved?.spec.mode).toBe("oneshot");
|
||||
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
|
||||
expect(resolved?.spec.backend).toBe("acpx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
it("maps a configured discord binding session key back to its spec", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
|
||||
expect(spec?.channel).toBe("discord");
|
||||
expect(spec?.conversationId).toBe("1478836151241412759");
|
||||
expect(spec?.agentId).toBe("codex");
|
||||
expect(spec?.backend).toBe("acpx");
|
||||
});
|
||||
|
||||
it("returns null for unknown session keys", () => {
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:main:acp:binding:discord:default:notfound",
|
||||
});
|
||||
expect(spec).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers exact account ACP settings over wildcard when session keys collide", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "*",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
backend: "wild",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
backend: "exact",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
|
||||
expect(spec?.backend).toBe("exact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfiguredAcpSessionKey", () => {
|
||||
it("is deterministic for the same conversation binding", () => {
|
||||
const sessionKeyA = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const sessionKeyB = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
expect(sessionKeyA).toBe(sessionKeyB);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureConfiguredAcpBindingSession", () => {
|
||||
it("keeps an existing ready session when configured binding omits cwd", async () => {
|
||||
const spec = {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
};
|
||||
const sessionKey = buildConfiguredAcpSessionKey(spec);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "existing",
|
||||
mode: "persistent",
|
||||
runtimeOptions: { cwd: "/workspace/openclaw" },
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
||||
expect(ensured).toEqual({ ok: true, sessionKey });
|
||||
expect(managerMocks.closeSession).not.toHaveBeenCalled();
|
||||
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => {
|
||||
const spec = {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
cwd: "/workspace/repo-a",
|
||||
};
|
||||
const sessionKey = buildConfiguredAcpSessionKey(spec);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "existing",
|
||||
mode: "persistent",
|
||||
runtimeOptions: { cwd: "/workspace/other-repo" },
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
||||
expect(ensured).toEqual({ ok: true, sessionKey });
|
||||
expect(managerMocks.closeSession).toHaveBeenCalledTimes(1);
|
||||
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
clearMeta: false,
|
||||
}),
|
||||
);
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("initializes ACP session with runtime agent override when provided", async () => {
|
||||
const spec = {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "coding",
|
||||
acpAgentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
};
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
||||
expect(ensured.ok).toBe(true);
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: "codex",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAcpSessionInPlace", () => {
|
||||
it("reinitializes from configured binding when ACP metadata is missing", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478844424791396446" },
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
const sessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478844424791396446",
|
||||
agentId: "claude",
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
});
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
reason: "new",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
backendId: "acpx",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clear ACP metadata before reinitialize succeeds", async () => {
|
||||
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
||||
acp: {
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
runtimeOptions: { cwd: "/home/bob/clawd" },
|
||||
},
|
||||
});
|
||||
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "backend unavailable" });
|
||||
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
clearMeta: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves harness agent ids during in-place reset even when not in agents.list", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "coding" }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const sessionKey = "agent:coding:acp:binding:discord:default:9373ab192b2317f4";
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
||||
acp: {
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
19
src/acp/persistent-bindings.ts
Normal file
19
src/acp/persistent-bindings.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
normalizeMode,
|
||||
normalizeText,
|
||||
toConfiguredAcpBindingRecord,
|
||||
type AcpBindingConfigShape,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.types.js";
|
||||
export {
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace,
|
||||
} from "./persistent-bindings.lifecycle.js";
|
||||
export {
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
} from "./persistent-bindings.resolve.js";
|
||||
105
src/acp/persistent-bindings.types.ts
Normal file
105
src/acp/persistent-bindings.types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "telegram";
|
||||
|
||||
export type ConfiguredAcpBindingSpec = {
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
/** Owning OpenClaw agent id (used for session identity/storage). */
|
||||
agentId: string;
|
||||
/** ACP harness agent id override (falls back to agentId when omitted). */
|
||||
acpAgentId?: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type ResolvedConfiguredAcpBinding = {
|
||||
spec: ConfiguredAcpBindingSpec;
|
||||
record: SessionBindingRecord;
|
||||
};
|
||||
|
||||
export type AcpBindingConfigShape = {
|
||||
mode?: string;
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function normalizeMode(value: unknown): AcpRuntimeSessionMode {
|
||||
const raw = normalizeText(value)?.toLowerCase();
|
||||
return raw === "oneshot" ? "oneshot" : "persistent";
|
||||
}
|
||||
|
||||
export function normalizeBindingConfig(raw: unknown): AcpBindingConfigShape {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {};
|
||||
}
|
||||
const shape = raw as AcpBindingConfigShape;
|
||||
const mode = normalizeText(shape.mode);
|
||||
return {
|
||||
mode: mode ? normalizeMode(mode) : undefined,
|
||||
cwd: normalizeText(shape.cwd),
|
||||
backend: normalizeText(shape.backend),
|
||||
label: normalizeText(shape.label),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBindingHash(params: {
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
}): string {
|
||||
return createHash("sha256")
|
||||
.update(`${params.channel}:${params.accountId}:${params.conversationId}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
export function buildConfiguredAcpSessionKey(spec: ConfiguredAcpBindingSpec): string {
|
||||
const hash = buildBindingHash({
|
||||
channel: spec.channel,
|
||||
accountId: spec.accountId,
|
||||
conversationId: spec.conversationId,
|
||||
});
|
||||
return `agent:${sanitizeAgentId(spec.agentId)}:acp:binding:${spec.channel}:${spec.accountId}:${hash}`;
|
||||
}
|
||||
|
||||
export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: `config:acp:${spec.channel}:${spec.accountId}:${spec.conversationId}`,
|
||||
targetSessionKey: buildConfiguredAcpSessionKey(spec),
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: spec.channel,
|
||||
accountId: spec.accountId,
|
||||
conversationId: spec.conversationId,
|
||||
parentConversationId: spec.parentConversationId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: {
|
||||
source: "config",
|
||||
mode: spec.mode,
|
||||
agentId: spec.agentId,
|
||||
...(spec.acpAgentId ? { acpAgentId: spec.acpAgentId } : {}),
|
||||
label: spec.label,
|
||||
...(spec.backend ? { backend: spec.backend } : {}),
|
||||
...(spec.cwd ? { cwd: spec.cwd } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
75
src/auto-reply/reply/acp-reset-target.ts
Normal file
75
src/auto-reply/reply/acp-reset-target.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
||||
|
||||
function normalizeText(value: string | undefined | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
conversationId?: string | null;
|
||||
parentConversationId?: string | null;
|
||||
activeSessionKey?: string | null;
|
||||
allowNonAcpBindingSessionKey?: boolean;
|
||||
skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
|
||||
fallbackToActiveAcpWhenUnbound?: boolean;
|
||||
}): string | undefined {
|
||||
const activeSessionKey = normalizeText(params.activeSessionKey);
|
||||
const activeAcpSessionKey =
|
||||
activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
|
||||
const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
|
||||
|
||||
const channel = normalizeText(params.channel).toLowerCase();
|
||||
const conversationId = normalizeText(params.conversationId);
|
||||
if (!channel || !conversationId) {
|
||||
return activeAcpSessionKey;
|
||||
}
|
||||
const accountId = normalizeText(params.accountId) || DEFAULT_ACCOUNT_ID;
|
||||
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
|
||||
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
|
||||
|
||||
const serviceBinding = getSessionBindingService().resolveByConversation({
|
||||
channel,
|
||||
accountId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
const serviceSessionKey =
|
||||
serviceBinding?.targetKind === "session" ? serviceBinding.targetSessionKey.trim() : "";
|
||||
if (serviceSessionKey) {
|
||||
if (allowNonAcpBindingSessionKey) {
|
||||
return serviceSessionKey;
|
||||
}
|
||||
return isAcpSessionKey(serviceSessionKey) ? serviceSessionKey : undefined;
|
||||
}
|
||||
|
||||
if (activeIsNonAcp && params.skipConfiguredFallbackWhenActiveSessionNonAcp) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
const configuredSessionKey =
|
||||
configuredBinding?.record.targetKind === "session"
|
||||
? configuredBinding.record.targetSessionKey.trim()
|
||||
: "";
|
||||
if (configuredSessionKey) {
|
||||
if (allowNonAcpBindingSessionKey) {
|
||||
return configuredSessionKey;
|
||||
}
|
||||
return isAcpSessionKey(configuredSessionKey) ? configuredSessionKey : undefined;
|
||||
}
|
||||
if (params.fallbackToActiveAcpWhenUnbound === false) {
|
||||
return undefined;
|
||||
}
|
||||
return activeAcpSessionKey;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import { resolveEffectiveResetTargetSessionKey } from "../acp-reset-target.js";
|
||||
import { resolveRequesterSessionKey } from "../commands-subagents/shared.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { resolveAcpCommandBindingContext } from "./context.js";
|
||||
@@ -35,19 +35,22 @@ async function resolveSessionKeyByToken(token: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
export function resolveBoundAcpThreadSessionKey(params: HandleCommandsParams): string | undefined {
|
||||
const commandTargetSessionKey =
|
||||
typeof params.ctx.CommandTargetSessionKey === "string"
|
||||
? params.ctx.CommandTargetSessionKey.trim()
|
||||
: "";
|
||||
const activeSessionKey = commandTargetSessionKey || params.sessionKey.trim();
|
||||
const bindingContext = resolveAcpCommandBindingContext(params);
|
||||
if (!bindingContext.channel || !bindingContext.conversationId) {
|
||||
return undefined;
|
||||
}
|
||||
const binding = getSessionBindingService().resolveByConversation({
|
||||
return resolveEffectiveResetTargetSessionKey({
|
||||
cfg: params.cfg,
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
parentConversationId: bindingContext.parentConversationId,
|
||||
activeSessionKey,
|
||||
allowNonAcpBindingSessionKey: true,
|
||||
skipConfiguredFallbackWhenActiveSessionNonAcp: false,
|
||||
});
|
||||
if (!binding || binding.targetKind !== "session") {
|
||||
return undefined;
|
||||
}
|
||||
return binding.targetSessionKey.trim() || undefined;
|
||||
}
|
||||
|
||||
export async function resolveAcpTargetSessionKey(params: {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { isAcpSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import { handleAcpCommand } from "./commands-acp.js";
|
||||
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import { handleApproveCommand } from "./commands-approve.js";
|
||||
import { handleBashCommand } from "./commands-bash.js";
|
||||
@@ -130,6 +133,40 @@ export async function emitResetCommandHooks(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void {
|
||||
const mutableCtx = ctx as Record<string, unknown>;
|
||||
mutableCtx.Body = resetTail;
|
||||
mutableCtx.RawBody = resetTail;
|
||||
mutableCtx.CommandBody = resetTail;
|
||||
mutableCtx.BodyForCommands = resetTail;
|
||||
mutableCtx.BodyForAgent = resetTail;
|
||||
mutableCtx.BodyStripped = resetTail;
|
||||
mutableCtx.AcpDispatchTailAfterReset = true;
|
||||
}
|
||||
|
||||
function resolveSessionEntryForHookSessionKey(
|
||||
sessionStore: HandleCommandsParams["sessionStore"] | undefined,
|
||||
sessionKey: string,
|
||||
): HandleCommandsParams["sessionEntry"] | undefined {
|
||||
if (!sessionStore) {
|
||||
return undefined;
|
||||
}
|
||||
const directEntry = sessionStore[sessionKey];
|
||||
if (directEntry) {
|
||||
return directEntry;
|
||||
}
|
||||
const normalizedTarget = sessionKey.trim().toLowerCase();
|
||||
if (!normalizedTarget) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [candidateKey, candidateEntry] of Object.entries(sessionStore)) {
|
||||
if (candidateKey.trim().toLowerCase() === normalizedTarget) {
|
||||
return candidateEntry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
|
||||
if (HANDLERS === null) {
|
||||
HANDLERS = [
|
||||
@@ -172,6 +209,74 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
// Trigger internal hook for reset/new commands
|
||||
if (resetRequested && params.command.isAuthorizedSender) {
|
||||
const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new";
|
||||
const resetTail =
|
||||
resetMatch != null
|
||||
? params.command.commandBodyNormalized.slice(resetMatch[0].length).trimStart()
|
||||
: "";
|
||||
const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params);
|
||||
const boundAcpKey =
|
||||
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
|
||||
? boundAcpSessionKey.trim()
|
||||
: undefined;
|
||||
if (boundAcpKey) {
|
||||
const resetResult = await resetAcpSessionInPlace({
|
||||
cfg: params.cfg,
|
||||
sessionKey: boundAcpKey,
|
||||
reason: commandAction,
|
||||
});
|
||||
if (!resetResult.ok && !resetResult.skipped) {
|
||||
logVerbose(
|
||||
`acp reset-in-place failed for ${boundAcpKey}: ${resetResult.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
if (resetResult.ok) {
|
||||
const hookSessionEntry =
|
||||
boundAcpKey === params.sessionKey
|
||||
? params.sessionEntry
|
||||
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
|
||||
const hookPreviousSessionEntry =
|
||||
boundAcpKey === params.sessionKey
|
||||
? params.previousSessionEntry
|
||||
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
|
||||
await emitResetCommandHooks({
|
||||
action: commandAction,
|
||||
ctx: params.ctx,
|
||||
cfg: params.cfg,
|
||||
command: params.command,
|
||||
sessionKey: boundAcpKey,
|
||||
sessionEntry: hookSessionEntry,
|
||||
previousSessionEntry: hookPreviousSessionEntry,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
if (resetTail) {
|
||||
applyAcpResetTailContext(params.ctx, resetTail);
|
||||
if (params.rootCtx && params.rootCtx !== params.ctx) {
|
||||
applyAcpResetTailContext(params.rootCtx, resetTail);
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "✅ ACP session reset in place." },
|
||||
};
|
||||
}
|
||||
if (resetResult.skipped) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ ACP session reset unavailable for this bound conversation. Rebind with /acp bind or /acp spawn.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "⚠️ ACP session reset failed. Check /acp status and try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
await emitResetCommandHooks({
|
||||
action: commandAction,
|
||||
ctx: params.ctx,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type CommandContext = {
|
||||
|
||||
export type HandleCommandsParams = {
|
||||
ctx: MsgContext;
|
||||
rootCtx?: MsgContext;
|
||||
cfg: OpenClawConfig;
|
||||
command: CommandContext;
|
||||
agentId?: string;
|
||||
|
||||
@@ -104,6 +104,27 @@ vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string };
|
||||
|
||||
const resetAcpSessionInPlaceMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
async (_params: unknown): Promise<ResetAcpSessionInPlaceResult> => ({
|
||||
ok: false,
|
||||
skipped: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.mock("../../acp/persistent-bindings.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../acp/persistent-bindings.js")>(
|
||||
"../../acp/persistent-bindings.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
|
||||
@@ -136,6 +157,11 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
|
||||
return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAcpSessionInPlaceMock.mockReset();
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const);
|
||||
});
|
||||
|
||||
describe("handleCommands gating", () => {
|
||||
it("blocks gated commands when disabled or not elevated-allowlisted", async () => {
|
||||
const cases = typedCases<{
|
||||
@@ -973,6 +999,226 @@ describe("handleCommands hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands ACP-bound /new and /reset", () => {
|
||||
const discordChannelId = "1478836151241412759";
|
||||
const buildDiscordBoundConfig = (): OpenClawConfig =>
|
||||
({
|
||||
commands: { text: true },
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: {
|
||||
kind: "channel",
|
||||
id: discordChannelId,
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["*"],
|
||||
guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } },
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
const buildDiscordBoundParams = (body: string) => {
|
||||
const params = buildParams(body, buildDiscordBoundConfig(), {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
AccountId: "default",
|
||||
SenderId: "12345",
|
||||
From: "discord:12345",
|
||||
To: discordChannelId,
|
||||
OriginatingTo: discordChannelId,
|
||||
SessionKey: "agent:main:acp:binding:discord:default:feedface",
|
||||
});
|
||||
params.sessionKey = "agent:main:acp:binding:discord:default:feedface";
|
||||
return params;
|
||||
};
|
||||
|
||||
it("handles /new as ACP in-place reset for bound conversations", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const result = await handleCommands(buildDiscordBoundParams("/new"));
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset in place");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
reason: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("continues with trailing prompt text after successful ACP-bound /new", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const params = buildDiscordBoundParams("/new continue with deployment");
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply).toBeUndefined();
|
||||
const mutableCtx = params.ctx as Record<string, unknown>;
|
||||
expect(mutableCtx.BodyStripped).toBe("continue with deployment");
|
||||
expect(mutableCtx.CommandBody).toBe("continue with deployment");
|
||||
expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true);
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles /reset failures without falling back to normal session reset flow", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
|
||||
const result = await handleCommands(buildDiscordBoundParams("/reset"));
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset failed");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
reason: "reset",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit reset hooks when ACP reset fails", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
|
||||
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
|
||||
|
||||
const result = await handleCommands(buildDiscordBoundParams("/reset"));
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps existing /new behavior for non-ACP sessions", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const result = await handleCommands(buildParams("/new", cfg));
|
||||
|
||||
expect(result.shouldContinue).toBe(true);
|
||||
expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => {
|
||||
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
|
||||
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: discordChannelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const params = buildDiscordBoundParams("/new");
|
||||
params.sessionKey = fallbackSessionKey;
|
||||
params.ctx.SessionKey = fallbackSessionKey;
|
||||
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset unavailable");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey: configuredAcpSessionKey,
|
||||
reason: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
|
||||
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
|
||||
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: discordChannelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const fallbackEntry = {
|
||||
sessionId: "fallback-session-id",
|
||||
sessionFile: "/tmp/fallback-session.jsonl",
|
||||
} as SessionEntry;
|
||||
const configuredEntry = {
|
||||
sessionId: "configured-acp-session-id",
|
||||
sessionFile: "/tmp/configured-acp-session.jsonl",
|
||||
} as SessionEntry;
|
||||
const params = buildDiscordBoundParams("/new");
|
||||
params.sessionKey = fallbackSessionKey;
|
||||
params.ctx.SessionKey = fallbackSessionKey;
|
||||
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
|
||||
params.sessionEntry = fallbackEntry;
|
||||
params.previousSessionEntry = fallbackEntry;
|
||||
params.sessionStore = {
|
||||
[fallbackSessionKey]: fallbackEntry,
|
||||
[configuredAcpSessionKey]: configuredEntry,
|
||||
};
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset in place");
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "command",
|
||||
action: "new",
|
||||
sessionKey: configuredAcpSessionKey,
|
||||
context: expect.objectContaining({
|
||||
sessionEntry: configuredEntry,
|
||||
previousSessionEntry: configuredEntry,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
hookSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses active ACP command target when conversation binding context is missing", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const params = buildParams(
|
||||
"/new",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
AccountId: "default",
|
||||
SenderId: "12345",
|
||||
From: "discord:12345",
|
||||
},
|
||||
);
|
||||
params.sessionKey = "discord:slash:12345";
|
||||
params.ctx.SessionKey = "discord:slash:12345";
|
||||
params.ctx.CommandSource = "native";
|
||||
params.ctx.CommandTargetSessionKey = activeAcpTarget;
|
||||
params.ctx.To = "user:12345";
|
||||
params.ctx.OriginatingTo = "user:12345";
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset in place");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey: activeAcpTarget,
|
||||
reason: "new",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands context", () => {
|
||||
it("returns expected details for /context commands", async () => {
|
||||
const cfg = {
|
||||
|
||||
@@ -178,7 +178,7 @@ function createAcpRuntime(events: Array<Record<string, unknown>>) {
|
||||
runtimeSessionName: `${input.sessionKey}:${input.mode}`,
|
||||
}) as { sessionKey: string; backend: string; runtimeSessionName: string },
|
||||
),
|
||||
runTurn: vi.fn(async function* () {
|
||||
runTurn: vi.fn(async function* (_params: { text?: string }) {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
@@ -912,6 +912,73 @@ describe("dispatchReplyFromConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes ACP reset tails through ACP after command handling", async () => {
|
||||
setNoAbort();
|
||||
const runtime = createAcpRuntime([
|
||||
{ type: "text_delta", text: "tail accepted" },
|
||||
{ type: "done" },
|
||||
]);
|
||||
acpMocks.readAcpSessionEntry.mockReturnValue({
|
||||
sessionKey: "agent:codex-acp:session-1",
|
||||
storeSessionKey: "agent:codex-acp:session-1",
|
||||
cfg: {},
|
||||
storePath: "/tmp/mock-sessions.json",
|
||||
entry: {},
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime:1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime,
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
acp: {
|
||||
enabled: true,
|
||||
dispatch: { enabled: true },
|
||||
},
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "deny",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
CommandSource: "native",
|
||||
SessionKey: "discord:slash:owner",
|
||||
CommandTargetSessionKey: "agent:codex-acp:session-1",
|
||||
CommandBody: "/new continue with deployment",
|
||||
BodyForCommands: "/new continue with deployment",
|
||||
BodyForAgent: "/new continue with deployment",
|
||||
});
|
||||
const replyResolver = vi.fn(async (resolverCtx: MsgContext) => {
|
||||
resolverCtx.Body = "continue with deployment";
|
||||
resolverCtx.RawBody = "continue with deployment";
|
||||
resolverCtx.CommandBody = "continue with deployment";
|
||||
resolverCtx.BodyForCommands = "continue with deployment";
|
||||
resolverCtx.BodyForAgent = "continue with deployment";
|
||||
resolverCtx.AcpDispatchTailAfterReset = true;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.runTurn).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.runTurn.mock.calls[0]?.[0]).toMatchObject({
|
||||
text: "continue with deployment",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bypass ACP slash aliases when text commands are disabled on native surfaces", async () => {
|
||||
setNoAbort();
|
||||
const runtime = createAcpRuntime([{ type: "done" }]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -133,6 +133,11 @@ export type MsgContext = {
|
||||
CommandAuthorized?: boolean;
|
||||
CommandSource?: "text" | "native";
|
||||
CommandTargetSessionKey?: string;
|
||||
/**
|
||||
* Internal flag: command handling prepared trailing prompt text for ACP dispatch.
|
||||
* Used for `/new <prompt>` and `/reset <prompt>` on ACP-bound sessions.
|
||||
*/
|
||||
AcpDispatchTailAfterReset?: boolean;
|
||||
/** Gateway client scopes when the message originates from the gateway. */
|
||||
GatewayClientScopes?: string[];
|
||||
/** Thread identifier (Telegram topic id or Matrix thread event id). */
|
||||
@@ -152,6 +157,11 @@ export type MsgContext = {
|
||||
* The chat/channel/user ID where the reply should be sent.
|
||||
*/
|
||||
OriginatingTo?: string;
|
||||
/**
|
||||
* Provider-specific parent conversation id for threaded contexts.
|
||||
* For Discord threads, this is the parent channel id.
|
||||
*/
|
||||
ThreadParentId?: string;
|
||||
/**
|
||||
* Messages from hooks to be included in the response.
|
||||
* Used for hook confirmation messages like "Session context saved to memory".
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import type { AgentRouteBinding } from "../config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
|
||||
function bindingMatchKey(match: AgentBinding["match"]) {
|
||||
function bindingMatchKey(match: AgentRouteBinding["match"]) {
|
||||
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||
const identityKey = bindingMatchIdentityKey(match);
|
||||
return [identityKey, accountId].join("|");
|
||||
}
|
||||
|
||||
function bindingMatchIdentityKey(match: AgentBinding["match"]) {
|
||||
function bindingMatchIdentityKey(match: AgentRouteBinding["match"]) {
|
||||
const roles = Array.isArray(match.roles)
|
||||
? Array.from(
|
||||
new Set(
|
||||
@@ -34,8 +35,8 @@ function bindingMatchIdentityKey(match: AgentBinding["match"]) {
|
||||
}
|
||||
|
||||
function canUpgradeBindingAccountScope(params: {
|
||||
existing: AgentBinding;
|
||||
incoming: AgentBinding;
|
||||
existing: AgentRouteBinding;
|
||||
incoming: AgentRouteBinding;
|
||||
normalizedIncomingAgentId: string;
|
||||
}): boolean {
|
||||
if (!params.incoming.match.accountId?.trim()) {
|
||||
@@ -53,7 +54,7 @@ function canUpgradeBindingAccountScope(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export function describeBinding(binding: AgentBinding) {
|
||||
export function describeBinding(binding: AgentRouteBinding) {
|
||||
const match = binding.match;
|
||||
const parts = [match.channel];
|
||||
if (match.accountId) {
|
||||
@@ -73,27 +74,28 @@ export function describeBinding(binding: AgentBinding) {
|
||||
|
||||
export function applyAgentBindings(
|
||||
cfg: OpenClawConfig,
|
||||
bindings: AgentBinding[],
|
||||
bindings: AgentRouteBinding[],
|
||||
): {
|
||||
config: OpenClawConfig;
|
||||
added: AgentBinding[];
|
||||
updated: AgentBinding[];
|
||||
skipped: AgentBinding[];
|
||||
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
|
||||
added: AgentRouteBinding[];
|
||||
updated: AgentRouteBinding[];
|
||||
skipped: AgentRouteBinding[];
|
||||
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>;
|
||||
} {
|
||||
const existing = [...(cfg.bindings ?? [])];
|
||||
const existingRoutes = [...listRouteBindings(cfg)];
|
||||
const nonRouteBindings = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
|
||||
const existingMatchMap = new Map<string, string>();
|
||||
for (const binding of existing) {
|
||||
for (const binding of existingRoutes) {
|
||||
const key = bindingMatchKey(binding.match);
|
||||
if (!existingMatchMap.has(key)) {
|
||||
existingMatchMap.set(key, normalizeAgentId(binding.agentId));
|
||||
}
|
||||
}
|
||||
|
||||
const added: AgentBinding[] = [];
|
||||
const updated: AgentBinding[] = [];
|
||||
const skipped: AgentBinding[] = [];
|
||||
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
|
||||
const added: AgentRouteBinding[] = [];
|
||||
const updated: AgentRouteBinding[] = [];
|
||||
const skipped: AgentRouteBinding[] = [];
|
||||
const conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }> = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
const agentId = normalizeAgentId(binding.agentId);
|
||||
@@ -108,7 +110,7 @@ export function applyAgentBindings(
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgradeIndex = existing.findIndex((candidate) =>
|
||||
const upgradeIndex = existingRoutes.findIndex((candidate) =>
|
||||
canUpgradeBindingAccountScope({
|
||||
existing: candidate,
|
||||
incoming: binding,
|
||||
@@ -116,12 +118,12 @@ export function applyAgentBindings(
|
||||
}),
|
||||
);
|
||||
if (upgradeIndex >= 0) {
|
||||
const current = existing[upgradeIndex];
|
||||
const current = existingRoutes[upgradeIndex];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const previousKey = bindingMatchKey(current.match);
|
||||
const upgradedBinding: AgentBinding = {
|
||||
const upgradedBinding: AgentRouteBinding = {
|
||||
...current,
|
||||
agentId,
|
||||
match: {
|
||||
@@ -129,7 +131,7 @@ export function applyAgentBindings(
|
||||
accountId: binding.match.accountId?.trim(),
|
||||
},
|
||||
};
|
||||
existing[upgradeIndex] = upgradedBinding;
|
||||
existingRoutes[upgradeIndex] = upgradedBinding;
|
||||
existingMatchMap.delete(previousKey);
|
||||
existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId);
|
||||
updated.push(upgradedBinding);
|
||||
@@ -147,7 +149,7 @@ export function applyAgentBindings(
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
bindings: [...existing, ...added],
|
||||
bindings: [...existingRoutes, ...added, ...nonRouteBindings],
|
||||
},
|
||||
added,
|
||||
updated,
|
||||
@@ -158,29 +160,30 @@ export function applyAgentBindings(
|
||||
|
||||
export function removeAgentBindings(
|
||||
cfg: OpenClawConfig,
|
||||
bindings: AgentBinding[],
|
||||
bindings: AgentRouteBinding[],
|
||||
): {
|
||||
config: OpenClawConfig;
|
||||
removed: AgentBinding[];
|
||||
missing: AgentBinding[];
|
||||
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
|
||||
removed: AgentRouteBinding[];
|
||||
missing: AgentRouteBinding[];
|
||||
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>;
|
||||
} {
|
||||
const existing = cfg.bindings ?? [];
|
||||
const existingRoutes = listRouteBindings(cfg);
|
||||
const nonRouteBindings = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
|
||||
const removeIndexes = new Set<number>();
|
||||
const removed: AgentBinding[] = [];
|
||||
const missing: AgentBinding[] = [];
|
||||
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
|
||||
const removed: AgentRouteBinding[] = [];
|
||||
const missing: AgentRouteBinding[] = [];
|
||||
const conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }> = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
const desiredAgentId = normalizeAgentId(binding.agentId);
|
||||
const key = bindingMatchKey(binding.match);
|
||||
let matchedIndex = -1;
|
||||
let conflictingAgentId: string | null = null;
|
||||
for (let i = 0; i < existing.length; i += 1) {
|
||||
for (let i = 0; i < existingRoutes.length; i += 1) {
|
||||
if (removeIndexes.has(i)) {
|
||||
continue;
|
||||
}
|
||||
const current = existing[i];
|
||||
const current = existingRoutes[i];
|
||||
if (!current || bindingMatchKey(current.match) !== key) {
|
||||
continue;
|
||||
}
|
||||
@@ -192,7 +195,7 @@ export function removeAgentBindings(
|
||||
conflictingAgentId = currentAgentId;
|
||||
}
|
||||
if (matchedIndex >= 0) {
|
||||
const matched = existing[matchedIndex];
|
||||
const matched = existingRoutes[matchedIndex];
|
||||
if (matched) {
|
||||
removeIndexes.add(matchedIndex);
|
||||
removed.push(matched);
|
||||
@@ -210,7 +213,8 @@ export function removeAgentBindings(
|
||||
return { config: cfg, removed, missing, conflicts };
|
||||
}
|
||||
|
||||
const nextBindings = existing.filter((_, index) => !removeIndexes.has(index));
|
||||
const nextRouteBindings = existingRoutes.filter((_, index) => !removeIndexes.has(index));
|
||||
const nextBindings = [...nextRouteBindings, ...nonRouteBindings];
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
@@ -262,11 +266,11 @@ export function buildChannelBindings(params: {
|
||||
selection: ChannelChoice[];
|
||||
config: OpenClawConfig;
|
||||
accountIds?: Partial<Record<ChannelChoice, string>>;
|
||||
}): AgentBinding[] {
|
||||
const bindings: AgentBinding[] = [];
|
||||
}): AgentRouteBinding[] {
|
||||
const bindings: AgentRouteBinding[] = [];
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
for (const channel of params.selection) {
|
||||
const match: AgentBinding["match"] = { channel };
|
||||
const match: AgentRouteBinding["match"] = { channel };
|
||||
const accountId = resolveBindingAccountId({
|
||||
channel,
|
||||
config: params.config,
|
||||
@@ -276,7 +280,7 @@ export function buildChannelBindings(params: {
|
||||
if (accountId) {
|
||||
match.accountId = accountId;
|
||||
}
|
||||
bindings.push({ agentId, match });
|
||||
bindings.push({ type: "route", agentId, match });
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
@@ -285,8 +289,8 @@ export function parseBindingSpecs(params: {
|
||||
agentId: string;
|
||||
specs?: string[];
|
||||
config: OpenClawConfig;
|
||||
}): { bindings: AgentBinding[]; errors: string[] } {
|
||||
const bindings: AgentBinding[] = [];
|
||||
}): { bindings: AgentRouteBinding[]; errors: string[] } {
|
||||
const bindings: AgentRouteBinding[] = [];
|
||||
const errors: string[] = [];
|
||||
const specs = params.specs ?? [];
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
@@ -312,11 +316,11 @@ export function parseBindingSpecs(params: {
|
||||
agentId,
|
||||
explicitAccountId: accountId,
|
||||
});
|
||||
const match: AgentBinding["match"] = { channel };
|
||||
const match: AgentRouteBinding["match"] = { channel };
|
||||
if (accountId) {
|
||||
match.accountId = accountId;
|
||||
}
|
||||
bindings.push({ agentId, match });
|
||||
bindings.push({ type: "route", agentId, match });
|
||||
}
|
||||
return { bindings, errors };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
|
||||
import { writeConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import type { AgentRouteBinding } from "../config/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -56,7 +57,7 @@ function hasAgent(cfg: Awaited<ReturnType<typeof requireValidConfig>>, agentId:
|
||||
return buildAgentSummaries(cfg).some((summary) => summary.id === agentId);
|
||||
}
|
||||
|
||||
function formatBindingOwnerLine(binding: AgentBinding): string {
|
||||
function formatBindingOwnerLine(binding: AgentRouteBinding): string {
|
||||
return `${normalizeAgentId(binding.agentId)} <- ${describeBinding(binding)}`;
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ function resolveTargetAgentIdOrExit(params: {
|
||||
}
|
||||
|
||||
function formatBindingConflicts(
|
||||
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>,
|
||||
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>,
|
||||
): string[] {
|
||||
return conflicts.map(
|
||||
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
||||
@@ -171,7 +172,7 @@ export async function agentsBindingsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = (cfg.bindings ?? []).filter(
|
||||
const filtered = listRouteBindings(cfg).filter(
|
||||
(binding) => !filterAgentId || normalizeAgentId(binding.agentId) === filterAgentId,
|
||||
);
|
||||
if (opts.json) {
|
||||
@@ -300,16 +301,18 @@ export async function agentsUnbindCommand(
|
||||
}
|
||||
|
||||
if (opts.all) {
|
||||
const existing = cfg.bindings ?? [];
|
||||
const existing = listRouteBindings(cfg);
|
||||
const removed = existing.filter((binding) => normalizeAgentId(binding.agentId) === agentId);
|
||||
const kept = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
|
||||
const keptRoutes = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
|
||||
const nonRoutes = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
|
||||
if (removed.length === 0) {
|
||||
runtime.log(`No bindings to remove for agent "${agentId}".`);
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
...cfg,
|
||||
bindings: kept.length > 0 ? kept : undefined,
|
||||
bindings:
|
||||
[...keptRoutes, ...nonRoutes].length > 0 ? [...keptRoutes, ...nonRoutes] : undefined,
|
||||
};
|
||||
await writeConfigFile(next);
|
||||
if (!opts.json) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { AgentRouteBinding } from "../config/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -81,8 +82,8 @@ export async function agentsListCommand(
|
||||
}
|
||||
|
||||
const summaries = buildAgentSummaries(cfg);
|
||||
const bindingMap = new Map<string, AgentBinding[]>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const bindingMap = new Map<string, AgentRouteBinding[]>();
|
||||
for (const binding of listRouteBindings(cfg)) {
|
||||
const agentId = normalizeAgentId(binding.agentId);
|
||||
const list = bindingMap.get(agentId) ?? [];
|
||||
list.push(binding);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
loadAgentIdentityFromWorkspace,
|
||||
parseIdentityMarkdown as parseIdentityMarkdownFile,
|
||||
} from "../agents/identity-file.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
@@ -88,7 +89,7 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] {
|
||||
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
|
||||
: [defaultAgentId];
|
||||
const bindingCounts = new Map<string, number>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
for (const binding of listRouteBindings(cfg)) {
|
||||
const agentId = normalizeAgentId(binding.agentId);
|
||||
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../channels/telegram/allow-from.js";
|
||||
import { fetchTelegramChatId } from "../channels/telegram/api.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||
@@ -265,7 +266,7 @@ function collectChannelsMissingDefaultAccount(
|
||||
}
|
||||
|
||||
export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] {
|
||||
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
||||
const bindings = listRouteBindings(cfg);
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const { channelKey, normalizedAccountIds } of collectChannelsMissingDefaultAccount(cfg)) {
|
||||
|
||||
26
src/config/bindings.ts
Normal file
26
src/config/bindings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
|
||||
|
||||
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
|
||||
return binding.type === "acp" ? "acp" : "route";
|
||||
}
|
||||
|
||||
export function isRouteBinding(binding: AgentBinding): binding is AgentRouteBinding {
|
||||
return normalizeBindingType(binding) === "route";
|
||||
}
|
||||
|
||||
export function isAcpBinding(binding: AgentBinding): binding is AgentAcpBinding {
|
||||
return normalizeBindingType(binding) === "acp";
|
||||
}
|
||||
|
||||
export function listConfiguredBindings(cfg: OpenClawConfig): AgentBinding[] {
|
||||
return Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
||||
}
|
||||
|
||||
export function listRouteBindings(cfg: OpenClawConfig): AgentRouteBinding[] {
|
||||
return listConfiguredBindings(cfg).filter(isRouteBinding);
|
||||
}
|
||||
|
||||
export function listAcpBindings(cfg: OpenClawConfig): AgentAcpBinding[] {
|
||||
return listConfiguredBindings(cfg).filter(isAcpBinding);
|
||||
}
|
||||
147
src/config/config.acp-binding-cutover.test.ts
Normal file
147
src/config/config.acp-binding-cutover.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
describe("ACP binding cutover schema", () => {
|
||||
it("accepts top-level typed ACP bindings with per-agent runtime defaults", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, runtime: { type: "embedded" } },
|
||||
{
|
||||
id: "coding",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "route",
|
||||
agentId: "main",
|
||||
match: { channel: "discord", accountId: "default" },
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "coding",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
label: "codex-main",
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects legacy Discord channel-local ACP binding fields", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"1459246755253325866": {
|
||||
channels: {
|
||||
"1478836151241412759": {
|
||||
bindings: {
|
||||
acp: {
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects legacy Telegram topic-local ACP binding fields", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
topics: {
|
||||
"42": {
|
||||
bindings: {
|
||||
acp: {
|
||||
agentId: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ACP bindings without a peer conversation target", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: { channel: "discord", accountId: "default" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ACP bindings on unsupported channels", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "C123456" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-canonical Telegram ACP topic peer IDs", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "42" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,20 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.",
|
||||
"agents.list":
|
||||
"Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.",
|
||||
"agents.list[].runtime":
|
||||
"Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.",
|
||||
"agents.list[].runtime.type":
|
||||
'Runtime type for this agent: "embedded" (default OpenClaw runtime) or "acp" (ACP harness defaults).',
|
||||
"agents.list[].runtime.acp":
|
||||
"ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.",
|
||||
"agents.list[].runtime.acp.agent":
|
||||
"Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).",
|
||||
"agents.list[].runtime.acp.backend":
|
||||
"Optional ACP backend override for this agent's ACP sessions (falls back to global acp.backend).",
|
||||
"agents.list[].runtime.acp.mode":
|
||||
"Optional ACP session mode default for this agent (persistent or oneshot).",
|
||||
"agents.list[].runtime.acp.cwd":
|
||||
"Optional default working directory for this agent's ACP sessions.",
|
||||
"agents.list[].identity.avatar":
|
||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||
"agents.defaults.heartbeat.suppressToolErrorWarnings":
|
||||
@@ -397,7 +411,9 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"audio.transcription.timeoutSeconds":
|
||||
"Maximum time allowed for the transcription command to finish before it is aborted. Increase this for longer recordings, and keep it tight in latency-sensitive deployments.",
|
||||
bindings:
|
||||
"Static routing bindings that pin inbound conversations to specific agent IDs by match rules. Use bindings for deterministic ownership when dynamic routing should not decide.",
|
||||
"Top-level binding rules for routing and persistent ACP conversation ownership. Use type=route for normal routing and type=acp for persistent ACP harness bindings.",
|
||||
"bindings[].type":
|
||||
'Binding kind. Use "route" (or omit for legacy route entries) for normal routing, and "acp" for persistent ACP conversation bindings.',
|
||||
"bindings[].agentId":
|
||||
"Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.",
|
||||
"bindings[].match":
|
||||
@@ -418,6 +434,14 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Optional team/workspace ID constraint used by providers that scope chats under teams. Add this when you need bindings isolated to one workspace context.",
|
||||
"bindings[].match.roles":
|
||||
"Optional role-based filter list used by providers that attach roles to chat context. Use this to route privileged or operational role traffic to specialized agents.",
|
||||
"bindings[].acp":
|
||||
"Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.list[].runtime.acp defaults for the matched conversation.",
|
||||
"bindings[].acp.mode": "ACP session mode override for this binding (persistent or oneshot).",
|
||||
"bindings[].acp.label":
|
||||
"Human-friendly label for ACP status/diagnostics in this bound conversation.",
|
||||
"bindings[].acp.cwd": "Working directory override for ACP sessions created from this binding.",
|
||||
"bindings[].acp.backend":
|
||||
"ACP backend override for this binding (falls back to agent runtime ACP backend, then global acp.backend).",
|
||||
broadcast:
|
||||
"Broadcast routing map for sending the same outbound message to multiple peer IDs per source conversation. Keep this minimal and audited because one source can fan out to many destinations.",
|
||||
"broadcast.strategy":
|
||||
|
||||
@@ -56,6 +56,13 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||
"agents.list.*.skills": "Agent Skill Filter",
|
||||
"agents.list[].runtime": "Agent Runtime",
|
||||
"agents.list[].runtime.type": "Agent Runtime Type",
|
||||
"agents.list[].runtime.acp": "Agent ACP Runtime",
|
||||
"agents.list[].runtime.acp.agent": "Agent ACP Harness Agent",
|
||||
"agents.list[].runtime.acp.backend": "Agent ACP Backend",
|
||||
"agents.list[].runtime.acp.mode": "Agent ACP Mode",
|
||||
"agents.list[].runtime.acp.cwd": "Agent ACP Working Directory",
|
||||
agents: "Agents",
|
||||
"agents.defaults": "Agent Defaults",
|
||||
"agents.list": "Agent List",
|
||||
@@ -259,6 +266,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"audio.transcription.command": "Audio Transcription Command",
|
||||
"audio.transcription.timeoutSeconds": "Audio Transcription Timeout (sec)",
|
||||
bindings: "Bindings",
|
||||
"bindings[].type": "Binding Type",
|
||||
"bindings[].agentId": "Binding Agent ID",
|
||||
"bindings[].match": "Binding Match Rule",
|
||||
"bindings[].match.channel": "Binding Channel",
|
||||
@@ -269,6 +277,11 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"bindings[].match.guildId": "Binding Guild ID",
|
||||
"bindings[].match.teamId": "Binding Team ID",
|
||||
"bindings[].match.roles": "Binding Roles",
|
||||
"bindings[].acp": "ACP Binding Overrides",
|
||||
"bindings[].acp.mode": "ACP Binding Mode",
|
||||
"bindings[].acp.label": "ACP Binding Label",
|
||||
"bindings[].acp.cwd": "ACP Binding Working Directory",
|
||||
"bindings[].acp.backend": "ACP Binding Backend",
|
||||
broadcast: "Broadcast",
|
||||
"broadcast.strategy": "Broadcast Strategy",
|
||||
"broadcast.*": "Broadcast Destination List",
|
||||
|
||||
@@ -5,6 +5,59 @@ import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
|
||||
import type { GroupChatConfig } from "./types.messages.js";
|
||||
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
|
||||
|
||||
export type AgentRuntimeAcpConfig = {
|
||||
/** ACP harness adapter id (for example codex, claude). */
|
||||
agent?: string;
|
||||
/** Optional ACP backend override for this agent runtime. */
|
||||
backend?: string;
|
||||
/** Optional ACP session mode override. */
|
||||
mode?: "persistent" | "oneshot";
|
||||
/** Optional runtime working directory override. */
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type AgentRuntimeConfig =
|
||||
| {
|
||||
type: "embedded";
|
||||
}
|
||||
| {
|
||||
type: "acp";
|
||||
acp?: AgentRuntimeAcpConfig;
|
||||
};
|
||||
|
||||
export type AgentBindingMatch = {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
peer?: { kind: ChatType; id: string };
|
||||
guildId?: string;
|
||||
teamId?: string;
|
||||
/** Discord role IDs used for role-based routing. */
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
export type AgentRouteBinding = {
|
||||
/** Missing type is interpreted as route for backward compatibility. */
|
||||
type?: "route";
|
||||
agentId: string;
|
||||
comment?: string;
|
||||
match: AgentBindingMatch;
|
||||
};
|
||||
|
||||
export type AgentAcpBinding = {
|
||||
type: "acp";
|
||||
agentId: string;
|
||||
comment?: string;
|
||||
match: AgentBindingMatch;
|
||||
acp?: {
|
||||
mode?: "persistent" | "oneshot";
|
||||
label?: string;
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentBinding = AgentRouteBinding | AgentAcpBinding;
|
||||
|
||||
export type AgentConfig = {
|
||||
id: string;
|
||||
default?: boolean;
|
||||
@@ -32,23 +85,11 @@ export type AgentConfig = {
|
||||
/** Optional per-agent stream params (e.g. cacheRetention, temperature). */
|
||||
params?: Record<string, unknown>;
|
||||
tools?: AgentToolsConfig;
|
||||
/** Optional runtime descriptor for this agent. */
|
||||
runtime?: AgentRuntimeConfig;
|
||||
};
|
||||
|
||||
export type AgentsConfig = {
|
||||
defaults?: AgentDefaultsConfig;
|
||||
list?: AgentConfig[];
|
||||
};
|
||||
|
||||
export type AgentBinding = {
|
||||
agentId: string;
|
||||
comment?: string;
|
||||
match: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
peer?: { kind: ChatType; id: string };
|
||||
guildId?: string;
|
||||
teamId?: string;
|
||||
/** Discord role IDs used for role-based routing. */
|
||||
roles?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../acp/persistent-bindings.js", () => ({
|
||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
||||
ensureConfiguredAcpBindingSessionMock(...args),
|
||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
||||
resolveConfiguredAcpBindingRecordMock(...args),
|
||||
}));
|
||||
|
||||
import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js";
|
||||
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
const GUILD_ID = "guild-1";
|
||||
const CHANNEL_ID = "channel-1";
|
||||
|
||||
function createConfiguredDiscordBinding() {
|
||||
return {
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: CHANNEL_ID,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:discord:default:channel-1",
|
||||
targetSessionKey: "agent:codex:acp:binding:discord:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: CHANNEL_ID,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: {
|
||||
source: "config",
|
||||
mode: "persistent",
|
||||
agentId: "codex",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
||||
const message = {
|
||||
id: "m-1",
|
||||
content: "<@bot-1> hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: CHANNEL_ID,
|
||||
attachments: [],
|
||||
mentionedUsers: [{ id: "bot-1" }],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
const client = {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === CHANNEL_ID) {
|
||||
return {
|
||||
id: CHANNEL_ID,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
|
||||
return {
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "bot-1",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: {
|
||||
channel_id: CHANNEL_ID,
|
||||
guild_id: GUILD_ID,
|
||||
guild: {
|
||||
id: GUILD_ID,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
...overrides,
|
||||
} satisfies Parameters<typeof preflightDiscordMessage>[0];
|
||||
}
|
||||
|
||||
describe("preflightDiscordMessage configured ACP bindings", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding());
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:discord:default:abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not initialize configured ACP bindings for rejected messages", async () => {
|
||||
const result = await preflightDiscordMessage(
|
||||
createBasePreflightParams({
|
||||
guildEntries: {
|
||||
[GUILD_ID]: {
|
||||
id: GUILD_ID,
|
||||
channels: {
|
||||
[CHANNEL_ID]: {
|
||||
allow: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initializes configured ACP bindings only after preflight accepts the message", async () => {
|
||||
const result = await preflightDiscordMessage(
|
||||
createBasePreflightParams({
|
||||
guildEntries: {
|
||||
[GUILD_ID]: {
|
||||
id: GUILD_ID,
|
||||
channels: {
|
||||
[CHANNEL_ID]: {
|
||||
allow: true,
|
||||
enabled: true,
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -7,10 +7,32 @@ import * as pluginCommandsModule from "../../plugins/commands.js";
|
||||
import { createDiscordNativeCommand } from "./native-command.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("../../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("../../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:discord:default:seed",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../acp/persistent-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
};
|
||||
});
|
||||
|
||||
type MockCommandInteraction = {
|
||||
user: { id: string; username: string; globalName: string };
|
||||
channel: { type: ChannelType; id: string };
|
||||
guild: null;
|
||||
guild: { id: string; name?: string } | null;
|
||||
rawData: { id: string; member: { roles: string[] } };
|
||||
options: {
|
||||
getString: ReturnType<typeof vi.fn>;
|
||||
@@ -22,7 +44,13 @@ type MockCommandInteraction = {
|
||||
client: object;
|
||||
};
|
||||
|
||||
function createInteraction(): MockCommandInteraction {
|
||||
function createInteraction(params?: {
|
||||
channelType?: ChannelType;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
guildName?: string;
|
||||
}): MockCommandInteraction {
|
||||
const guild = params?.guildId ? { id: params.guildId, name: params.guildName } : null;
|
||||
return {
|
||||
user: {
|
||||
id: "owner",
|
||||
@@ -30,10 +58,10 @@ function createInteraction(): MockCommandInteraction {
|
||||
globalName: "Tester",
|
||||
},
|
||||
channel: {
|
||||
type: ChannelType.DM,
|
||||
id: "dm-1",
|
||||
type: params?.channelType ?? ChannelType.DM,
|
||||
id: params?.channelId ?? "dm-1",
|
||||
},
|
||||
guild: null,
|
||||
guild,
|
||||
rawData: {
|
||||
id: "interaction-1",
|
||||
member: { roles: [] },
|
||||
@@ -62,6 +90,13 @@ function createConfig(): OpenClawConfig {
|
||||
describe("Discord native plugin command dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:discord:default:seed",
|
||||
});
|
||||
});
|
||||
|
||||
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
|
||||
@@ -110,4 +145,192 @@ describe("Discord native plugin command dispatch", () => {
|
||||
expect.objectContaining({ content: "direct plugin output" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
|
||||
const guildId = "1459246755253325866";
|
||||
const channelId = "1478836151241412759";
|
||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const cfg = {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: channelId },
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as OpenClawConfig;
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
const command = createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
guildId,
|
||||
guildName: "Ops",
|
||||
});
|
||||
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:discord:default:1478836151241412759",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
|
||||
const channelId = "dm-1";
|
||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface";
|
||||
const cfg = {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: channelId },
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
const command = createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.DM,
|
||||
channelId,
|
||||
});
|
||||
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:discord:default:dm-1",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
136
src/telegram/bot-message-context.acp-bindings.test.ts
Normal file
136
src/telegram/bot-message-context.acp-bindings.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../acp/persistent-bindings.js", () => ({
|
||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
||||
ensureConfiguredAcpBindingSessionMock(...args),
|
||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
||||
resolveConfiguredAcpBindingRecordMock(...args),
|
||||
}));
|
||||
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
function createConfiguredTelegramBinding() {
|
||||
return {
|
||||
spec: {
|
||||
channel: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:telegram:work:-1001234567890:topic:42",
|
||||
targetSessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "work",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: {
|
||||
source: "config",
|
||||
mode: "persistent",
|
||||
agentId: "codex",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
describe("buildTelegramMessageContext ACP configured bindings", () => {
|
||||
beforeEach(() => {
|
||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding());
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.route.accountId).toBe("work");
|
||||
expect(ctx?.route.matchedBy).toBe("binding.channel");
|
||||
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
|
||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips ACP session initialization when topic access is denied", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "hello",
|
||||
},
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: { enabled: false },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers ACP session initialization for unauthorized control commands", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "/new",
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {},
|
||||
},
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops inbound processing when configured ACP binding initialization fails", async () => {
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ok: false,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
accountId: "work",
|
||||
message: {
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
|
||||
message_thread_id: 42,
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ type BuildTelegramMessageContextForTestParams = {
|
||||
allMedia?: TelegramMediaRef[];
|
||||
options?: BuildTelegramMessageContextParams["options"];
|
||||
cfg?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
|
||||
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
|
||||
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
|
||||
@@ -45,7 +46,7 @@ export async function buildTelegramMessageContextForTest(
|
||||
},
|
||||
} as never,
|
||||
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
|
||||
account: { accountId: "default" } as never,
|
||||
account: { accountId: params.accountId ?? "default" } as never,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
dmPolicy: "open",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Bot } from "grammy";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
} from "../acp/persistent-bindings.route.js";
|
||||
import { resolveAckReaction } from "../agents/identity.js";
|
||||
import {
|
||||
findModelInCatalog,
|
||||
@@ -245,9 +249,22 @@ export const buildTelegramMessageContext = async ({
|
||||
`telegram: per-topic agent override: topic=${resolvedThreadId ?? dmThreadId} agent=${topicAgentId} sessionKey=${overrideSessionKey}`,
|
||||
);
|
||||
}
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
cfg: freshCfg,
|
||||
route,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
conversationId: peerId,
|
||||
parentConversationId: isGroup ? String(chatId) : undefined,
|
||||
});
|
||||
const configuredBinding = configuredRoute.configuredBinding;
|
||||
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
const requiresExplicitAccountBinding = (candidate: ResolvedAgentRoute): boolean =>
|
||||
candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default";
|
||||
// Fail closed for named Telegram accounts when route resolution falls back to
|
||||
// default-agent routing. This prevents cross-account DM/session contamination.
|
||||
if (route.accountId !== DEFAULT_ACCOUNT_ID && route.matchedBy === "default") {
|
||||
if (requiresExplicitAccountBinding(route)) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
@@ -256,14 +273,6 @@ export const buildTelegramMessageContext = async ({
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const baseSessionKey = route.sessionKey;
|
||||
// DMs: use thread suffix for session isolation (works regardless of dmScope)
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
@@ -307,21 +316,6 @@ export const buildTelegramMessageContext = async ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute requireMention early for preflight transcription gating
|
||||
const activationOverride = resolveGroupActivation({
|
||||
chatId,
|
||||
messageThreadId: resolvedThreadId,
|
||||
sessionKey: sessionKey,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const baseRequireMention = resolveGroupRequireMention(chatId);
|
||||
const requireMention = firstDefined(
|
||||
activationOverride,
|
||||
topicConfig?.requireMention,
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
|
||||
baseRequireMention,
|
||||
);
|
||||
|
||||
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
|
||||
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
|
||||
if (topicRequiredButMissing) {
|
||||
@@ -371,6 +365,54 @@ export const buildTelegramMessageContext = async ({
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
|
||||
if (!configuredBinding) {
|
||||
return true;
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg: freshCfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logVerbose(
|
||||
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "configured ACP binding unavailable",
|
||||
target: configuredBinding.spec.conversationId,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
// DMs: use thread suffix for session isolation (works regardless of dmScope)
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
// Compute requireMention after access checks and final route selection.
|
||||
const activationOverride = resolveGroupActivation({
|
||||
chatId,
|
||||
messageThreadId: resolvedThreadId,
|
||||
sessionKey: sessionKey,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const baseRequireMention = resolveGroupRequireMention(chatId);
|
||||
const requireMention = firstDefined(
|
||||
activationOverride,
|
||||
topicConfig?.requireMention,
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
|
||||
baseRequireMention,
|
||||
);
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
@@ -553,6 +595,10 @@ export const buildTelegramMessageContext = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await ensureConfiguredBindingReady())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ACK reactions
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
||||
channel: "telegram",
|
||||
|
||||
@@ -5,6 +5,18 @@ import { createNativeCommandTestParams } from "./bot-native-commands.test-helper
|
||||
|
||||
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
||||
})),
|
||||
}));
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
recordSessionMetaFromInbound: vi.fn(),
|
||||
resolveStorePath: vi.fn(),
|
||||
@@ -13,6 +25,14 @@ const replyMocks = vi.hoisted(() => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../acp/persistent-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../acp/persistent-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
};
|
||||
});
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound,
|
||||
resolveStorePath: sessionMocks.resolveStorePath,
|
||||
@@ -64,31 +84,102 @@ function buildStatusCommandContext() {
|
||||
};
|
||||
}
|
||||
|
||||
function registerAndResolveStatusHandler(cfg: OpenClawConfig): TelegramCommandHandler {
|
||||
function buildStatusTopicCommandContext() {
|
||||
return {
|
||||
match: "",
|
||||
message: {
|
||||
message_id: 2,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup" as const,
|
||||
title: "OpenClaw",
|
||||
is_forum: true,
|
||||
},
|
||||
message_thread_id: 42,
|
||||
from: { id: 200, username: "bob" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerAndResolveStatusHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { cfg, allowFrom, groupAllowFrom } = params;
|
||||
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage,
|
||||
},
|
||||
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
cfg,
|
||||
allowFrom: ["*"],
|
||||
allowFrom: allowFrom ?? ["*"],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
}),
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get("status");
|
||||
expect(handler).toBeTruthy();
|
||||
return handler as TelegramCommandHandler;
|
||||
return { handler: handler as TelegramCommandHandler, sendMessage };
|
||||
}
|
||||
|
||||
function registerAndResolveCommandHandler(params: {
|
||||
commandName: string;
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { commandName, cfg, allowFrom, groupAllowFrom, useAccessGroups } = params;
|
||||
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
registerTelegramNativeCommands({
|
||||
...createNativeCommandTestParams({
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage,
|
||||
},
|
||||
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? [],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: useAccessGroups ?? true,
|
||||
}),
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get(commandName);
|
||||
expect(handler).toBeTruthy();
|
||||
return { handler: handler as TelegramCommandHandler, sendMessage };
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
beforeEach(() => {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
||||
});
|
||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined);
|
||||
@@ -96,7 +187,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
|
||||
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const handler = registerAndResolveStatusHandler(cfg);
|
||||
const { handler } = registerAndResolveStatusHandler({ cfg });
|
||||
await handler(buildStatusCommandContext());
|
||||
|
||||
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
|
||||
@@ -115,7 +206,7 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise);
|
||||
|
||||
const cfg: OpenClawConfig = {};
|
||||
const handler = registerAndResolveStatusHandler(cfg);
|
||||
const { handler } = registerAndResolveStatusHandler({ cfg });
|
||||
const runPromise = handler(buildStatusCommandContext());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -128,4 +219,168 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
});
|
||||
await handler(buildStatusTopicCommandContext());
|
||||
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||
>
|
||||
)[0]?.[0];
|
||||
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
});
|
||||
|
||||
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: false,
|
||||
sessionKey: boundSessionKey,
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
allowFrom: ["200"],
|
||||
groupAllowFrom: ["200"],
|
||||
});
|
||||
await handler(buildStatusTopicCommandContext());
|
||||
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"Configured ACP binding is unavailable right now. Please try again.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
parentConversationId: "-1001234567890",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
cfg: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
await handler(buildStatusTopicCommandContext());
|
||||
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"You are not authorized to use this command.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
cfg: {},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
useAccessGroups: true,
|
||||
});
|
||||
await handler(buildStatusTopicCommandContext());
|
||||
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"You are not authorized to use this command.",
|
||||
expect.objectContaining({ message_thread_id: 42 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Bot, Context } from "grammy";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
} from "../acp/persistent-bindings.route.js";
|
||||
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||
import type { CommandArgs } from "../auto-reply/commands-registry.js";
|
||||
import {
|
||||
@@ -170,6 +174,11 @@ async function resolveTelegramCommandAuth(params: {
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
chatId,
|
||||
accountId,
|
||||
@@ -205,9 +214,10 @@ async function resolveTelegramCommandAuth(params: {
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
|
||||
const sendAuthMessage = async (text: string) => {
|
||||
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
fn: () => bot.api.sendMessage(chatId, text),
|
||||
fn: () => bot.api.sendMessage(chatId, text, threadParams),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
@@ -409,12 +419,19 @@ export const registerTelegramNativeCommands = ({
|
||||
botIdentity: opts.token,
|
||||
});
|
||||
|
||||
const resolveCommandRuntimeContext = (params: {
|
||||
const resolveCommandRuntimeContext = async (params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
resolvedThreadId?: number;
|
||||
}) => {
|
||||
}): Promise<{
|
||||
chatId: number;
|
||||
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
mediaLocalRoots: readonly string[] | undefined;
|
||||
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
chunkMode: ReturnType<typeof resolveChunkMode>;
|
||||
} | null> => {
|
||||
const { msg, isGroup, isForum, resolvedThreadId } = params;
|
||||
const chatId = msg.chat.id;
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
@@ -424,16 +441,49 @@ export const registerTelegramNativeCommands = ({
|
||||
messageThreadId,
|
||||
});
|
||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||
const route = resolveAgentRoute({
|
||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
||||
let route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
||||
id: peerId,
|
||||
},
|
||||
parentPeer,
|
||||
});
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
cfg,
|
||||
route,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
conversationId: peerId,
|
||||
parentConversationId: isGroup ? String(chatId) : undefined,
|
||||
});
|
||||
const configuredBinding = configuredRoute.configuredBinding;
|
||||
route = configuredRoute.route;
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
fn: () =>
|
||||
bot.api.sendMessage(
|
||||
chatId,
|
||||
"Configured ACP binding is unavailable right now. Please try again.",
|
||||
buildTelegramThreadParams(threadSpec) ?? {},
|
||||
),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
@@ -504,15 +554,19 @@ export const registerTelegramNativeCommands = ({
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
commandAuthorized: initialCommandAuthorized,
|
||||
} = auth;
|
||||
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
|
||||
resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
});
|
||||
let commandAuthorized = initialCommandAuthorized;
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
});
|
||||
if (!runtimeContext) {
|
||||
return;
|
||||
}
|
||||
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
|
||||
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||
chatId,
|
||||
accountId: route.accountId,
|
||||
@@ -729,13 +783,16 @@ export const registerTelegramNativeCommands = ({
|
||||
return;
|
||||
}
|
||||
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
|
||||
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
|
||||
resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
});
|
||||
const runtimeContext = await resolveCommandRuntimeContext({
|
||||
msg,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
});
|
||||
if (!runtimeContext) {
|
||||
return;
|
||||
}
|
||||
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
|
||||
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||
chatId,
|
||||
accountId: route.accountId,
|
||||
|
||||
Reference in New Issue
Block a user