diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f8d65f39e..1bd2a0fc8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
- "src/**"
- "crates/**"
- "operator/**"
+ - "scripts/teams-ack-drop-proxy.py"
+ - "scripts/test-teams-ack-drop-proxy.py"
+ - ".github/workflows/ci.yml"
- "Cargo.toml"
- "Cargo.lock"
- "Dockerfile*"
@@ -13,31 +16,41 @@ on:
env:
CARGO_TERM_COLOR: always
+permissions:
+ contents: read
+
jobs:
changes:
runs-on: ubuntu-latest
outputs:
core: ${{ steps.filter.outputs.core }}
operator: ${{ steps.filter.outputs.operator }}
+ teams_ack_proxy: ${{ steps.filter.outputs.teams_ack_proxy }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
+ persist-credentials: false
- id: filter
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
CHANGED=$(git diff --name-only "$BASE" "$HEAD")
- echo "core=$(echo "$CHANGED" | grep -qE '^(src/|crates/|Cargo\.(toml|lock))' && echo true || echo false)" >> "$GITHUB_OUTPUT"
- echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
+ {
+ echo "core=$(echo "$CHANGED" | grep -qE '^(src/|crates/|Cargo\.(toml|lock)|\.github/workflows/ci\.yml$)' && echo true || echo false)"
+ echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)"
+ echo "teams_ack_proxy=$(echo "$CHANGED" | grep -qE '^(scripts/(teams-ack-drop-proxy|test-teams-ack-drop-proxy)\.py|\.github/workflows/ci\.yml)$' && echo true || echo false)"
+ } >> "$GITHUB_OUTPUT"
check:
needs: changes
if: needs.changes.outputs.core == 'true'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-13)
with:
components: clippy
@@ -94,6 +107,20 @@ jobs:
- name: cargo build (unified)
run: cargo build --features unified
+ teams-ack-proxy:
+ needs: changes
+ if: needs.changes.outputs.teams_ack_proxy == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
+ - name: Test bounded Teams ACK-drop proxy
+ run: |
+ set -euo pipefail
+ python3 -m py_compile scripts/teams-ack-drop-proxy.py scripts/test-teams-ack-drop-proxy.py
+ python3 -W error::ResourceWarning scripts/test-teams-ack-drop-proxy.py
+
operator:
needs: changes
if: needs.changes.outputs.operator == 'true'
@@ -102,7 +129,9 @@ jobs:
run:
working-directory: operator
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-13)
with:
components: clippy
diff --git a/.gitignore b/.gitignore
index fe7eedff9..18e804612 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ gateway/target/
config.toml
*.swp
.DS_Store
+__pycache__/
+*.py[cod]
.env
.kiro/
diff --git a/Cargo.lock b/Cargo.lock
index fc11ec7e5..183cfe2c3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2607,8 +2607,10 @@ dependencies = [
"chrono",
"futures-util",
"hmac 0.12.1",
+ "httpdate",
"image",
"jsonwebtoken",
+ "libc",
"parking_lot",
"prost",
"quick-xml",
@@ -2624,6 +2626,7 @@ dependencies = [
"tracing-subscriber",
"urlencoding",
"uuid",
+ "windows-sys 0.61.2",
"wiremock",
]
diff --git a/README.md b/README.md
index a9628a824..970f86411 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,17 @@ See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standa
+
+Microsoft Teams (Unified or Custom Gateway)
+
+Start with the [self-hosted setup guide](docs/msteams-selfhosted.md), or use the
+[enterprise Kubernetes guide](docs/msteams-enterprise.md). Before relying on a
+specific capability or scope, check the
+[live-validation tracker](docs/msteams-live-validation.md). Teams attachment
+behavior is documented in [Inbound Attachments](docs/inbound-attachments.md).
+
+
+
### 2. Install with Helm (Kiro CLI — default)
```bash
@@ -168,6 +179,7 @@ kubectl rollout restart deployment/openab-kiro
### 4. Use
In your Discord channel:
+
```
@YourBot explain this code
```
diff --git a/README.zh-TW.md b/README.zh-TW.md
index c429cdb42..12ce82c39 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -138,6 +138,16 @@ platforms 使用 `webhook/API`,Feishu/Lark 則使用 `WS/webhook`。
+
+Microsoft Teams(Unified 或 Custom Gateway)
+
+請先閱讀 [self-hosted 設定指南](docs/msteams-selfhosted.md);企業 Kubernetes
+部署則使用 [enterprise 指南](docs/msteams-enterprise.md)。依賴特定功能或
+scope 前,請檢查 [live-validation tracker](docs/msteams-live-validation.md)。
+Teams attachment 行為記錄於 [Inbound Attachments](docs/inbound-attachments.md)。
+
+
+
### 2. 使用 Helm 安裝(Kiro CLI — 預設)
```bash
@@ -168,6 +178,7 @@ kubectl rollout restart deployment/openab-kiro
### 4. 使用方式
在 Discord 頻道中輸入:
+
```
@YourBot explain this code
```
diff --git a/charts/openab/README.md b/charts/openab/README.md
index dfac33a38..80c7edfe1 100644
--- a/charts/openab/README.md
+++ b/charts/openab/README.md
@@ -36,7 +36,8 @@ Each agent lives under `agents.`.
| `nameOverride` | Override this agent's generated resource name. | `""` |
| `workingDir` | Working directory and HOME inside the container. | `"/home/agent"` |
| `env` | Inline environment variables passed to the agent process. | `{}` |
-| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps. | `[]` |
+| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps for the OpenAB process. | `[]` |
+| `secretEnv` | Individual Secret keys injected into the OpenAB process. Raw `configToml` must explicitly pass only required agent credentials through `[agent].env` or `inherit_env`; adapter and Gateway secrets must not reach the ACP child. | `[]` |
| `pool.maxSessions` | Maximum concurrent ACP sessions for the agent. | `10` |
| `pool.sessionTtlHours` | Idle session TTL in hours. | `24` |
| `reactions.enabled` | Enable status reactions. | `true` |
@@ -48,8 +49,13 @@ Each agent lives under `agents.`.
| `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` |
| `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` |
| `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` |
-| `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` |
-| `cronjobs` | Config-driven scheduled messages for an agent. | `[]` |
+| `gateway.teams.reactionsEnabled` | Opt in to Microsoft public-preview Bot Connector reactions. | `false` |
+| `gateway.teams.inboundAttachments` | Enable metadata-first Teams image/text ingress on both Core and Gateway. | `false` |
+| `gateway.teams.conversationRegistryPath` | Opt in to the Gateway-local persistent Teams conversation registry. Mount the path separately. | `""` |
+| `gateway.teams.conversationRegistryMaxEntries` | Persistent Teams registry entry cap. | `1000` |
+| `gateway.teams.conversationRegistryTtlSecs` | Active/disabled registry retention window. | `31536000` |
+| `configToml` | Raw authoritative `config.toml`, including baseline `[[cron.jobs]]`. Required unless `configUrl` is used. | `""` |
+| `configUrl` | External authoritative config URL; mutually exclusive with the rendered ConfigMap path. | `""` |
| `persistence.enabled` | Enable persistent storage for auth and settings. | `true` |
| `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` |
| `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` |
@@ -112,6 +118,51 @@ See [`docs/migrate-to-configtoml.md`](../../docs/migrate-to-configtoml.md) for a
[`docs/adr/configurl-over-helm-rendering.md`](../../docs/adr/configurl-over-helm-rendering.md) for when to prefer `configUrl` instead
(platform-agnostic — works identically on Kubernetes, ECS, Zeabur, and AgentCore).
+For Teams typed scope, put the policy in that raw TOML rather than under the Gateway transport values:
+
+```toml
+[teams]
+allowed_teams = []
+allowed_channels = [] # both empty = all Team channels; otherwise Team OR channel match
+allow_personal = true
+allow_group_chats = true
+```
+
+Presence of any of these four fields opts into typed L2 policy. In Standalone Gateway mode, the policy still belongs to the OpenAB Core `configToml`; `gateway.teams.*` configures transport credentials and reaction preview on the Gateway container.
+
+`gateway.teams.inboundAttachments=true` is the exception that must stay aligned across processes: the chart emits `TEAMS_INBOUND_ATTACHMENTS=true` into both Core and Gateway. It enables bounded metadata-first image/text materialization only after Core trust admission. When `gateway.deploy=false`, configure the same environment variable on the external Gateway yourself.
+
+`gateway.teams.conversationRegistryPath` is a separate Gateway-only opt-in. The chart does not silently provision or attach a Gateway PVC; use `gateway.extraVolumeMounts` and `gateway.extraVolumes` (prefer an externally managed PVC with `"helm.sh/resource-policy": keep`) so the configured file survives pod replacement. An empty path preserves the previous process-local behavior and emits no registry environment variables.
+
+Teams operator cron belongs only in the raw Core `configToml`; the chart does not create a parallel target selector:
+
+```toml
+[[cron.jobs]]
+schedule = "0 9 * * 1-5"
+platform = "teams"
+channel = ""
+teams_tenant_id = ""
+message = "summarize yesterday's merged work"
+timezone = "Asia/Taipei"
+```
+
+This requires an exact active record in the Gateway registry. `serviceUrl` remains Gateway-local, `thread_id` is invalid for Teams, and agent-writable usercron cannot select the record.
+
### Discord ID precision warning
Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision.
+
+## Maintaining This Reference
+
+- **Trigger:** any change to a commonly documented key, default, generated
+ resource, or Teams transport/registry value in `values.yaml` or `templates/`.
+- **Action:** update the table or example in this file, then run:
+
+ ```bash
+ helm template test charts/openab --set agents.kiro.enabled=false
+ helm template test charts/openab \
+ --set-file agents.kiro.configToml=config.toml.example
+ ```
+
+- **Why:** [`values.yaml`](values.yaml) and the templates are authoritative;
+ this README is a curated operator view and must not silently drift from them.
diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml
index 3ae0e079b..5473ad9c7 100644
--- a/charts/openab/templates/deployment.yaml
+++ b/charts/openab/templates/deployment.yaml
@@ -88,6 +88,10 @@ spec:
name: {{ include "openab.agentFullname" $d }}
key: gateway-ws-token
{{- end }}
+ {{- if and ($cfg.gateway).enabled (hasKey (($cfg.gateway).teams) "inboundAttachments") }}
+ - name: TEAMS_INBOUND_ATTACHMENTS
+ value: {{ ($cfg.gateway).teams.inboundAttachments | quote }}
+ {{- end }}
- name: HOME
value: {{ $cfg.workingDir | default "/home/agent" }}
{{- range $k, $v := $cfg.env }}
diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml
index 2a89dc79a..03cae558e 100644
--- a/charts/openab/templates/gateway.yaml
+++ b/charts/openab/templates/gateway.yaml
@@ -108,6 +108,22 @@ spec:
- name: TEAMS_WEBHOOK_PATH
value: {{ ($cfg.gateway).teams.webhookPath | quote }}
{{- end }}
+ {{- if hasKey (($cfg.gateway).teams) "reactionsEnabled" }}
+ - name: TEAMS_REACTIONS_ENABLED
+ value: {{ ($cfg.gateway).teams.reactionsEnabled | quote }}
+ {{- end }}
+ {{- if hasKey (($cfg.gateway).teams) "inboundAttachments" }}
+ - name: TEAMS_INBOUND_ATTACHMENTS
+ value: {{ ($cfg.gateway).teams.inboundAttachments | quote }}
+ {{- end }}
+ {{- if ($cfg.gateway).teams.conversationRegistryPath }}
+ - name: TEAMS_CONVERSATION_REGISTRY_PATH
+ value: {{ ($cfg.gateway).teams.conversationRegistryPath | quote }}
+ - name: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES
+ value: {{ ($cfg.gateway).teams.conversationRegistryMaxEntries | int64 | quote }}
+ - name: TEAMS_CONVERSATION_REGISTRY_TTL_SECS
+ value: {{ ($cfg.gateway).teams.conversationRegistryTtlSecs | int64 | quote }}
+ {{- end }}
{{- end }}
{{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }}
{{- if $hasFeishu }}
diff --git a/charts/openab/tests/teams_registry_test.yaml b/charts/openab/tests/teams_registry_test.yaml
new file mode 100644
index 000000000..fee435651
--- /dev/null
+++ b/charts/openab/tests/teams_registry_test.yaml
@@ -0,0 +1,57 @@
+suite: Teams persistent conversation registry rendering
+templates:
+ - templates/gateway.yaml
+
+set:
+ agents.kiro.gateway.enabled: true
+ agents.kiro.gateway.teams.appId: test-app
+ agents.kiro.gateway.teams.appSecret: test-secret
+
+tests:
+ - it: keeps registry disabled and adds no volume by default
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: TEAMS_CONVERSATION_REGISTRY_PATH
+ - notExists:
+ path: spec.template.spec.volumes
+
+ - it: renders integer defaults after an explicit path
+ set:
+ agents.kiro.gateway.teams.conversationRegistryPath: /var/lib/openab/teams/conversations.json
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES
+ value: "1000"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: TEAMS_CONVERSATION_REGISTRY_TTL_SECS
+ value: "31536000"
+
+ - it: renders bounded registry settings only after an explicit path
+ set:
+ agents.kiro.gateway.teams.conversationRegistryPath: /var/lib/openab/teams/conversations.json
+ agents.kiro.gateway.teams.conversationRegistryMaxEntries: 123
+ agents.kiro.gateway.teams.conversationRegistryTtlSecs: 456
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: TEAMS_CONVERSATION_REGISTRY_PATH
+ value: /var/lib/openab/teams/conversations.json
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES
+ value: "123"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: TEAMS_CONVERSATION_REGISTRY_TTL_SECS
+ value: "456"
+ - notExists:
+ path: spec.template.spec.volumes
diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml
index fd37b023c..d8e33e135 100644
--- a/charts/openab/values.yaml
+++ b/charts/openab/values.yaml
@@ -68,9 +68,10 @@ agents:
# nameOverride: ""
# env: {}
# envFrom: []
- # # secretEnv: inject API keys from a Kubernetes Secret without storing them in the ConfigMap.
- # # Each entry renders as valueFrom.secretKeyRef in the Deployment and auto-adds the key name
- # # to inherit_env in config.toml. ⚠️ Do NOT also list the same key in env — use one or the other.
+ # # secretEnv: inject values from a Kubernetes Secret into the OpenAB process
+ # # without storing them in the ConfigMap. Raw configToml is mounted verbatim;
+ # # pass only required agent credentials through [agent].env or inherit_env.
+ # # Adapter and Gateway secrets must stay out of the ACP child environment.
# # secretEnv:
# # - name: GEMINI_API_KEY
# # secretName: my-secrets
@@ -102,6 +103,12 @@ agents:
# # allowed_channels = ["C01234567"]
# # allow_user_messages = "mentions"
# #
+ # # [teams]
+ # # allowed_teams = [] # Team IDs; both lists empty = all Team channels
+ # # allowed_channels = [] # a Team OR channel match admits
+ # # allow_personal = true
+ # # allow_group_chats = true
+ # #
# # [agent]
# # command = "claude-agent-acp"
# # inherit_env = ["ANTHROPIC_API_KEY"]
@@ -389,7 +396,9 @@ agents:
env: {}
# Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN.
envFrom: []
- secretEnv: [] # list of {name, secretName, secretKey} — rendered as valueFrom.secretKeyRef; keys auto-added to inherit_env
+ # list of {name, secretName, secretKey}; injected into OpenAB only. Raw
+ # configToml decides explicitly which values, if any, reach the ACP child.
+ secretEnv: []
pool:
maxSessions: 10
sessionTtlHours: 24
@@ -416,7 +425,7 @@ agents:
gateway:
enabled: false # set to true + provide url to enable the [gateway] config block
deploy: true # set to false to skip Gateway Deployment/Service (config-only mode)
- url: "" # e.g. ws://openab-gateway:8080/ws
+ url: "" # WebSocket URL, e.g. the in-cluster openab-gateway Service
platform: "telegram" # default platform when gateway is enabled
token: "" # optional shared secret (injected via GATEWAY_WS_TOKEN env var)
botUsername: "" # optional, for @mention gating
@@ -461,6 +470,15 @@ agents:
openidMetadata: "" # Override for sovereign clouds → TEAMS_OPENID_METADATA
allowedTenants: [] # List of tenant IDs → TEAMS_ALLOWED_TENANTS
webhookPath: "" # Gateway default: /webhook/teams → TEAMS_WEBHOOK_PATH
+ reactionsEnabled: false # Public-preview Bot Connector reactions → TEAMS_REACTIONS_ENABLED
+ # Default-off metadata-first image/text ingress. Sets the same env on
+ # Core and Gateway; no Microsoft URL or token crosses into Core.
+ inboundAttachments: false # → TEAMS_INBOUND_ATTACHMENTS
+ # Optional trusted persistent conversation registry. The chart does not create a
+ # Gateway PVC; mount this path with gateway.extraVolumeMounts/extraVolumes.
+ conversationRegistryPath: "" # disabled → TEAMS_CONVERSATION_REGISTRY_PATH
+ conversationRegistryMaxEntries: 1000 # → TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES
+ conversationRegistryTtlSecs: 31536000 # → TEAMS_CONVERSATION_REGISTRY_TTL_SECS
# Feishu/Lark adapter config (gateway-side env vars)
# See docs/feishu.md for full setup guide
feishu:
diff --git a/config.toml.example b/config.toml.example
index 00add1a9b..0003b439a 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -61,6 +61,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# # send-once (streaming is forced off to avoid
# # posting duplicate, growing messages)
# streaming_placeholder = false # set false for draft-based platforms (e.g. Telegram Rich Messages)
+# gateway_ack_timeout_secs = 12 # only enforced for ACKs advertised by a negotiated gateway
# --- Telegram (first-class section; alternative to TELEGRAM_* env vars) ---
# Config-authoritative with ${} expansion; each field falls back to its
@@ -133,7 +134,23 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# app_secret = "${TEAMS_APP_SECRET}" # env fallback: TEAMS_APP_SECRET
# allowed_tenants = [""] # env fallback: TEAMS_ALLOWED_TENANTS (empty = all)
# webhook_path = "/webhook/teams" # env fallback: TEAMS_WEBHOOK_PATH
-# allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS
+# dedupe_ttl_secs = 600 # env fallback: TEAMS_DEDUPE_TTL_SECS
+# route_ttl_secs = 3600 # env fallback: TEAMS_ROUTE_TTL_SECS
+# max_route_entries = 10000 # independent route/dedupe/ownership caps; env: TEAMS_MAX_ROUTE_ENTRIES
+# reactions_enabled = false # public-preview reactions; env: TEAMS_REACTIONS_ENABLED
+# processing_indicator = "off" # off | message; env: TEAMS_PROCESSING_INDICATOR
+# streaming = false # progressive bot-owned edits; env: TEAMS_STREAMING
+# inbound_attachments = false # post-trust image/text materialization; env: TEAMS_INBOUND_ATTACHMENTS
+# conversation_registry_path = "teams/conversations.json" # opt-in; relative to $HOME/.openab/
+# conversation_registry_max_entries = 1000 # env: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES
+# conversation_registry_ttl_secs = 31536000 # one year; env: TEAMS_CONVERSATION_REGISTRY_TTL_SECS
+# allowed_teams = [] # Team IDs; env: TEAMS_ALLOWED_TEAMS (comma-separated)
+# allowed_channels = [] # channel IDs; env: TEAMS_ALLOWED_CHANNELS
+# # both empty = all Team channels; Team OR channel match
+# allow_personal = true # env: TEAMS_ALLOW_PERSONAL
+# allow_group_chats = true # env: TEAMS_ALLOW_GROUP_CHATS
+# # setting any field above opts into typed scope policy
+# allow_all_users = false # independent L3 gate; env: TEAMS_ALLOW_ALL_USERS
# allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…)
# # env fallback: TEAMS_ALLOWED_USERS (comma-separated)
@@ -340,10 +357,21 @@ error_hold_ms = 2500
# schedule = "0 9 * * 1-5" # weekdays at 9:00 AM
# channel = "123456789" # target channel/thread ID
# message = "summarize yesterday's merged PRs" # prompt for the agent
-# platform = "discord" # "discord" or "slack"
+# platform = "discord" # discord/slack/telegram/googlechat/lineworks/teams
# sender_name = "DailyOps" # attribution (default: "openab-cron")
# timezone = "America/New_York" # IANA timezone (default: "UTC")
-# thread_id = "" # optional: post to existing thread
+# thread_id = "" # optional existing thread; rejected for Teams
+
+# Teams operator baseline: requires an active Gateway conversation-registry record.
+# `channel` is the trusted Teams conversation ID; do not configure serviceUrl.
+# [[cron.jobs]]
+# schedule = "0 9 * * 1-5"
+# platform = "teams"
+# channel = ""
+# teams_tenant_id = ""
+# message = "summarize yesterday's merged work"
+# sender_name = "DailyOps"
+# timezone = "Asia/Taipei"
# [[cron.jobs]]
# schedule = "0 0 * * 0"
diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs
index 5f5d83747..e278c6110 100644
--- a/crates/openab-core/src/acp/connection.rs
+++ b/crates/openab-core/src/acp/connection.rs
@@ -602,20 +602,55 @@ impl AcpConnection {
Ok(session_id)
}
- /// Set a config option (e.g. model, mode) via ACP session/set_config_option.
- /// Returns the updated list of all config options.
+ /// Set a config option while retaining the legacy prompt fallback used by
+ /// operator-supplied default configuration. Broker-owned commands use the
+ /// strict variant below so they never consume an agent turn.
pub async fn set_config_option(
&mut self,
config_id: &str,
value: &str,
) -> Result> {
+ if let Ok(options) = self.set_config_option_strict(config_id, value).await {
+ return Ok(options);
+ }
+
let session_id = self
.acp_session_id
.as_ref()
.ok_or_else(|| anyhow!("no session"))?
.clone();
+ let command = format!("/{config_id} {value}");
+ info!("set_config_option unsupported; using legacy prompt fallback");
+ self.send_request(
+ "session/prompt",
+ Some(json!({
+ "sessionId": session_id,
+ "prompt": [{"type": "text", "text": command}],
+ })),
+ )
+ .await?;
+ for option in &mut self.config_options {
+ if option.id == config_id {
+ option.current_value = value.to_string();
+ }
+ }
+ Ok(self.config_options.clone())
+ }
- let resp = self
+ /// Set a config option only through the ACP configuration method. No
+ /// `session/prompt` fallback is allowed because command interception must
+ /// not turn a broker control into an agent turn.
+ pub async fn set_config_option_strict(
+ &mut self,
+ config_id: &str,
+ value: &str,
+ ) -> Result> {
+ let session_id = self
+ .acp_session_id
+ .as_ref()
+ .ok_or_else(|| anyhow!("no session"))?
+ .clone();
+ let response = self
.send_request(
"session/set_config_option",
Some(json!({
@@ -624,39 +659,11 @@ impl AcpConnection {
"value": value,
})),
)
- .await;
-
- match resp {
- Ok(r) => {
- if let Some(result) = r.result.as_ref() {
- self.config_options = parse_config_options(result);
- }
- info!(config_id, value, "config option set");
- }
- Err(_) => {
- // Fall back: send as a slash command (e.g. "/model claude-sonnet-4")
- let cmd = format!("/{config_id} {value}");
- info!(
- cmd,
- "set_config_option not supported, falling back to prompt"
- );
- let _resp = self
- .send_request(
- "session/prompt",
- Some(json!({
- "sessionId": session_id,
- "prompt": [{"type": "text", "text": cmd}],
- })),
- )
- .await?;
- for opt in &mut self.config_options {
- if opt.id == config_id {
- opt.current_value = value.to_string();
- }
- }
- }
+ .await?;
+ if let Some(result) = response.result.as_ref() {
+ self.config_options = parse_config_options(result);
}
-
+ info!("config option set");
Ok(self.config_options.clone())
}
@@ -956,7 +963,7 @@ mod tests {
let (result, inherited) = build_agent_env(&explicit, &inherit);
- assert_eq!(result.get(key).unwrap(), "from_config");
+ assert_eq!(result.get(key).map(String::as_str), Some("from_config"));
assert!(!inherited.contains(&key.to_string()));
std::env::remove_var(key);
}
@@ -970,7 +977,7 @@ mod tests {
let (result, inherited) = build_agent_env(&explicit, &inherit);
- assert_eq!(result.get(key).unwrap(), "process_value");
+ assert_eq!(result.get(key).map(String::as_str), Some("process_value"));
assert!(inherited.contains(&key.to_string()));
std::env::remove_var(key);
}
@@ -1022,8 +1029,8 @@ mod reader_loop_tests {
));
let stale = b"{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":{\"stopReason\":\"ok\"}}\n";
- agent_stdout_writer.write_all(stale).await.unwrap();
- agent_stdout_writer.flush().await.unwrap();
+ assert!(agent_stdout_writer.write_all(stale).await.is_ok());
+ assert!(agent_stdout_writer.flush().await.is_ok());
let forwarded = tokio::time::timeout(std::time::Duration::from_secs(2), sub_rx.recv())
.await
@@ -1033,7 +1040,7 @@ mod reader_loop_tests {
assert!(pending.lock().await.is_empty());
drop(agent_stdout_writer);
- handle.await.unwrap();
+ assert!(handle.await.is_ok());
}
/// Matched-id path: when a response's id is in `pending`, the loop must
@@ -1065,8 +1072,8 @@ mod reader_loop_tests {
));
let payload = b"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"stopReason\":\"end_turn\"}}\n";
- agent_stdout_writer.write_all(payload).await.unwrap();
- agent_stdout_writer.flush().await.unwrap();
+ assert!(agent_stdout_writer.write_all(payload).await.is_ok());
+ assert!(agent_stdout_writer.flush().await.is_ok());
let resolved = tokio::time::timeout(std::time::Duration::from_secs(2), resp_rx)
.await
@@ -1082,7 +1089,7 @@ mod reader_loop_tests {
assert!(pending.lock().await.is_empty());
drop(agent_stdout_writer);
- handle.await.unwrap();
+ assert!(handle.await.is_ok());
}
#[test]
diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs
index 86b2ee989..f162b6bdf 100644
--- a/crates/openab-core/src/acp/pool.rs
+++ b/crates/openab-core/src/acp/pool.rs
@@ -393,6 +393,24 @@ impl SessionPool {
false
}
+ /// Whether a live in-process ACP connection exists without resuming or
+ /// creating session state. Control commands use this to avoid turning a
+ /// read-only query into implicit session activation.
+ pub async fn has_live_session(&self, thread_id: &str) -> bool {
+ let connection = {
+ let state = self.state.read().await;
+ state.active.get(thread_id).cloned()
+ };
+ let Some(connection) = connection else {
+ return false;
+ };
+ let live = match connection.try_lock() {
+ Ok(connection) => connection.alive(),
+ Err(_) => true,
+ };
+ live
+ }
+
pub async fn get_or_create(
&self,
thread_id: &str,
@@ -597,7 +615,7 @@ impl SessionPool {
// Apply default config options (e.g. mode=bypass, model=swe-1-6)
for (config_id, value) in &self.default_config_options {
if let Err(e) = new_conn.set_config_option(config_id, value).await {
- warn!(config_id, value, error = %e, "failed to set default config option");
+ warn!(error = %e, "failed to set default config option");
}
}
@@ -769,6 +787,27 @@ impl SessionPool {
conn.set_config_option(config_id, value).await
}
+ /// Command-only config mutation. Unlike the compatibility method above,
+ /// this never falls back to `session/prompt`.
+ pub async fn set_config_option_strict(
+ &self,
+ thread_id: &str,
+ config_id: &str,
+ value: &str,
+ ) -> Result> {
+ let conn = {
+ let state = self.state.read().await;
+ state.active.get(thread_id).cloned().ok_or_else(|| {
+ anyhow!(
+ "no connection for thread {}",
+ crate::redact::redact_session_ids(thread_id)
+ )
+ })?
+ };
+ let mut conn = conn.lock().await;
+ conn.set_config_option_strict(config_id, value).await
+ }
+
/// Query account-level usage/billing from the backend agent for a session
/// (kiro-cli extension). Fails when there is no active session for the
/// thread or the backend does not support usage queries.
@@ -801,12 +840,17 @@ impl SessionPool {
"method": "session/cancel",
"params": {"sessionId": session_id}
}))?;
- tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "sending session/cancel");
+ tracing::info!("sending session/cancel");
use tokio::io::AsyncWriteExt;
- let mut w = stdin.lock().await;
- w.write_all(data.as_bytes()).await?;
- w.write_all(b"\n").await?;
- w.flush().await?;
+ tokio::time::timeout(std::time::Duration::from_secs(10), async {
+ let mut writer = stdin.lock().await;
+ writer.write_all(data.as_bytes()).await?;
+ writer.write_all(b"\n").await?;
+ writer.flush().await?;
+ Ok::<(), anyhow::Error>(())
+ })
+ .await
+ .map_err(|_| anyhow!("session/cancel write timed out"))??;
Ok(())
}
@@ -827,12 +871,16 @@ impl SessionPool {
"method": "session/cancel",
"params": {"sessionId": session_id}
}))?;
- tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "reset: sending session/cancel");
+ tracing::info!("reset: sending session/cancel");
use tokio::io::AsyncWriteExt;
- let mut w = stdin.lock().await;
- let _ = w.write_all(data.as_bytes()).await;
- let _ = w.write_all(b"\n").await;
- let _ = w.flush().await;
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(10), async {
+ let mut writer = stdin.lock().await;
+ writer.write_all(data.as_bytes()).await?;
+ writer.write_all(b"\n").await?;
+ writer.flush().await?;
+ Ok::<(), anyhow::Error>(())
+ })
+ .await;
}
let mut state = self.state.write().await;
@@ -849,7 +897,7 @@ impl SessionPool {
self.save_mapping(&state.persisted);
self.save_meta(&state.session_workdirs);
if had_active {
- info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session reset");
+ info!("session reset");
Ok(())
} else {
Err(anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id)))
@@ -1040,19 +1088,35 @@ mod tests {
#[cfg(feature = "acp-mcp")]
impl CountingRegistrar {
+ fn minted(&self) -> Vec {
+ self.minted
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone()
+ }
+
fn revoked(&self) -> Vec {
- self.revoked.lock().unwrap().clone()
+ self.revoked
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone()
}
}
#[cfg(feature = "acp-mcp")]
impl crate::acp_mcp::SessionTokenRegistrar for CountingRegistrar {
fn mint(&self, channel_id: &str) -> String {
- self.minted.lock().unwrap().push(channel_id.to_string());
+ self.minted
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .push(channel_id.to_string());
"token-xyz".to_string()
}
fn revoke(&self, token: &str) {
- self.revoked.lock().unwrap().push(token.to_string());
+ self.revoked
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .push(token.to_string());
}
}
@@ -1072,6 +1136,24 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn persisted_state_is_not_a_live_session_for_read_only_commands() {
+ let pool = super::SessionPool::new(
+ crate::config::AgentConfig::default(),
+ 1,
+ 900,
+ HashMap::new(),
+ );
+ pool.state
+ .write()
+ .await
+ .persisted
+ .insert("teams:persisted-only".into(), "session".into());
+
+ assert!(pool.has_active_session("teams:persisted-only").await);
+ assert!(!pool.has_live_session("teams:persisted-only").await);
+ }
+
/// F3: replacing a hung predecessor's token revokes the predecessor's EXACT token and leaves
/// the successor's standing. Without the revoke the predecessor token keeps resolving to the
/// channel and — since `AcpTunnelSource` authorizes by channel — could reach the successor's
@@ -1126,7 +1208,9 @@ mod tests {
#[cfg(feature = "acp-mcp")]
#[tokio::test]
async fn no_token_is_minted_when_the_facade_config_write_fails() {
- let dir = tempfile::tempdir().unwrap();
+ let Ok(dir) = tempfile::tempdir() else {
+ panic!("temporary directory must be available");
+ };
// Make `/.openab` a FILE, so `create_dir_all` inside the writer fails.
//
// This used to block on `.cursor`, which openab no longer creates: since D-15 it authors
@@ -1134,21 +1218,20 @@ mod tests {
// `.cursor` the write would SUCCEED, the test would fail, and — worse if it had been
// written the other way round — a test asserting "no mint on failure" would have been
// passing against a call that never failed.
- std::fs::write(dir.path().join(".openab"), b"not a directory").unwrap();
+ assert!(std::fs::write(dir.path().join(".openab"), b"not a directory").is_ok());
let counting = Arc::new(CountingRegistrar::default());
let registrar: Arc = counting.clone();
- let token = super::setup_facade_session(
- dir.path().to_str().unwrap(),
- "http://127.0.0.1:8848/mcp",
- "acp_x",
- ®istrar,
- )
- .await;
+ let Some(workdir) = dir.path().to_str() else {
+ panic!("temporary path must be UTF-8");
+ };
+ let token =
+ super::setup_facade_session(workdir, "http://127.0.0.1:8848/mcp", "acp_x", ®istrar)
+ .await;
assert!(token.is_none(), "a failed config write must yield no token");
assert!(
- counting.minted.lock().unwrap().is_empty(),
+ counting.minted().is_empty(),
"the registrar must never be asked to mint when the config could not be written"
);
}
@@ -1157,19 +1240,20 @@ mod tests {
#[cfg(feature = "acp-mcp")]
#[tokio::test]
async fn a_successful_facade_config_write_mints_one_token() {
- let dir = tempfile::tempdir().unwrap();
+ let Ok(dir) = tempfile::tempdir() else {
+ panic!("temporary directory must be available");
+ };
let counting = Arc::new(CountingRegistrar::default());
let registrar: Arc = counting.clone();
- let token = super::setup_facade_session(
- dir.path().to_str().unwrap(),
- "http://127.0.0.1:8848/mcp",
- "acp_x",
- ®istrar,
- )
- .await;
+ let Some(workdir) = dir.path().to_str() else {
+ panic!("temporary path must be UTF-8");
+ };
+ let token =
+ super::setup_facade_session(workdir, "http://127.0.0.1:8848/mcp", "acp_x", ®istrar)
+ .await;
assert_eq!(token.as_deref(), Some("token-xyz"));
- assert_eq!(counting.minted.lock().unwrap().as_slice(), ["acp_x"]);
+ assert_eq!(counting.minted(), ["acp_x"]);
}
#[test]
@@ -1294,7 +1378,10 @@ mod tests {
struct Cap(StdArc>>);
impl Write for Cap {
fn write(&mut self, b: &[u8]) -> std::io::Result {
- self.0.lock().unwrap().extend_from_slice(b);
+ self.0
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
@@ -1318,7 +1405,13 @@ mod tests {
);
});
- let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
+ let bytes = buf
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone();
+ let Ok(out) = String::from_utf8(bytes) else {
+ panic!("captured tracing output must be UTF-8");
+ };
assert!(out.contains("force-evicting hung session"), "the warning must fire: {out}");
assert!(!out.contains(uuid), "no raw uuid may reach the log: {out}");
assert!(!out.contains("acp_") && !out.contains("sess_"), "no raw id prefix either: {out}");
diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs
index fa7e95dba..4cf86a937 100644
--- a/crates/openab-core/src/adapter.rs
+++ b/crates/openab-core/src/adapter.rs
@@ -1,6 +1,6 @@
use anyhow::Result;
use async_trait::async_trait;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, warn};
@@ -9,7 +9,13 @@ use crate::config::{ReactionsConfig, ToolDisplay};
use crate::error_display::{format_coded_error, format_user_error};
use crate::format;
use crate::markdown::{self, TableMode};
+use crate::progressive::{
+ classify_placeholder, deliver_required_ack_chunks, finalize_edit_after_cosmetic,
+ finalize_explicit_reply, is_ambiguous_delivery, AmbiguousProgressiveDelivery,
+ CosmeticEditOutcome, CosmeticEditState, PlaceholderStart, COSMETIC_EDIT_INTERVAL,
+};
use crate::reactions::StatusReactionController;
+use crate::status::{StatusMessageController, StatusTerminal};
// --- Output directive parsing ---
@@ -216,6 +222,14 @@ pub(crate) fn finalize_body(
/// Compare with `SenderContext`, which is **metadata for the agent**: there
/// `channel_id` is the parent channel and `thread_id` is the thread,
/// matching Slack's model for cross-platform consistency.
+#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct PersistentConversationTarget {
+ pub tenant_id: String,
+ pub bot_framework_channel_id: String,
+ pub conversation_id: String,
+}
+
#[derive(Clone, Debug)]
pub struct ChannelRef {
pub platform: String,
@@ -225,6 +239,9 @@ pub struct ChannelRef {
pub thread_id: Option,
/// Parent channel if this is a thread-as-channel (Discord).
pub parent_id: Option,
+ /// Exact logical identity for a Gateway-local durable conversation route.
+ /// The stored reference and service URL never enter Core.
+ pub persistent_conversation: Option>,
/// Originating gateway event ID, propagated back in `GatewayReply.reply_to`
/// so the gateway can correlate replies with inbound events (e.g. LINE reply tokens).
/// Excluded from Hash/Eq — two ChannelRefs pointing to the same channel are
@@ -238,6 +255,7 @@ impl PartialEq for ChannelRef {
&& self.channel_id == other.channel_id
&& self.thread_id == other.thread_id
&& self.parent_id == other.parent_id
+ && self.persistent_conversation == other.persistent_conversation
}
}
@@ -249,6 +267,7 @@ impl std::hash::Hash for ChannelRef {
self.channel_id.hash(state);
self.thread_id.hash(state);
self.parent_id.hash(state);
+ self.persistent_conversation.hash(state);
}
}
@@ -310,6 +329,198 @@ pub struct SenderContext {
pub receiver_id: Option,
}
+// --- Adapter capability and delivery contracts ---
+
+/// How an adapter can progressively deliver response content.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum StreamingMode {
+ /// Send one final message; no placeholder or edit loop.
+ #[default]
+ Disabled,
+ /// Send a placeholder and edit it with complete snapshots.
+ Edit,
+ /// Use a platform-native append/finalize streaming API.
+ Native,
+}
+
+/// Platform message-size budget. Authoritative final content is split in this
+/// exact unit; cosmetic previews may use a conservative character projection.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "unit", rename_all = "snake_case")]
+pub enum MessageLimit {
+ Characters { max: usize },
+ Bytes { max: usize },
+ Utf16Bytes { max: usize },
+ Unlimited,
+}
+
+impl Default for MessageLimit {
+ fn default() -> Self {
+ Self::Characters { max: 4096 }
+ }
+}
+
+impl MessageLimit {
+ pub fn conservative_char_limit(self) -> usize {
+ match self {
+ Self::Characters { max } => max.max(1),
+ Self::Bytes { max } => (max / 4).max(1),
+ Self::Utf16Bytes { max } => (max / 4).max(1),
+ Self::Unlimited => usize::MAX,
+ }
+ }
+
+ pub(crate) fn text_budget(self) -> format::TextBudget {
+ match self {
+ Self::Characters { max } => format::TextBudget::Characters(max),
+ Self::Bytes { max } => format::TextBudget::Bytes(max),
+ Self::Utf16Bytes { max } => format::TextBudget::Utf16Bytes(max),
+ Self::Unlimited => format::TextBudget::Unlimited,
+ }
+ }
+}
+
+/// User-visible status mechanism, kept independent from content streaming.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum StatusBackend {
+ #[default]
+ None,
+ Reactions,
+ Assistant,
+ Typing,
+ Message,
+}
+
+/// Platform-aware behavior contract used by direct, unified, and standalone
+/// gateway adapters. Defaults are deliberately conservative for unknown peers.
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct AdapterCapabilities {
+ pub send_ack: bool,
+ pub edit_ack: bool,
+ pub delete_ack: bool,
+ /// Whether command targets use the additive `target_message_id` field.
+ /// False peers require the legacy `reply_to = target` fallback.
+ pub supports_target_message_id: bool,
+ /// Native reaction writes are available independently from the selected
+ /// transient progress backend. Used for permanent batch receipts.
+ pub supports_reactions: bool,
+ /// Gateway can resolve one opaque inbound attachment reference after Core
+ /// trust admission and return bounded normalized bytes.
+ pub supports_attachment_materialization: bool,
+ /// Gateway can durably register an authenticated route after Core trust.
+ pub supports_conversation_registry: bool,
+ /// Gateway can resolve an exact durable conversation target for proactive writes.
+ pub supports_persistent_conversation_send: bool,
+ pub can_edit: bool,
+ pub can_delete: bool,
+ pub streaming_mode: StreamingMode,
+ pub show_streaming_placeholder: bool,
+ pub message_limit: MessageLimit,
+ pub status_backend: StatusBackend,
+}
+
+impl Default for AdapterCapabilities {
+ fn default() -> Self {
+ Self {
+ send_ack: false,
+ edit_ack: false,
+ delete_ack: false,
+ supports_target_message_id: false,
+ supports_reactions: false,
+ supports_attachment_materialization: false,
+ supports_conversation_registry: false,
+ supports_persistent_conversation_send: false,
+ can_edit: false,
+ can_delete: false,
+ streaming_mode: StreamingMode::Disabled,
+ show_streaming_placeholder: true,
+ message_limit: MessageLimit::default(),
+ status_backend: StatusBackend::None,
+ }
+ }
+}
+
+/// Result of a platform write. `Unknown` is distinct from rejection because a
+/// timed-out POST may have reached the platform and must not be blindly retried.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum WriteOutcome {
+ Delivered {
+ message_id: Option,
+ },
+ Rejected {
+ code: String,
+ message: String,
+ retry_after_ms: Option,
+ },
+ Unknown {
+ code: String,
+ message: String,
+ },
+}
+
+/// Preserve a structured platform write outcome through legacy `Result` trait
+/// methods. Progressive finalization downcasts this error instead of treating
+/// every failure as safe for delete-and-fresh-send recovery.
+#[derive(Clone, Debug)]
+pub struct WriteFailure {
+ pub outcome: WriteOutcome,
+}
+
+impl WriteFailure {
+ pub fn new(outcome: WriteOutcome) -> Self {
+ Self { outcome }
+ }
+}
+
+impl std::fmt::Display for WriteFailure {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.outcome {
+ WriteOutcome::Delivered { .. } => write!(f, "unexpected delivered write failure"),
+ WriteOutcome::Rejected { code, message, .. } => {
+ write!(f, "write rejected ({code}): {message}")
+ }
+ WriteOutcome::Unknown { code, message } => {
+ write!(f, "write outcome unknown ({code}): {message}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for WriteFailure {}
+
+fn failed_write_outcome(operation: &str, error: &anyhow::Error) -> WriteOutcome {
+ error
+ .downcast_ref::()
+ .map(|failure| failure.outcome.clone())
+ .unwrap_or_else(|| WriteOutcome::Unknown {
+ code: format!("{operation}_adapter_error"),
+ message: error.to_string(),
+ })
+}
+
+/// Stable wire discriminator carried by additive GatewayResponse fields.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum WriteOutcomeKind {
+ Delivered,
+ Rejected,
+ Unknown,
+}
+
+/// Bounded attachment result returned by an adapter after trust admission.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MaterializedAttachment {
+ pub attachment_type: String,
+ pub filename: String,
+ pub mime_type: String,
+ pub data: Vec,
+ pub size: u64,
+ pub status: Option,
+}
+
// --- ChatAdapter trait ---
#[async_trait]
@@ -322,9 +533,69 @@ pub trait ChatAdapter: Send + Sync + 'static {
/// for Discord; Slack uses its Block Kit `markdown` block cap).
fn message_limit(&self) -> usize;
+ /// Platform-aware capability view. Shared adapters override this method to
+ /// select behavior using `ChannelRef.platform`; direct adapters inherit a
+ /// backward-compatible view derived from their existing trait methods.
+ fn capabilities(&self, platform: &str) -> AdapterCapabilities {
+ let streaming_mode = if self.uses_native_streaming(false) {
+ StreamingMode::Native
+ } else if self.use_streaming(false) {
+ StreamingMode::Edit
+ } else {
+ StreamingMode::Disabled
+ };
+ let message_limit = if platform == "acp" {
+ MessageLimit::Unlimited
+ } else {
+ MessageLimit::Characters {
+ max: self.message_limit(),
+ }
+ };
+ let status_backend = if self.uses_assistant_status() {
+ StatusBackend::Assistant
+ } else {
+ StatusBackend::Reactions
+ };
+ AdapterCapabilities {
+ can_edit: streaming_mode != StreamingMode::Disabled,
+ can_delete: streaming_mode != StreamingMode::Disabled,
+ streaming_mode,
+ show_streaming_placeholder: self.show_streaming_placeholder(),
+ message_limit,
+ supports_reactions: status_backend == StatusBackend::Reactions,
+ status_backend,
+ ..AdapterCapabilities::default()
+ }
+ }
+
+ /// Resolve one Gateway-local opaque attachment reference after the caller
+ /// has completed structural, scope, and identity admission.
+ async fn materialize_attachment(
+ &self,
+ _channel: &ChannelRef,
+ _reference: &str,
+ ) -> Result {
+ Err(anyhow::anyhow!("attachment materialization not supported"))
+ }
+
+ /// Register a trusted Gateway-local conversation route for later use.
+ async fn register_conversation(&self, _channel: &ChannelRef) -> Result<()> {
+ Err(anyhow::anyhow!("conversation registry not supported"))
+ }
+
/// Send a new message, returns a reference to the sent message.
async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result;
+ /// Outcome-preserving send used by duplicate-safe progressive delivery.
+ async fn send_message_outcome(&self, channel: &ChannelRef, content: &str) -> WriteOutcome {
+ match self.send_message(channel, content).await {
+ Ok(message) => WriteOutcome::Delivered {
+ message_id: Some(message.message_id),
+ },
+ Err(error) => failed_write_outcome("send", &error),
+ }
+ }
+
/// Create a thread from a trigger message, returns the thread channel ref.
async fn create_thread(
&self,
@@ -345,6 +616,14 @@ pub trait ChatAdapter: Send + Sync + 'static {
Err(anyhow::anyhow!("edit_message not supported"))
}
+ /// Outcome-preserving edit used by authoritative finalization.
+ async fn edit_message_outcome(&self, msg: &MessageRef, content: &str) -> WriteOutcome {
+ match self.edit_message(msg, content).await {
+ Ok(()) => WriteOutcome::Delivered { message_id: None },
+ Err(error) => failed_write_outcome("edit", &error),
+ }
+ }
+
/// Send a message as a reply to a specific message (Discord: message_reference).
/// Default: falls back to plain send_message (ignores reply_to).
async fn send_message_with_reply(
@@ -357,6 +636,24 @@ pub trait ChatAdapter: Send + Sync + 'static {
self.send_message(channel, content).await
}
+ /// Outcome-preserving explicit reply send.
+ async fn send_message_with_reply_outcome(
+ &self,
+ channel: &ChannelRef,
+ content: &str,
+ reply_to_message_id: &str,
+ ) -> WriteOutcome {
+ match self
+ .send_message_with_reply(channel, content, reply_to_message_id)
+ .await
+ {
+ Ok(message) => WriteOutcome::Delivered {
+ message_id: Some(message.message_id),
+ },
+ Err(error) => failed_write_outcome("reply_send", &error),
+ }
+ }
+
/// Rename the thread/channel title. Default: no-op (not all platforms support it).
async fn rename_thread(&self, _channel: &ChannelRef, _title: &str) -> Result<()> {
Ok(())
@@ -368,6 +665,14 @@ pub trait ChatAdapter: Send + Sync + 'static {
self.edit_message(msg, "\u{200b}").await
}
+ /// Outcome-preserving delete used by progressive recovery.
+ async fn delete_message_outcome(&self, msg: &MessageRef) -> WriteOutcome {
+ match self.delete_message(msg).await {
+ Ok(()) => WriteOutcome::Delivered { message_id: None },
+ Err(error) => failed_write_outcome("delete", &error),
+ }
+ }
+
/// Whether this adapter streams via a native streaming API (Slack
/// chat.startStream) rather than the post+edit loop. Default: false.
/// `other_bot_present` lets adapters fall back to send-once in multi-bot
@@ -466,6 +771,24 @@ pub struct AdapterRouter {
trust: crate::trust::PlatformTrustConfigs,
}
+fn use_structured_progressive(
+ platform: &str,
+ streaming: bool,
+ native: bool,
+ capabilities: &AdapterCapabilities,
+) -> bool {
+ platform == "teams"
+ && streaming
+ && !native
+ && capabilities.send_ack
+ && capabilities.edit_ack
+ && capabilities.delete_ack
+ && capabilities.supports_target_message_id
+ && capabilities.can_edit
+ && capabilities.can_delete
+ && capabilities.show_streaming_placeholder
+}
+
impl AdapterRouter {
pub fn new(
pool: Arc,
@@ -519,6 +842,17 @@ impl AdapterRouter {
self.trust.decide(platform, channel_id, is_dm, sender_id)
}
+ /// Evaluate only L3 identity after an adapter-specific typed-scope policy
+ /// has already admitted L2. Teams needs this because Team-or-channel scope
+ /// matching cannot be represented by the legacy flat channel allowlist.
+ pub fn gate_identity(&self, platform: &str, sender_id: &str) -> crate::trust::Decision {
+ if self.trust.get(platform).identity_allowed(sender_id) {
+ crate::trust::Decision::Allow
+ } else {
+ crate::trust::Decision::DenyIdentity
+ }
+ }
+
/// Access the underlying session pool (e.g. for config option queries).
pub fn pool(&self) -> &Arc {
&self.pool
@@ -605,10 +939,13 @@ impl AdapterRouter {
return Err(e);
}
- // In assistant-status mode (e.g. Slack assistant_mode), status is conveyed
- // via assistant.threads.setStatus, so the emoji-reaction lifecycle is skipped
- // entirely — mirrors dispatch_batch so per-message and batched modes agree.
- let assistant_status = adapter.uses_assistant_status();
+ // Status and content streaming are separate capabilities. Only the
+ // reactions backend drives the emoji lifecycle here; assistant status is
+ // handled inside stream_prompt_blocks and `none` remains side-effect free.
+ let capabilities = adapter.capabilities(&ctx.thread_channel.platform);
+ let reaction_status = capabilities.status_backend == StatusBackend::Reactions;
+ let receipt_reactions =
+ self.reactions_config.enabled && capabilities.supports_reactions;
let reactions = Arc::new(StatusReactionController::new(
self.reactions_config.enabled,
@@ -617,7 +954,7 @@ impl AdapterRouter {
self.reactions_config.emojis.clone(),
self.reactions_config.timing.clone(),
));
- if !assistant_status {
+ if receipt_reactions {
reactions.set_queued().await;
}
@@ -632,7 +969,7 @@ impl AdapterRouter {
)
.await;
- if !assistant_status {
+ if reaction_status {
match &result {
Ok(()) => reactions.set_done().await,
Err(_) => reactions.set_error().await,
@@ -653,9 +990,11 @@ impl AdapterRouter {
}
if let Err(ref e) = result {
- let _ = adapter
- .send_message(&ctx.thread_channel, &format!("⚠️ {e}"))
- .await;
+ if !is_ambiguous_delivery(e) {
+ let _ = adapter
+ .send_message(&ctx.thread_channel, &format!("⚠️ {e}"))
+ .await;
+ }
}
result
@@ -700,25 +1039,35 @@ impl AdapterRouter {
) -> Result<()> {
let adapter = adapter.clone();
let thread_channel = thread_channel.clone();
- let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit());
- // ACP must not inherit the unified adapter's Telegram streaming flag (wrong
- // coupling): it streams append-only `agent_message_chunk` deltas built from the
- // post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it
- // explicitly by platform rather than by whatever Telegram happens to be set to.
- let streaming = if thread_channel.platform == "acp" {
- false
- } else {
- adapter.use_streaming(other_bot_present)
- };
+ let capabilities = adapter.capabilities(&thread_channel.platform);
+ let final_message_budget = capabilities.message_limit.text_budget();
+ let capability_limit = capabilities.message_limit.conservative_char_limit();
+ let message_limit = reply_message_limit(&thread_channel.platform, capability_limit);
+ // ACP stays append-only and cannot use the post+edit path. For all other
+ // platforms, the platform-aware capability is authoritative; multi-bot
+ // participation still disables streaming for the current turn.
+ let streaming = thread_channel.platform != "acp"
+ && capabilities.streaming_mode != StreamingMode::Disabled
+ && !other_bot_present;
// Keep the full turn text (incl. inter-tool narration) when streaming
// (it was already shown live) OR when `[reactions] narration_display` is
// set. Otherwise a send-once turn delivers only the final answer block.
// Platform-agnostic — read from the shared reactions config, alongside
// `tool_display`. `streaming` still drives the placeholder / native-stream
// paths below; only the final-text selection uses `keep_full_text`.
- let keep_full_text = streaming || self.reactions_config.narration_display;
- let native = adapter.uses_native_streaming(other_bot_present);
- let assistant_status = adapter.uses_assistant_status();
+ let narration_display = self.reactions_config.narration_display;
+ let keep_full_text = streaming || narration_display;
+ let native = streaming && capabilities.streaming_mode == StreamingMode::Native;
+ let structured_progressive =
+ use_structured_progressive(&thread_channel.platform, streaming, native, &capabilities);
+ let assistant_status = capabilities.status_backend == StatusBackend::Assistant;
+ let reaction_status = capabilities.status_backend == StatusBackend::Reactions;
+ let message_status_enabled = capabilities.status_backend == StatusBackend::Message;
+ let message_status = Arc::new(StatusMessageController::new(
+ message_status_enabled,
+ adapter.clone(),
+ thread_channel.clone(),
+ ));
// Platforms that render Markdown tables natively (e.g. Slack Block Kit
// `markdown` blocks / `markdown_text` stream chunks) skip the
// table→code/bullets pre-pass so the raw table renders natively.
@@ -743,9 +1092,11 @@ impl AdapterRouter {
conn.session_reset = false;
let (mut rx, request_id) = conn.session_prompt(content_blocks).await?;
- if assistant_status {
+ if message_status_enabled {
+ message_status.set_thinking().await;
+ } else if assistant_status {
let _ = adapter.set_status(&thread_channel, "Thinking…").await;
- } else {
+ } else if reaction_status {
reactions.set_thinking().await;
}
@@ -773,96 +1124,151 @@ impl AdapterRouter {
let mut native_last_flush = tokio::time::Instant::now();
const NATIVE_FLUSH_MS: u128 = 400;
- // Streaming edit: send placeholder, spawn edit loop
- let (buf_tx, placeholder_msg, edit_handle) = if streaming && !native {
- let initial = if reset {
- "⚠️ _Session expired, starting fresh..._\n\n…".to_string()
- } else {
- "…".to_string()
- };
- let msg = if adapter.show_streaming_placeholder() {
- adapter.send_message(&thread_channel, &initial).await?
- } else {
- // Dummy ref for edit loop — gateway uses drafts, doesn't need real msg_id
- MessageRef {
- message_id: "draft".to_string(),
- channel: thread_channel.clone(),
- }
- };
- let (tx, rx) = tokio::sync::watch::channel(initial);
- let edit_adapter = adapter.clone();
- let edit_msg = msg.clone();
- let limit = message_limit;
- let mut buf_rx = rx;
- let edit_handle = tokio::spawn(async move {
- let mut last = String::new();
- // Track consecutive edit failures so we can abort cosmetic
- // streaming when the platform stops accepting edits (e.g.
- // Feishu's 20-edits-per-message hard cap, errcode 230072).
- // Once aborted, the final delivery path still runs and the
- // user sees the complete content at turn end.
- let mut consecutive_failures: u32 = 0;
- const MAX_CONSECUTIVE_FAILURES: u32 = 3;
- loop {
- tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
- if buf_rx.has_changed().unwrap_or(false) {
- let content = buf_rx.borrow_and_update().clone();
- if content != last {
- let display = if content.chars().count() > limit - 100 {
- format!(
- "…{}",
- format::truncate_chars_tail(&content, limit - 100)
- )
- } else {
- content.clone()
- };
- match edit_adapter
- .edit_message(&edit_msg, &display)
- .await
- {
- Ok(_) => {
- consecutive_failures = 0;
- last = content;
- }
- Err(e) => {
- consecutive_failures += 1;
- tracing::debug!(
- message_id = %edit_msg.message_id,
- platform = %edit_msg.channel.platform,
- error = ?e,
- consecutive_failures,
- "mid-stream cosmetic edit failed"
- );
- if consecutive_failures
- >= MAX_CONSECUTIVE_FAILURES
- {
- tracing::warn!(
+ // Streaming edit: create one real placeholder when structured
+ // outcomes are available, then spawn the cosmetic edit loop.
+ let mut placeholder_create_unknown = false;
+ let mut placeholder_create_rejected = false;
+ let (buf_tx, placeholder_msg, edit_handle, cosmetic_edit_state) =
+ if streaming && !native {
+ let initial = if reset {
+ "⚠️ _Session expired, starting fresh..._\n\n…".to_string()
+ } else {
+ "…".to_string()
+ };
+ let msg = if capabilities.show_streaming_placeholder {
+ if structured_progressive {
+ match classify_placeholder(
+ &thread_channel,
+ adapter
+ .send_message_outcome(&thread_channel, &initial)
+ .await,
+ ) {
+ PlaceholderStart::Ready(message) => Some(message),
+ PlaceholderStart::Rejected => {
+ placeholder_create_rejected = true;
+ None
+ }
+ PlaceholderStart::Unknown => {
+ placeholder_create_unknown = true;
+ None
+ }
+ }
+ } else {
+ Some(adapter.send_message(&thread_channel, &initial).await?)
+ }
+ } else {
+ // Dummy ref for edit loop — gateway drafts do not need a real ID.
+ Some(MessageRef {
+ message_id: "draft".to_string(),
+ channel: thread_channel.clone(),
+ })
+ };
+
+ if let Some(msg) = msg {
+ let (tx, rx) = tokio::sync::watch::channel(initial);
+ let edit_adapter = adapter.clone();
+ let edit_msg = msg.clone();
+ let edit_state = Arc::new(std::sync::Mutex::new(
+ CosmeticEditState::default(),
+ ));
+ let task_edit_state = edit_state.clone();
+ let limit = message_limit;
+ let mut buf_rx = rx;
+ let edit_handle = tokio::spawn(async move {
+ // Only newer changed display content can supersede a failed
+ // PUT. Reserve it as Unknown before awaiting so cancellation
+ // cannot turn an in-flight write into a duplicate final PUT.
+ loop {
+ tokio::time::sleep(COSMETIC_EDIT_INTERVAL).await;
+ if buf_rx.has_changed().unwrap_or(false) {
+ let content = buf_rx.borrow_and_update().clone();
+ let display =
+ if content.chars().count() > limit - 100 {
+ format!(
+ "…{}",
+ format::truncate_chars_tail(
+ &content,
+ limit - 100,
+ )
+ )
+ } else {
+ content
+ };
+ let should_attempt = {
+ let mut state = task_edit_state
+ .lock()
+ .unwrap_or_else(|poisoned| {
+ poisoned.into_inner()
+ });
+ state.begin_attempt(display.clone())
+ };
+ if should_attempt {
+ let result = edit_adapter
+ .edit_message(&edit_msg, &display)
+ .await;
+ let outcome = match &result {
+ Ok(()) => CosmeticEditOutcome::Delivered,
+ Err(error) => match failed_write_outcome(
+ "edit",
+ error,
+ ) {
+ WriteOutcome::Rejected { .. } => {
+ CosmeticEditOutcome::Rejected
+ }
+ WriteOutcome::Delivered { .. }
+ | WriteOutcome::Unknown { .. } => {
+ CosmeticEditOutcome::Unknown
+ }
+ },
+ };
+ let (stop, consecutive_failures) = {
+ let mut state = task_edit_state
+ .lock()
+ .unwrap_or_else(|poisoned| {
+ poisoned.into_inner()
+ });
+ let stop = state.complete_attempt(outcome);
+ (stop, state.consecutive_failures())
+ };
+ if let Err(e) = result {
+ tracing::debug!(
message_id = %edit_msg.message_id,
platform = %edit_msg.channel.platform,
+ error = ?e,
consecutive_failures,
- "mid-stream cosmetic edit aborted; \
- final content will be delivered at turn end"
+ "mid-stream cosmetic edit failed"
);
- break;
+ if stop {
+ tracing::warn!(
+ message_id = %edit_msg.message_id,
+ platform = %edit_msg.channel.platform,
+ consecutive_failures,
+ "mid-stream cosmetic edit aborted; \
+ final content will be delivered at turn end"
+ );
+ break;
+ }
}
}
}
+ if buf_rx.has_changed().is_err() {
+ break;
+ }
}
- }
- if buf_rx.has_changed().is_err() {
- break;
- }
+ });
+ (Some(tx), Some(msg), Some(edit_handle), Some(edit_state))
+ } else {
+ (None, None, None, None)
}
- });
- (Some(tx), Some(msg), Some(edit_handle))
- } else {
- (None, None, None)
- };
+ } else {
+ (None, None, None, None)
+ };
// (#732) Liveness-aware recv loop. Filters stale id-bearing
// messages and abandons cleanly on dead agent / hard ceiling
// so late responses cannot leak into the next prompt.
let mut response_error: Option = None;
+ let mut hard_timed_out = false;
let mut turn_result = TurnResult::default();
let prompt_start = tokio::time::Instant::now();
loop {
@@ -902,6 +1308,7 @@ impl AdapterRouter {
break;
}
if prompt_start.elapsed() > prompt_hard_timeout {
+ hard_timed_out = true;
response_error = Some(format!(
"Agent exceeded hard timeout ({}s)",
prompt_hard_timeout.as_secs(),
@@ -969,24 +1376,29 @@ impl AdapterRouter {
}
}
AcpEvent::Thinking => {
- if assistant_status {
+ if message_status_enabled {
+ message_status.set_thinking().await;
+ } else if assistant_status {
let _ = adapter
.set_status(&thread_channel, "Thinking…")
.await;
- } else {
+ } else if reaction_status {
reactions.set_thinking().await;
}
}
AcpEvent::ToolStart { id, title } if !title.is_empty() => {
- // Live indicator: assistant status line vs emoji reaction.
- if assistant_status {
+ // Live indicator: processing message, assistant status line,
+ // or emoji reaction. These are independent from content streaming.
+ if message_status_enabled {
+ message_status.set_tool(&title).await;
+ } else if assistant_status {
let _ = adapter
.set_status(
&thread_channel,
&format!("Using {title}…"),
)
.await;
- } else {
+ } else if reaction_status {
reactions.set_tool(&title).await;
}
// Record the tool in BOTH modes so the finalized message keeps
@@ -1025,12 +1437,15 @@ impl AdapterRouter {
// tool; send-once delivery slices from here so the
// preceding inter-tool narration is dropped.
answer_start = text_buf.len();
- // Live indicator: assistant status line vs emoji reaction.
- if assistant_status {
+ // Live indicator: processing message, assistant status line,
+ // or emoji reaction.
+ if message_status_enabled {
+ message_status.set_thinking().await;
+ } else if assistant_status {
let _ = adapter
.set_status(&thread_channel, "Thinking…")
.await;
- } else {
+ } else if reaction_status {
reactions.set_thinking().await;
}
// Update the tool's state in BOTH modes (see ToolStart) so the
@@ -1089,12 +1504,20 @@ impl AdapterRouter {
// and if finalize's PUT travels a different pooled connection the
// server-side arrival order is not strictly guaranteed. That
// residual window is display-only (stale tail briefly shown) and
- // far narrower than before this join existed.
+ // far narrower than before this join existed. Structured Teams
+ // also reserves an in-flight display as Unknown before awaiting;
+ // finalization will not repeat that exact content blindly.
drop(buf_tx);
if let Some(handle) = edit_handle {
handle.abort();
let _ = handle.await;
}
+ let cosmetic_edit_snapshot = cosmetic_edit_state.as_ref().map(|state| {
+ state
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone()
+ });
// In send-once mode, deliver only the final answer block —
// the text after the last tool call — so inter-tool narration
@@ -1104,6 +1527,11 @@ impl AdapterRouter {
// FULL buffer (they sit at output start, which the slice may
// drop) so a leading [[reply_to:...]] survives the narration
// it was emitted alongside.
+ let keep_full_text = if placeholder_create_rejected {
+ narration_display
+ } else {
+ keep_full_text
+ };
let (directives, text_buf) =
split_delivery(&text_buf, answer_start, keep_full_text);
// The session-reset notice lives at the head of the buffer; a
@@ -1113,6 +1541,14 @@ impl AdapterRouter {
// encodes the four-corner truth table so it can be unit-tested.
let text_buf = finalize_body(reset, keep_full_text, answer_start, text_buf);
+ let status_terminal = if hard_timed_out {
+ StatusTerminal::TimedOut
+ } else if response_error.is_some() || turn_result.is_silent_failure() {
+ StatusTerminal::Failed
+ } else {
+ StatusTerminal::Completed
+ };
+
// Build final content
let final_content =
display_for(platform_is_acp, &tool_lines, &text_buf, false, tool_display);
@@ -1141,15 +1577,47 @@ impl AdapterRouter {
&final_content,
message_limit.saturating_sub(mention_reserve),
);
- propagate_mentions_to_chunks(chunks, &mentions, message_limit)
+ Ok(propagate_mentions_to_chunks(
+ chunks,
+ &mentions,
+ message_limit,
+ ))
} else {
- format::split_message(&final_content, message_limit)
+ format::split_message_with_budget(&final_content, final_message_budget)
+ .map_err(anyhow::Error::new)
+ };
+ let chunks = match chunks {
+ Ok(chunks) => chunks,
+ Err(error) => {
+ warn!(
+ platform = %thread_channel.platform,
+ error = %error,
+ "final content cannot fit the negotiated message budget"
+ );
+ if message_status_enabled {
+ message_status
+ .mark_terminal(StatusTerminal::DeliveryFailed)
+ .await;
+ }
+ if assistant_status {
+ let _ = adapter.set_status(&thread_channel, "").await;
+ }
+ return Err(error.context("reply formatting failed"));
+ }
};
// Track delivery health across all final write paths. Any failure
// here means the user's view is incomplete; we propagate Err at the
// end of the closure so dispatch surfaces set_error (❌) instead of
// silently calling set_done (🆗) over a half-delivered turn.
- let mut delivery_failed = false;
+ let mut delivery_failed = placeholder_create_unknown;
+ let mut delivery_ambiguous = placeholder_create_unknown;
+ let mut chunk_failure = None;
+ // Terminate status before delivering final content. A successful final
+ // delivery clears the processing message below; a failed delete can
+ // therefore leave only recognizable terminal text.
+ if message_status_enabled {
+ message_status.mark_terminal(status_terminal).await;
+ }
// Clear the assistant status line before delivering the final message.
if assistant_status {
let _ = adapter.set_status(&thread_channel, "").await;
@@ -1213,34 +1681,50 @@ impl AdapterRouter {
}
} else if let Some(msg) = placeholder_msg {
if let Some(ref reply_id) = directives.reply_to {
- // reply_to directive: send reply first, then delete placeholder.
- // Only delete if send succeeds — preserves placeholder on failure.
- let mut send_ok = false;
- let mut first = true;
- for chunk in &chunks {
- if first {
- match adapter.send_message_with_reply(
- &thread_channel,
- chunk,
- reply_id,
- ).await {
- Ok(_) => { send_ok = true; }
- Err(e) => {
- tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to send failed; preserving placeholder");
- delivery_failed = true;
+ if structured_progressive {
+ let health = finalize_explicit_reply(
+ &adapter,
+ &thread_channel,
+ &msg,
+ reply_id,
+ &chunks,
+ )
+ .await;
+ delivery_failed |= health.failed;
+ delivery_ambiguous |= health.ambiguous;
+ if health.chunk_failure.is_some() {
+ chunk_failure = health.chunk_failure;
+ }
+ } else {
+ // reply_to directive: send reply first, then delete placeholder.
+ // Only delete if send succeeds — preserves placeholder on failure.
+ let mut send_ok = false;
+ let mut first = true;
+ for chunk in &chunks {
+ if first {
+ match adapter.send_message_with_reply(
+ &thread_channel,
+ chunk,
+ reply_id,
+ ).await {
+ Ok(_) => { send_ok = true; }
+ Err(e) => {
+ tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to send failed; preserving placeholder");
+ delivery_failed = true;
+ }
}
+ } else if let Err(e) =
+ adapter.send_message(&thread_channel, chunk).await
+ {
+ tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to overflow chunk send failed");
+ delivery_failed = true;
}
- } else if let Err(e) =
- adapter.send_message(&thread_channel, chunk).await
- {
- tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to overflow chunk send failed");
- delivery_failed = true;
+ first = false;
}
- first = false;
- }
- if send_ok {
- if let Err(e) = adapter.delete_message(&msg).await {
- tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "delete placeholder failed; placeholder will remain visible");
+ if send_ok {
+ if let Err(e) = adapter.delete_message(&msg).await {
+ tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "delete placeholder failed; placeholder will remain visible");
+ }
}
}
} else if adapter.platform() == "discord"
@@ -1271,6 +1755,20 @@ impl AdapterRouter {
if send_ok {
let _ = adapter.delete_message(&msg).await;
}
+ } else if structured_progressive {
+ let health = finalize_edit_after_cosmetic(
+ &adapter,
+ &thread_channel,
+ &msg,
+ &chunks,
+ cosmetic_edit_snapshot.as_ref(),
+ )
+ .await;
+ delivery_failed |= health.failed;
+ delivery_ambiguous |= health.ambiguous;
+ if health.chunk_failure.is_some() {
+ chunk_failure = health.chunk_failure;
+ }
} else {
// Normal streaming: edit first chunk into placeholder, send rest.
// If placeholder is a dummy "draft" ref (no real message), send as
@@ -1316,9 +1814,41 @@ impl AdapterRouter {
}
}
}
+ } else if placeholder_create_unknown {
+ // The placeholder POST may have committed without returning its
+ // real activity ID. Do not create any additional Teams activity.
+ } else if structured_progressive && placeholder_create_rejected {
+ let health = deliver_required_ack_chunks(
+ &adapter,
+ &thread_channel,
+ directives.reply_to.as_deref(),
+ &chunks,
+ )
+ .await;
+ delivery_failed |= health.failed;
+ delivery_ambiguous |= health.ambiguous;
+ if health.chunk_failure.is_some() {
+ chunk_failure = health.chunk_failure;
+ }
+ } else if capabilities.send_ack {
+ // A negotiated required send ACK makes each chunk outcome
+ // authoritative. Deliver sequentially and stop at the first
+ // rejected or unknown POST so no suffix can skip a gap.
+ let health = deliver_required_ack_chunks(
+ &adapter,
+ &thread_channel,
+ directives.reply_to.as_deref(),
+ &chunks,
+ )
+ .await;
+ delivery_failed |= health.failed;
+ delivery_ambiguous |= health.ambiguous;
+ if health.chunk_failure.is_some() {
+ chunk_failure = health.chunk_failure;
+ }
} else {
- // Send-once: all chunks as new messages
- // First chunk uses reply_to directive if present
+ // Legacy peers preserve best-effort send-once behavior. New
+ // required-ACK peers use the ordered branch above.
let mut first = true;
for chunk in &chunks {
if first {
@@ -1347,11 +1877,49 @@ impl AdapterRouter {
}
}
+ if let Some(failure) = &chunk_failure {
+ warn!(
+ platform = %thread_channel.platform,
+ delivered_chunks = failure.delivered_chunks,
+ total_chunks = failure.total_chunks,
+ failed_chunk_index = failure.failed_chunk_index,
+ error_code = %failure.error_code,
+ ambiguous = delivery_ambiguous,
+ "final chunk delivery stopped before completion"
+ );
+ }
+
if delivery_failed {
- Err(anyhow::anyhow!(
- "streaming finalization had delivery failures; user view is incomplete"
- ))
+ if message_status_enabled {
+ message_status
+ .mark_terminal(StatusTerminal::DeliveryFailed)
+ .await;
+ }
+ if delivery_ambiguous {
+ Err(AmbiguousProgressiveDelivery.into())
+ } else if let Some(failure) = chunk_failure {
+ let classification = if failure.delivered_chunks > 0 {
+ "partial delivery"
+ } else {
+ "delivery failed"
+ };
+ Err(anyhow::anyhow!(
+ "{}: delivered {} of {} chunks; stopped at chunk {} ({})",
+ classification,
+ failure.delivered_chunks,
+ failure.total_chunks,
+ failure.failed_chunk_index,
+ failure.error_code,
+ ))
+ } else {
+ Err(anyhow::anyhow!(
+ "finalization had delivery failures; user view is incomplete"
+ ))
+ }
} else {
+ if message_status_enabled {
+ message_status.clear().await;
+ }
Ok(())
}
})
@@ -1746,6 +2314,133 @@ mod tests {
assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1);
}
+ #[test]
+ fn capability_message_limits_are_authoritative_and_conservative() {
+ assert_eq!(
+ MessageLimit::Characters { max: 8_000 }.conservative_char_limit(),
+ 8_000
+ );
+ assert_eq!(
+ MessageLimit::Bytes { max: 4_000 }.conservative_char_limit(),
+ 1_000
+ );
+ assert_eq!(
+ MessageLimit::Utf16Bytes { max: 4_000 }.conservative_char_limit(),
+ 1_000
+ );
+ assert_eq!(
+ MessageLimit::Unlimited.conservative_char_limit(),
+ usize::MAX
+ );
+ assert_eq!(
+ MessageLimit::Characters { max: 0 }.conservative_char_limit(),
+ 1
+ );
+ assert_eq!(
+ MessageLimit::Utf16Bytes { max: 80_000 }
+ .text_budget()
+ .measure("A🙂"),
+ 6
+ );
+ assert_eq!(
+ MessageLimit::Bytes { max: 80_000 }
+ .text_budget()
+ .measure("A🙂"),
+ 5
+ );
+ assert_eq!(
+ AdapterCapabilities::default().status_backend,
+ StatusBackend::None
+ );
+ }
+
+ #[test]
+ fn teams_table_fallback_precedes_utf16_budgeting() -> Result<()> {
+ let markdown = "Before\n\n| Name | Value |\n| --- | --- |\n| alpha | 🙂🙂🙂🙂🙂 |\n| beta | 你好世界 |\n\nAfter";
+ let rendered = crate::markdown::convert_tables(markdown, TableMode::Code);
+ assert!(rendered.contains("```\n"));
+ assert_eq!(
+ crate::markdown::convert_tables(markdown, TableMode::Off),
+ markdown
+ );
+
+ let budget = MessageLimit::Utf16Bytes { max: 96 }.text_budget();
+ let chunks = crate::format::split_message_with_budget(&rendered, budget)?;
+ assert!(chunks.len() > 1);
+ for chunk in chunks {
+ assert!(budget.measure(&chunk) <= 96);
+ let fences = chunk.lines().filter(|line| line.starts_with("```")).count();
+ assert!(
+ fences.is_multiple_of(2),
+ "unbalanced table fallback: {chunk:?}"
+ );
+ }
+ Ok(())
+ }
+
+ #[test]
+ fn structured_progressive_is_teams_only_and_requires_every_primitive() {
+ let complete = AdapterCapabilities {
+ send_ack: true,
+ edit_ack: true,
+ delete_ack: true,
+ supports_target_message_id: true,
+ can_edit: true,
+ can_delete: true,
+ show_streaming_placeholder: true,
+ ..AdapterCapabilities::default()
+ };
+ assert!(use_structured_progressive("teams", true, false, &complete));
+ assert!(!use_structured_progressive(
+ "feishu", true, false, &complete
+ ));
+ assert!(!use_structured_progressive(
+ "teams", false, false, &complete
+ ));
+ assert!(!use_structured_progressive("teams", true, true, &complete));
+
+ for missing in 0..7 {
+ let mut capabilities = complete.clone();
+ match missing {
+ 0 => capabilities.send_ack = false,
+ 1 => capabilities.edit_ack = false,
+ 2 => capabilities.delete_ack = false,
+ 3 => capabilities.supports_target_message_id = false,
+ 4 => capabilities.can_edit = false,
+ 5 => capabilities.can_delete = false,
+ _ => capabilities.show_streaming_placeholder = false,
+ }
+ assert!(!use_structured_progressive(
+ "teams",
+ true,
+ false,
+ &capabilities
+ ));
+ }
+ }
+
+ #[test]
+ fn typed_write_failures_survive_legacy_result_methods() {
+ let rejected = anyhow::Error::new(WriteFailure::new(WriteOutcome::Rejected {
+ code: "explicit_rejection".into(),
+ message: "not applied".into(),
+ retry_after_ms: Some(250),
+ }));
+ assert!(matches!(
+ failed_write_outcome("edit", &rejected),
+ WriteOutcome::Rejected {
+ retry_after_ms: Some(250),
+ ..
+ }
+ ));
+
+ let generic = anyhow::anyhow!("transport failed");
+ assert!(matches!(
+ failed_write_outcome("edit", &generic),
+ WriteOutcome::Unknown { code, .. } if code == "edit_adapter_error"
+ ));
+ }
+
#[test]
fn select_delivery_text_send_once_keeps_only_final_block() {
// Simulates: narration "n1" → tool (answer_start→2) → narration "n2"
@@ -1940,6 +2635,7 @@ mod tests {
channel_id: "U123".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: Some("evt_aaa".into()),
};
let b = ChannelRef {
@@ -1947,6 +2643,7 @@ mod tests {
channel_id: "U123".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: Some("evt_bbb".into()),
};
assert_eq!(a, b, "same channel with different event IDs must be equal");
@@ -1960,6 +2657,7 @@ mod tests {
channel_id: "U123".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: Some("evt_aaa".into()),
};
let b = ChannelRef {
@@ -1967,6 +2665,7 @@ mod tests {
channel_id: "U123".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: Some("evt_bbb".into()),
};
let mut map = HashMap::new();
@@ -1977,6 +2676,35 @@ mod tests {
assert_eq!(map.values().next(), Some(&"second"));
}
+ #[test]
+ fn persistent_conversation_participates_in_routing_equality_and_hash() {
+ use std::collections::HashSet;
+
+ let channel = |tenant: &str| ChannelRef {
+ platform: "teams".into(),
+ channel_id: "conversation-1".into(),
+ thread_id: None,
+ parent_id: None,
+ persistent_conversation: Some(Box::new(PersistentConversationTarget {
+ tenant_id: tenant.into(),
+ bot_framework_channel_id: "msteams".into(),
+ conversation_id: "conversation-1".into(),
+ })),
+ origin_event_id: None,
+ };
+ let first = channel("tenant-1");
+ let second = channel("tenant-2");
+ assert_ne!(first, second);
+ let mut routes = HashSet::new();
+ routes.insert(first.clone());
+ routes.insert(second);
+ assert_eq!(routes.len(), 2);
+ assert_eq!(
+ first.clone().persistent_conversation.unwrap().tenant_id,
+ "tenant-1"
+ );
+ }
+
#[test]
fn origin_event_id_survives_clone() {
let ch = ChannelRef {
@@ -1984,6 +2712,7 @@ mod tests {
channel_id: "U123".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: Some("evt_abc".into()),
};
// Simulates create_thread propagation: clone preserves origin_event_id
diff --git a/crates/openab-core/src/commands.rs b/crates/openab-core/src/commands.rs
new file mode 100644
index 000000000..96ffbf8a2
--- /dev/null
+++ b/crates/openab-core/src/commands.rs
@@ -0,0 +1,1116 @@
+//! Platform-neutral command parsing and execution.
+//!
+//! Ingress admission and presentation remain platform responsibilities. Callers
+//! must invoke this service only after their structural, scope, and identity
+//! gates have admitted the event.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+
+use crate::acp::protocol::{ConfigOption, UsageReport};
+use crate::acp::SessionPool;
+use crate::dispatch::Dispatcher;
+
+const COMMAND_EXECUTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
+const TEXT_OPTION_LIMIT: usize = 25;
+const TEXT_RESPONSE_CHAR_LIMIT: usize = 3_500;
+const TEXT_VALUE_CHAR_LIMIT: usize = 120;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ConfigCategory {
+ Model,
+ Agent,
+}
+
+impl ConfigCategory {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Model => "model",
+ Self::Agent => "agent",
+ }
+ }
+
+ fn matches(self, category: Option<&str>) -> bool {
+ match self {
+ Self::Model => category == Some("model"),
+ Self::Agent => matches!(category, Some("agent" | "mode")),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum CommandName {
+ Models,
+ Agents,
+ Cancel,
+ CancelAll,
+ Reset,
+ Usage,
+}
+
+impl CommandName {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Models => "models",
+ Self::Agents => "agents",
+ Self::Cancel => "cancel",
+ Self::CancelAll => "cancel-all",
+ Self::Reset => "reset",
+ Self::Usage => "usage",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Command {
+ ListConfig(ConfigCategory),
+ SetConfig {
+ category: ConfigCategory,
+ selector: String,
+ },
+ Cancel,
+ CancelAll,
+ Reset,
+ Usage,
+ InvalidArguments {
+ name: CommandName,
+ },
+}
+
+impl Command {
+ pub fn name(&self) -> CommandName {
+ match self {
+ Self::ListConfig(ConfigCategory::Model)
+ | Self::SetConfig {
+ category: ConfigCategory::Model,
+ ..
+ } => CommandName::Models,
+ Self::ListConfig(ConfigCategory::Agent)
+ | Self::SetConfig {
+ category: ConfigCategory::Agent,
+ ..
+ } => CommandName::Agents,
+ Self::Cancel => CommandName::Cancel,
+ Self::CancelAll => CommandName::CancelAll,
+ Self::Reset => CommandName::Reset,
+ Self::Usage => CommandName::Usage,
+ Self::InvalidArguments { name } => *name,
+ }
+ }
+}
+
+/// Parse only broker-owned commands. Prefix collisions and unknown slash text
+/// return `None` so agent-native commands continue through the ordinary prompt
+/// path.
+pub fn parse_command(input: &str) -> Option {
+ let trimmed = input.trim();
+ match trimmed {
+ "/models" => return Some(Command::ListConfig(ConfigCategory::Model)),
+ "/agents" => return Some(Command::ListConfig(ConfigCategory::Agent)),
+ "/cancel" => return Some(Command::Cancel),
+ "/cancel-all" => return Some(Command::CancelAll),
+ "/reset" => return Some(Command::Reset),
+ "/usage" => return Some(Command::Usage),
+ "/model" => return Some(Command::ListConfig(ConfigCategory::Model)),
+ "/agent" => return Some(Command::ListConfig(ConfigCategory::Agent)),
+ _ => {}
+ }
+
+ for (prefix, name) in [
+ ("/models", CommandName::Models),
+ ("/agents", CommandName::Agents),
+ ("/cancel-all", CommandName::CancelAll),
+ ("/cancel", CommandName::Cancel),
+ ("/reset", CommandName::Reset),
+ ("/usage", CommandName::Usage),
+ ] {
+ if has_whitespace_suffix(trimmed, prefix) {
+ return Some(Command::InvalidArguments { name });
+ }
+ }
+
+ parse_config_compatibility(trimmed, "/model", ConfigCategory::Model)
+ .or_else(|| parse_config_compatibility(trimmed, "/agent", ConfigCategory::Agent))
+}
+
+fn has_whitespace_suffix(input: &str, prefix: &str) -> bool {
+ input
+ .strip_prefix(prefix)
+ .is_some_and(|suffix| suffix.chars().next().is_some_and(char::is_whitespace))
+}
+
+fn parse_config_compatibility(
+ input: &str,
+ prefix: &str,
+ category: ConfigCategory,
+) -> Option {
+ let suffix = input.strip_prefix(prefix)?;
+ if suffix.is_empty() {
+ return Some(Command::ListConfig(category));
+ }
+ if !suffix.chars().next().is_some_and(char::is_whitespace) {
+ return None;
+ }
+
+ let mut parts = suffix.split_whitespace();
+ match parts.next() {
+ Some("list") if parts.next().is_none() => Some(Command::ListConfig(category)),
+ Some("set") => {
+ let selector = parts.collect::>().join(" ");
+ if selector.is_empty() {
+ Some(Command::InvalidArguments {
+ name: command_name(category),
+ })
+ } else {
+ Some(Command::SetConfig { category, selector })
+ }
+ }
+ _ => Some(Command::InvalidArguments {
+ name: command_name(category),
+ }),
+ }
+}
+
+fn command_name(category: ConfigCategory) -> CommandName {
+ match category {
+ ConfigCategory::Model => CommandName::Models,
+ ConfigCategory::Agent => CommandName::Agents,
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CommandContext {
+ pub platform: String,
+ pub logical_thread_id: String,
+ pub response_is_private: bool,
+}
+
+impl CommandContext {
+ pub fn new(
+ platform: impl Into,
+ logical_thread_id: impl Into,
+ response_is_private: bool,
+ ) -> Self {
+ Self {
+ platform: platform.into(),
+ logical_thread_id: logical_thread_id.into(),
+ response_is_private,
+ }
+ }
+
+ pub fn session_key(&self) -> String {
+ format!("{}:{}", self.platform, self.logical_thread_id)
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum CommandError {
+ InvalidArguments(CommandName),
+ NoConfigOptions(ConfigCategory),
+ InvalidConfigSelection(Option),
+ ConfigUpdateUnavailable,
+ OperationUnavailable,
+ NoActiveSession,
+ UsagePrivateOnly,
+ UsageUnsupported,
+ UsageUnavailable,
+}
+
+#[derive(Clone, Debug)]
+pub enum CommandResult {
+ ConfigOptions {
+ category: ConfigCategory,
+ options: Vec,
+ },
+ ConfigUpdated {
+ display_name: String,
+ },
+ Cancel {
+ signalled: bool,
+ },
+ CancelAll {
+ signalled: bool,
+ buffers_cleared: bool,
+ },
+ Reset {
+ session_reset: bool,
+ buffers_cleared: bool,
+ },
+ Usage(UsageReport),
+ Error(CommandError),
+}
+
+impl CommandResult {
+ pub fn outcome_class(&self) -> &'static str {
+ match self {
+ Self::ConfigOptions { .. }
+ | Self::ConfigUpdated { .. }
+ | Self::Cancel { signalled: true }
+ | Self::CancelAll {
+ signalled: true, ..
+ }
+ | Self::CancelAll {
+ buffers_cleared: true,
+ ..
+ }
+ | Self::Reset {
+ session_reset: true,
+ ..
+ }
+ | Self::Reset {
+ buffers_cleared: true,
+ ..
+ }
+ | Self::Usage(_) => "completed",
+ Self::Cancel { signalled: false }
+ | Self::CancelAll {
+ signalled: false,
+ buffers_cleared: false,
+ }
+ | Self::Reset {
+ session_reset: false,
+ buffers_cleared: false,
+ } => "no_active_session",
+ Self::Error(CommandError::UsagePrivateOnly) => "denied_private_surface",
+ Self::Error(CommandError::InvalidArguments(_))
+ | Self::Error(CommandError::InvalidConfigSelection(_)) => "invalid",
+ Self::Error(_) => "unavailable",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum UsageFailure {
+ Unsupported,
+ Unavailable,
+}
+
+#[async_trait]
+trait CommandBackend: Send + Sync {
+ async fn has_live_session(&self, session_key: &str) -> bool;
+ async fn get_config_options(&self, session_key: &str) -> Vec;
+ async fn set_config_option(
+ &self,
+ session_key: &str,
+ config_id: &str,
+ value: &str,
+ ) -> anyhow::Result<()>;
+ async fn get_usage(&self, session_key: &str) -> Result;
+ async fn cancel_session(&self, session_key: &str) -> bool;
+ async fn reset_session(&self, session_key: &str) -> bool;
+ fn clear_buffered_thread(&self, platform: &str, logical_thread_id: &str) -> bool;
+}
+
+struct CoreCommandBackend {
+ pool: Arc,
+ dispatcher: Arc,
+}
+
+#[async_trait]
+impl CommandBackend for CoreCommandBackend {
+ async fn has_live_session(&self, session_key: &str) -> bool {
+ self.pool.has_live_session(session_key).await
+ }
+
+ async fn get_config_options(&self, session_key: &str) -> Vec {
+ self.pool.get_config_options(session_key).await
+ }
+
+ async fn set_config_option(
+ &self,
+ session_key: &str,
+ config_id: &str,
+ value: &str,
+ ) -> anyhow::Result<()> {
+ self.pool
+ .set_config_option_strict(session_key, config_id, value)
+ .await
+ .map(|_| ())
+ }
+
+ async fn get_usage(&self, session_key: &str) -> Result {
+ self.pool.get_usage(session_key).await.map_err(|error| {
+ if error.to_string().contains("usage query is not supported") {
+ UsageFailure::Unsupported
+ } else {
+ UsageFailure::Unavailable
+ }
+ })
+ }
+
+ async fn cancel_session(&self, session_key: &str) -> bool {
+ self.pool.cancel_session(session_key).await.is_ok()
+ }
+
+ async fn reset_session(&self, session_key: &str) -> bool {
+ self.pool.reset_session(session_key).await.is_ok()
+ }
+
+ fn clear_buffered_thread(&self, platform: &str, logical_thread_id: &str) -> bool {
+ self.dispatcher
+ .cancel_buffered_thread(platform, logical_thread_id)
+ > 0
+ }
+}
+
+#[derive(Clone)]
+pub struct CommandService {
+ backend: Arc,
+}
+
+impl CommandService {
+ pub fn new(pool: Arc, dispatcher: Arc) -> Self {
+ Self {
+ backend: Arc::new(CoreCommandBackend { pool, dispatcher }),
+ }
+ }
+
+ pub async fn execute(&self, command: Command, context: &CommandContext) -> CommandResult {
+ tokio::time::timeout(
+ COMMAND_EXECUTION_TIMEOUT,
+ self.execute_inner(command, context),
+ )
+ .await
+ .unwrap_or(CommandResult::Error(CommandError::OperationUnavailable))
+ }
+
+ async fn execute_inner(&self, command: Command, context: &CommandContext) -> CommandResult {
+ match command {
+ Command::ListConfig(category) => self.list_config(context, category).await,
+ Command::SetConfig { category, selector } => {
+ self.set_config_by_selector(context, category, &selector)
+ .await
+ }
+ Command::Cancel => CommandResult::Cancel {
+ signalled: self.backend.cancel_session(&context.session_key()).await,
+ },
+ Command::CancelAll => {
+ let buffers_cleared = self
+ .backend
+ .clear_buffered_thread(&context.platform, &context.logical_thread_id);
+ let signalled = self.backend.cancel_session(&context.session_key()).await;
+ CommandResult::CancelAll {
+ signalled,
+ buffers_cleared,
+ }
+ }
+ Command::Reset => {
+ let buffers_cleared = self
+ .backend
+ .clear_buffered_thread(&context.platform, &context.logical_thread_id);
+ let session_reset = self.backend.reset_session(&context.session_key()).await;
+ CommandResult::Reset {
+ session_reset,
+ buffers_cleared,
+ }
+ }
+ Command::Usage => self.usage(context).await,
+ Command::InvalidArguments { name } => {
+ CommandResult::Error(CommandError::InvalidArguments(name))
+ }
+ }
+ }
+
+ pub async fn set_config_value(
+ &self,
+ context: &CommandContext,
+ config_id: &str,
+ value: &str,
+ ) -> CommandResult {
+ tokio::time::timeout(
+ COMMAND_EXECUTION_TIMEOUT,
+ self.set_config_value_inner(context, config_id, value),
+ )
+ .await
+ .unwrap_or(CommandResult::Error(CommandError::OperationUnavailable))
+ }
+
+ async fn set_config_value_inner(
+ &self,
+ context: &CommandContext,
+ config_id: &str,
+ value: &str,
+ ) -> CommandResult {
+ let options = self
+ .backend
+ .get_config_options(&context.session_key())
+ .await;
+ let Some(display_name) = options.iter().find_map(|option| {
+ if option.id != config_id
+ || (!ConfigCategory::Model.matches(option.category.as_deref())
+ && !ConfigCategory::Agent.matches(option.category.as_deref()))
+ {
+ return None;
+ }
+ option
+ .options
+ .iter()
+ .find(|choice| choice.value == value)
+ .map(|choice| choice.name.clone())
+ }) else {
+ return CommandResult::Error(CommandError::InvalidConfigSelection(None));
+ };
+
+ match self
+ .backend
+ .set_config_option(&context.session_key(), config_id, value)
+ .await
+ {
+ Ok(()) => CommandResult::ConfigUpdated { display_name },
+ Err(_) => CommandResult::Error(CommandError::ConfigUpdateUnavailable),
+ }
+ }
+
+ async fn list_config(
+ &self,
+ context: &CommandContext,
+ category: ConfigCategory,
+ ) -> CommandResult {
+ let options = matching_options(
+ self.backend
+ .get_config_options(&context.session_key())
+ .await,
+ category,
+ );
+ if options.is_empty() {
+ CommandResult::Error(CommandError::NoConfigOptions(category))
+ } else {
+ CommandResult::ConfigOptions { category, options }
+ }
+ }
+
+ async fn set_config_by_selector(
+ &self,
+ context: &CommandContext,
+ category: ConfigCategory,
+ selector: &str,
+ ) -> CommandResult {
+ let options = matching_options(
+ self.backend
+ .get_config_options(&context.session_key())
+ .await,
+ category,
+ );
+ if options.is_empty() {
+ return CommandResult::Error(CommandError::NoConfigOptions(category));
+ }
+
+ let choices = ordered_choices(&options);
+ let selected = selector
+ .parse::()
+ .ok()
+ .and_then(|index| index.checked_sub(1))
+ .and_then(|index| choices.get(index).copied())
+ .or_else(|| {
+ let folded = selector.to_lowercase();
+ choices.iter().copied().find(|(_, choice)| {
+ choice.value.to_lowercase() == folded || choice.name.to_lowercase() == folded
+ })
+ });
+ let Some((config_id, choice)) = selected else {
+ return CommandResult::Error(CommandError::InvalidConfigSelection(Some(category)));
+ };
+
+ match self
+ .backend
+ .set_config_option(&context.session_key(), config_id, &choice.value)
+ .await
+ {
+ Ok(()) => CommandResult::ConfigUpdated {
+ display_name: choice.name.clone(),
+ },
+ Err(_) => CommandResult::Error(CommandError::ConfigUpdateUnavailable),
+ }
+ }
+
+ async fn usage(&self, context: &CommandContext) -> CommandResult {
+ if !context.response_is_private {
+ return CommandResult::Error(CommandError::UsagePrivateOnly);
+ }
+ if !self.backend.has_live_session(&context.session_key()).await {
+ return CommandResult::Error(CommandError::NoActiveSession);
+ }
+ match self.backend.get_usage(&context.session_key()).await {
+ Ok(report) => CommandResult::Usage(report),
+ Err(UsageFailure::Unsupported) => CommandResult::Error(CommandError::UsageUnsupported),
+ Err(UsageFailure::Unavailable) => CommandResult::Error(CommandError::UsageUnavailable),
+ }
+ }
+}
+
+fn matching_options(options: Vec, category: ConfigCategory) -> Vec {
+ options
+ .into_iter()
+ .filter(|option| category.matches(option.category.as_deref()))
+ .map(|mut option| {
+ option.category = Some(category.as_str().to_string());
+ option
+ })
+ .collect()
+}
+
+fn ordered_choices(
+ options: &[ConfigOption],
+) -> Vec<(&str, &crate::acp::protocol::ConfigOptionValue)> {
+ let mut choices = Vec::new();
+ for option in options {
+ choices.extend(
+ option
+ .options
+ .iter()
+ .filter(|choice| choice.value == option.current_value)
+ .map(|choice| (option.id.as_str(), choice)),
+ );
+ choices.extend(
+ option
+ .options
+ .iter()
+ .filter(|choice| choice.value != option.current_value)
+ .map(|choice| (option.id.as_str(), choice)),
+ );
+ }
+ choices
+}
+
+pub fn render_text_result(result: &CommandResult) -> String {
+ let text = match result {
+ CommandResult::ConfigOptions { category, options } => {
+ let choices = ordered_choices(options);
+ let shown = choices.len().min(TEXT_OPTION_LIMIT);
+ let mut lines = vec![format!("🔧 Available {}s:", category.as_str())];
+ for (index, (_, choice)) in choices.iter().take(shown).enumerate() {
+ let is_current = options.iter().any(|option| {
+ option.current_value == choice.value
+ && option
+ .options
+ .iter()
+ .any(|candidate| std::ptr::eq(candidate, *choice))
+ });
+ lines.push(format!(
+ " {}. {}{}",
+ index + 1,
+ truncate_chars(&choice.name, TEXT_VALUE_CHAR_LIMIT),
+ if is_current { " ✅" } else { "" }
+ ));
+ }
+ if choices.len() > shown {
+ lines.push(format!(
+ "… {} more option(s) omitted.",
+ choices.len() - shown
+ ));
+ }
+ lines.push(format!(
+ "\nUsage: /{} set ",
+ category.as_str()
+ ));
+ lines.join("\n")
+ }
+ CommandResult::ConfigUpdated { display_name } => format!(
+ "✅ Switched to **{}**",
+ truncate_chars(display_name, TEXT_VALUE_CHAR_LIMIT)
+ ),
+ CommandResult::Cancel { signalled: true } => "🛑 Cancel signal sent.".to_string(),
+ CommandResult::Cancel { signalled: false } => {
+ "⚠️ Nothing to cancel — no active session.".to_string()
+ }
+ CommandResult::CancelAll {
+ signalled: true,
+ buffers_cleared: true,
+ } => "🛑 Cancel signal sent. Buffered messages cleared.".to_string(),
+ CommandResult::CancelAll {
+ signalled: true,
+ buffers_cleared: false,
+ } => "🛑 Cancel signal sent.".to_string(),
+ CommandResult::CancelAll {
+ signalled: false,
+ buffers_cleared: true,
+ } => "🛑 Buffered messages cleared. No active session to cancel.".to_string(),
+ CommandResult::CancelAll {
+ signalled: false,
+ buffers_cleared: false,
+ } => "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string(),
+ CommandResult::Reset {
+ session_reset: true,
+ buffers_cleared: true,
+ } => "🔄 Session reset. Buffered messages cleared. Start a new conversation!".to_string(),
+ CommandResult::Reset {
+ session_reset: true,
+ buffers_cleared: false,
+ } => "🔄 Session reset. Start a new conversation!".to_string(),
+ CommandResult::Reset {
+ session_reset: false,
+ buffers_cleared: true,
+ } => "🔄 Buffered messages cleared. No active session to reset.".to_string(),
+ CommandResult::Reset {
+ session_reset: false,
+ buffers_cleared: false,
+ } => "⚠️ No active session to reset.".to_string(),
+ CommandResult::Usage(report) => render_usage(report),
+ CommandResult::Error(error) => render_error(*error),
+ };
+ truncate_chars(&text, TEXT_RESPONSE_CHAR_LIMIT)
+}
+
+fn render_usage(report: &UsageReport) -> String {
+ let mut lines = vec![format!(
+ "📊 **Usage — {}**",
+ truncate_chars(&report.plan_name, TEXT_VALUE_CHAR_LIMIT)
+ )];
+ for breakdown in &report.breakdowns {
+ let name = truncate_chars(&breakdown.display_name, TEXT_VALUE_CHAR_LIMIT);
+ match breakdown.limit {
+ Some(limit) => {
+ let percentage = breakdown.percentage.unwrap_or_else(|| {
+ if limit > 0.0 {
+ (breakdown.used / limit * 100.0).round() as u64
+ } else {
+ 0
+ }
+ });
+ let filled = percentage.min(100) as usize / 10;
+ let bar = "█".repeat(filled) + &"░".repeat(10 - filled);
+ lines.push(format!(
+ "{name}: {:.2} / {:.0} `{bar}` {percentage}%{}",
+ breakdown.used,
+ limit,
+ if percentage > 100 { " ⚠️" } else { "" }
+ ));
+ }
+ None => lines.push(format!("{name}: {:.2} used", breakdown.used)),
+ }
+ if let Some(charges) = breakdown.overage_charges.filter(|charges| *charges > 0.0) {
+ lines.push(format!(
+ "Overage charges: {:.2} {}",
+ charges,
+ truncate_chars(
+ breakdown.currency.as_deref().unwrap_or("USD"),
+ TEXT_VALUE_CHAR_LIMIT,
+ )
+ ));
+ }
+ }
+ if let Some(reset) = &report.billing_cycle_reset {
+ lines.push(format!(
+ "Billing cycle resets {}",
+ truncate_chars(reset, TEXT_VALUE_CHAR_LIMIT)
+ ));
+ }
+ lines.join("\n")
+}
+
+fn render_error(error: CommandError) -> String {
+ match error {
+ CommandError::InvalidArguments(name) => format!(
+ "⚠️ Invalid arguments. Usage: {}",
+ match name {
+ CommandName::Models => "/models or /model list | /model set ",
+ CommandName::Agents => "/agents or /agent list | /agent set ",
+ CommandName::Cancel => "/cancel",
+ CommandName::CancelAll => "/cancel-all",
+ CommandName::Reset => "/reset",
+ CommandName::Usage => "/usage",
+ }
+ ),
+ CommandError::NoConfigOptions(category) => format!(
+ "⚠️ No {} options available. Start a conversation first.",
+ category.as_str()
+ ),
+ CommandError::InvalidConfigSelection(Some(category)) => format!(
+ "⚠️ No matching {}. Use /{} list to see options.",
+ category.as_str(),
+ category.as_str()
+ ),
+ CommandError::InvalidConfigSelection(None) => {
+ "⚠️ That configuration selection is no longer available.".to_string()
+ }
+ CommandError::ConfigUpdateUnavailable => {
+ "❌ The configuration change could not be completed.".to_string()
+ }
+ CommandError::OperationUnavailable => "⚠️ The command could not be completed.".to_string(),
+ CommandError::NoActiveSession => {
+ "⚠️ No active session. Start a conversation first.".to_string()
+ }
+ CommandError::UsagePrivateOnly => {
+ "🔒 `/usage` is only available in a private chat.".to_string()
+ }
+ CommandError::UsageUnsupported => {
+ "⚠️ Usage reporting is not supported by this backend.".to_string()
+ }
+ CommandError::UsageUnavailable => {
+ "⚠️ Usage information is temporarily unavailable.".to_string()
+ }
+ }
+}
+
+fn truncate_chars(input: &str, max: usize) -> String {
+ if input.chars().count() <= max {
+ input.to_string()
+ } else if max == 0 {
+ String::new()
+ } else {
+ let mut output: String = input.chars().take(max - 1).collect();
+ output.push('…');
+ output
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Mutex;
+
+ use super::*;
+ use crate::acp::protocol::{ConfigOptionValue, UsageBreakdown};
+
+ #[derive(Default)]
+ struct FakeState {
+ active: bool,
+ options: Vec,
+ usage: Option>,
+ usage_delay: Option,
+ cancel_succeeds: bool,
+ reset_succeeds: bool,
+ buffers_cleared: bool,
+ set_calls: Vec<(String, String, String)>,
+ usage_calls: usize,
+ cancel_calls: usize,
+ reset_calls: usize,
+ clear_calls: Vec<(String, String)>,
+ }
+
+ #[derive(Default)]
+ struct FakeBackend {
+ state: Mutex,
+ }
+
+ impl FakeBackend {
+ fn state(&self) -> std::sync::MutexGuard<'_, FakeState> {
+ self.state
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+ }
+
+ #[async_trait]
+ impl CommandBackend for FakeBackend {
+ async fn has_live_session(&self, _session_key: &str) -> bool {
+ self.state().active
+ }
+
+ async fn get_config_options(&self, _session_key: &str) -> Vec {
+ self.state().options.clone()
+ }
+
+ async fn set_config_option(
+ &self,
+ session_key: &str,
+ config_id: &str,
+ value: &str,
+ ) -> anyhow::Result<()> {
+ self.state().set_calls.push((
+ session_key.to_string(),
+ config_id.to_string(),
+ value.to_string(),
+ ));
+ Ok(())
+ }
+
+ async fn get_usage(&self, _session_key: &str) -> Result {
+ let (delay, result) = {
+ let mut state = self.state();
+ state.usage_calls += 1;
+ (
+ state.usage_delay,
+ state
+ .usage
+ .clone()
+ .unwrap_or(Err(UsageFailure::Unavailable)),
+ )
+ };
+ if let Some(delay) = delay {
+ tokio::time::sleep(delay).await;
+ }
+ result
+ }
+
+ async fn cancel_session(&self, _session_key: &str) -> bool {
+ let mut state = self.state();
+ state.cancel_calls += 1;
+ state.cancel_succeeds
+ }
+
+ async fn reset_session(&self, _session_key: &str) -> bool {
+ let mut state = self.state();
+ state.reset_calls += 1;
+ state.reset_succeeds
+ }
+
+ fn clear_buffered_thread(&self, platform: &str, logical_thread_id: &str) -> bool {
+ let mut state = self.state();
+ state
+ .clear_calls
+ .push((platform.to_string(), logical_thread_id.to_string()));
+ state.buffers_cleared
+ }
+ }
+
+ fn service(backend: Arc) -> CommandService {
+ CommandService { backend }
+ }
+
+ fn context(private: bool) -> CommandContext {
+ CommandContext::new("teams", "conversation", private)
+ }
+
+ fn option(category: &str, count: usize, current: usize) -> ConfigOption {
+ ConfigOption {
+ id: category.to_string(),
+ name: category.to_string(),
+ description: None,
+ category: Some(category.to_string()),
+ option_type: "enum".to_string(),
+ current_value: format!("value-{current}"),
+ options: (0..count)
+ .map(|index| ConfigOptionValue {
+ value: format!("value-{index}"),
+ name: format!("Choice {index}"),
+ description: None,
+ })
+ .collect(),
+ }
+ }
+
+ fn usage_report(limit: Option, percentage: Option) -> UsageReport {
+ UsageReport {
+ plan_name: "Plan".to_string(),
+ billing_cycle_reset: Some("2026-09-01".to_string()),
+ breakdowns: vec![UsageBreakdown {
+ display_name: "Credits".to_string(),
+ used: 12.5,
+ limit,
+ percentage,
+ overage_charges: Some(1.25),
+ currency: Some("USD".to_string()),
+ }],
+ }
+ }
+
+ fn configure_usage(backend: &FakeBackend, usage: Result) {
+ let mut state = backend.state();
+ state.active = true;
+ state.usage = Some(usage);
+ }
+
+ #[test]
+ fn parser_requires_exact_boundaries_and_preserves_unknown_slash_text() {
+ assert_eq!(
+ parse_command(" /models \n"),
+ Some(Command::ListConfig(ConfigCategory::Model))
+ );
+ assert_eq!(parse_command("/cancel-all"), Some(Command::CancelAll));
+ assert_eq!(
+ parse_command("/reset now"),
+ Some(Command::InvalidArguments {
+ name: CommandName::Reset
+ })
+ );
+ assert_eq!(
+ parse_command("/model set Choice 1"),
+ Some(Command::SetConfig {
+ category: ConfigCategory::Model,
+ selector: "Choice 1".to_string()
+ })
+ );
+ assert_eq!(
+ parse_command("/agent list extra"),
+ Some(Command::InvalidArguments {
+ name: CommandName::Agents
+ })
+ );
+ assert_eq!(parse_command("/reset-now"), None);
+ assert_eq!(parse_command("/cancel-all-now"), None);
+ assert_eq!(parse_command("/usage-report"), None);
+ assert_eq!(parse_command("/compact"), None);
+ assert_eq!(parse_command("/Models"), None);
+ }
+
+ #[tokio::test]
+ async fn text_config_list_is_current_first_and_bounded_to_25() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.state().options = vec![option("model", 28, 27)];
+ let result = service(backend)
+ .execute(Command::ListConfig(ConfigCategory::Model), &context(true))
+ .await;
+ let text = render_text_result(&result);
+ let Some(first_choice) = text.lines().nth(1) else {
+ panic!("rendered config list has no first choice");
+ };
+ assert!(first_choice.contains("Choice 27 ✅"));
+ assert!(text.contains("… 3 more option(s) omitted."));
+ assert!(!text.contains("Choice 26"));
+ }
+
+ #[tokio::test]
+ async fn agent_category_accepts_mode_and_selection_uses_full_option_set() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.state().options = vec![option("mode", 30, 0)];
+ let result = service(backend.clone())
+ .execute(
+ Command::SetConfig {
+ category: ConfigCategory::Agent,
+ selector: "Choice 29".to_string(),
+ },
+ &context(true),
+ )
+ .await;
+ assert!(matches!(result, CommandResult::ConfigUpdated { .. }));
+ assert_eq!(backend.state().set_calls.len(), 1);
+ }
+
+ #[tokio::test]
+ async fn forged_config_payload_is_rejected_before_backend_mutation() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.state().options = vec![option("model", 2, 0)];
+ let result = service(backend.clone())
+ .set_config_value(&context(true), "forged", "value-1")
+ .await;
+ assert!(matches!(
+ result,
+ CommandResult::Error(CommandError::InvalidConfigSelection(_))
+ ));
+ assert!(backend.state().set_calls.is_empty());
+ }
+
+ #[tokio::test]
+ async fn cancel_preserves_buffers_while_cancel_all_and_reset_clear_only_context_thread() {
+ let backend = Arc::new(FakeBackend::default());
+ {
+ let mut state = backend.state();
+ state.cancel_succeeds = true;
+ state.reset_succeeds = true;
+ state.buffers_cleared = true;
+ }
+ let service = service(backend.clone());
+ service.execute(Command::Cancel, &context(true)).await;
+ assert!(backend.state().clear_calls.is_empty());
+
+ service.execute(Command::CancelAll, &context(true)).await;
+ service.execute(Command::Reset, &context(true)).await;
+ let state = backend.state();
+ assert_eq!(
+ state.clear_calls,
+ vec![("teams".to_string(), "conversation".to_string()); 2]
+ );
+ assert_eq!(state.cancel_calls, 2);
+ assert_eq!(state.reset_calls, 1);
+ }
+
+ #[tokio::test]
+ async fn public_usage_is_denied_before_session_or_backend_access() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.state().active = true;
+ let result = service(backend.clone())
+ .execute(Command::Usage, &context(false))
+ .await;
+ assert!(matches!(
+ result,
+ CommandResult::Error(CommandError::UsagePrivateOnly)
+ ));
+ assert_eq!(backend.state().usage_calls, 0);
+ }
+
+ #[tokio::test]
+ async fn usage_classifies_absent_unsupported_and_malformed_without_raw_errors() {
+ let backend = Arc::new(FakeBackend::default());
+ let service = service(backend.clone());
+ let absent = service.execute(Command::Usage, &context(true)).await;
+ assert!(matches!(
+ absent,
+ CommandResult::Error(CommandError::NoActiveSession)
+ ));
+
+ configure_usage(&backend, Err(UsageFailure::Unsupported));
+ let unsupported = service.execute(Command::Usage, &context(true)).await;
+ assert!(matches!(
+ unsupported,
+ CommandResult::Error(CommandError::UsageUnsupported)
+ ));
+
+ backend.state().usage = Some(Err(UsageFailure::Unavailable));
+ let malformed = service.execute(Command::Usage, &context(true)).await;
+ assert!(matches!(
+ malformed,
+ CommandResult::Error(CommandError::UsageUnavailable)
+ ));
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn command_execution_timeout_returns_bounded_error() {
+ let backend = Arc::new(FakeBackend::default());
+ configure_usage(&backend, Ok(usage_report(Some(10.0), Some(50))));
+ backend.state().usage_delay = Some(std::time::Duration::from_secs(60));
+ let result = service(backend)
+ .execute(Command::Usage, &context(true))
+ .await;
+ assert!(matches!(
+ result,
+ CommandResult::Error(CommandError::OperationUnavailable)
+ ));
+ assert_eq!(
+ render_text_result(&result),
+ "⚠️ The command could not be completed."
+ );
+ }
+
+ #[tokio::test]
+ async fn usage_renderer_handles_over_limit_and_no_cap_reports() {
+ let backend = Arc::new(FakeBackend::default());
+ configure_usage(&backend, Ok(usage_report(Some(10.0), Some(125))));
+ let service = service(backend.clone());
+ let over = service.execute(Command::Usage, &context(true)).await;
+ let over_text = render_text_result(&over);
+ assert!(over_text.contains("125% ⚠️"));
+ assert!(over_text.contains("Overage charges: 1.25 USD"));
+
+ backend.state().usage = Some(Ok(usage_report(None, None)));
+ let no_cap = service.execute(Command::Usage, &context(true)).await;
+ assert!(render_text_result(&no_cap).contains("Credits: 12.50 used"));
+ }
+
+ #[test]
+ fn renderer_bounds_untrusted_backend_strings() {
+ let text = render_text_result(&CommandResult::Usage(UsageReport {
+ plan_name: "x".repeat(10_000),
+ billing_cycle_reset: None,
+ breakdowns: vec![],
+ }));
+ assert!(text.chars().count() <= TEXT_RESPONSE_CHAR_LIMIT);
+ assert!(!text.contains(&"x".repeat(TEXT_VALUE_CHAR_LIMIT + 1)));
+ }
+
+ #[test]
+ fn session_key_is_namespaced_by_platform() {
+ assert_eq!(context(true).session_key(), "teams:conversation");
+ assert_ne!(
+ context(true).session_key(),
+ CommandContext::new("discord", "conversation", true).session_key()
+ );
+ }
+
+ #[test]
+ fn error_rendering_never_contains_backend_details() {
+ let expected = [
+ (
+ CommandError::ConfigUpdateUnavailable,
+ "❌ The configuration change could not be completed.",
+ ),
+ (
+ CommandError::UsageUnavailable,
+ "⚠️ Usage information is temporarily unavailable.",
+ ),
+ ];
+ for (error, message) in expected {
+ assert_eq!(render_text_result(&CommandResult::Error(error)), message);
+ }
+ }
+}
diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs
index a9bc26abd..98f1f2631 100644
--- a/crates/openab-core/src/config.rs
+++ b/crates/openab-core/src/config.rs
@@ -687,6 +687,10 @@ pub struct GatewayConfig {
/// Show "…" placeholder at streaming start. Default: true. Set false for platforms using drafts.
#[serde(default = "default_true")]
pub streaming_placeholder: bool,
+ /// Maximum time to wait for a write acknowledgement advertised by a new
+ /// gateway peer. Legacy peers remain fire-and-forget. Default: 12 seconds.
+ #[serde(default = "default_gateway_ack_timeout_secs")]
+ pub gateway_ack_timeout_secs: u64,
/// Whether the connected gateway renders tables natively (e.g. Telegram Rich Messages).
/// Default: true (matches Telegram default). Set false if Rich Messages is disabled
/// on the gateway daemon to preserve table code-block wrapping.
@@ -707,6 +711,10 @@ fn default_gateway_platform() -> String {
"telegram".into()
}
+fn default_gateway_ack_timeout_secs() -> u64 {
+ 12
+}
+
/// First-class `[telegram]` configuration section (see ADR: first-class
/// per-platform config). Config-authoritative with `${ENV}` expansion; every
/// field falls back to its `TELEGRAM_*` environment variable when unset, then to
@@ -1291,8 +1299,19 @@ impl GoogleChatConfig {
}
}
-/// First-class `[teams]` section — credentials, connection, and L3 identity
-/// trust for the MS Teams adapter. Config-first invariant (#1375): each field
+/// Opt-in Teams processing indicator. Message mode uses one turn-local bot
+/// activity and the existing send/edit/delete acknowledgement contract.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TeamsProcessingIndicator {
+ #[default]
+ Off,
+ Message,
+}
+
+/// First-class `[teams]` section — credentials, connection, typed L2 scope,
+/// and L3 identity trust for the MS Teams adapter. Config-first invariant
+/// (#1375): each field
/// resolves `[teams].field` (with `${}` expansion) → `TEAMS_*` env var →
/// default. Graduates from the shared [`PlatformTrustConfig`] (#1380).
#[derive(Debug, Clone, Default, Deserialize)]
@@ -1314,6 +1333,47 @@ pub struct TeamsConfig {
/// Webhook mount path. Env fallback: `TEAMS_WEBHOOK_PATH`
/// (default `/webhook/teams`).
pub webhook_path: Option,
+ /// Process-local duplicate suppression window. Env fallback:
+ /// `TEAMS_DEDUPE_TTL_SECS` (default 600 seconds).
+ pub dedupe_ttl_secs: Option,
+ /// Ephemeral authenticated route lifetime. Env fallback:
+ /// `TEAMS_ROUTE_TTL_SECS` (default 3600 seconds).
+ pub route_ttl_secs: Option,
+ /// Shared capacity bound for route, dedupe, and ownership caches. Env fallback:
+ /// `TEAMS_MAX_ROUTE_ENTRIES` (default 10000).
+ pub max_route_entries: Option,
+ /// Opt in to the public-preview Bot Connector reaction API and advertise
+ /// the reaction status backend. Env fallback: `TEAMS_REACTIONS_ENABLED`.
+ /// Defaults to `false` so existing deployments remain side-effect free.
+ pub reactions_enabled: Option,
+ /// Opt in to one turn-local processing message. Env fallback:
+ /// `TEAMS_PROCESSING_INDICATOR`; default `off`.
+ pub processing_indicator: Option,
+ /// Opt in to progressive content through one real bot-owned placeholder.
+ /// Env fallback: `TEAMS_STREAMING`; default `false`.
+ pub streaming: Option,
+ /// Permit post-admission materialization of bounded inbound image/text
+ /// attachments. Env fallback: `TEAMS_INBOUND_ATTACHMENTS`; default `false`.
+ pub inbound_attachments: Option,
+ /// Opt-in persistent conversation registry file. Relative paths resolve
+ /// beneath `$HOME/.openab/`. Env: `TEAMS_CONVERSATION_REGISTRY_PATH`.
+ pub conversation_registry_path: Option,
+ /// Persistent registry entry cap. Env fallback:
+ /// `TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES`; default 1000.
+ pub conversation_registry_max_entries: Option,
+ /// Active/disabled registry retention. Env fallback:
+ /// `TEAMS_CONVERSATION_REGISTRY_TTL_SECS`; default one year.
+ pub conversation_registry_ttl_secs: Option,
+ /// Team IDs admitted by typed channel scope. Env fallback:
+ /// `TEAMS_ALLOWED_TEAMS` (comma-separated). Both scope lists empty = open.
+ pub allowed_teams: Option>,
+ /// Teams channel IDs admitted by typed channel scope. Env fallback:
+ /// `TEAMS_ALLOWED_CHANNELS` (comma-separated). Team OR channel match wins.
+ pub allowed_channels: Option>,
+ /// Admit Personal chats. Env fallback: `TEAMS_ALLOW_PERSONAL`; default true.
+ pub allow_personal: Option,
+ /// Admit group chats. Env fallback: `TEAMS_ALLOW_GROUP_CHATS`; default true.
+ pub allow_group_chats: Option,
/// Explicit flag: true = allow all users, false = check `allowed_users`.
/// Defaults to `false` (deny-all). Env fallback: `TEAMS_ALLOW_ALL_USERS`.
pub allow_all_users: Option,
@@ -1331,6 +1391,21 @@ pub struct ResolvedTeams {
pub oauth_endpoint: String,
pub openid_metadata: String,
pub webhook_path: String,
+ pub dedupe_ttl_secs: u64,
+ pub route_ttl_secs: u64,
+ pub max_route_entries: usize,
+ pub reactions_enabled: bool,
+ pub processing_indicator: TeamsProcessingIndicator,
+ pub streaming: bool,
+ pub inbound_attachments: bool,
+ pub conversation_registry_path: Option,
+ pub conversation_registry_max_entries: usize,
+ pub conversation_registry_ttl_secs: u64,
+ pub allowed_teams: Vec,
+ pub allowed_channels: Vec,
+ pub allow_personal: bool,
+ pub allow_group_chats: bool,
+ pub scope_policy_configured: bool,
pub allow_all_users: bool,
pub allowed_users: Vec,
}
@@ -1355,6 +1430,68 @@ impl TeamsConfig {
.collect(),
}
};
+ let positive_u64 = |cfg: Option, env: &str, default: u64| {
+ cfg.filter(|value| *value > 0)
+ .or_else(|| {
+ std::env::var(env)
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .filter(|value| *value > 0)
+ })
+ .unwrap_or(default)
+ };
+ let positive_usize = |cfg: Option, env: &str, default: usize| {
+ cfg.filter(|value| *value > 0)
+ .or_else(|| {
+ std::env::var(env)
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .filter(|value| *value > 0)
+ })
+ .unwrap_or(default)
+ };
+ let bool_with_default = |cfg: Option, env: &str, default: bool| {
+ cfg.or_else(|| {
+ // An explicitly present but malformed switch resolves false.
+ // This is fail-closed for both admitted surfaces and opt-in UX.
+ std::env::var(env)
+ .ok()
+ .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
+ })
+ .unwrap_or(default)
+ };
+ let processing_indicator = self.processing_indicator.unwrap_or_else(|| {
+ match std::env::var("TEAMS_PROCESSING_INDICATOR") {
+ Ok(value) if value.trim().eq_ignore_ascii_case("message") => {
+ TeamsProcessingIndicator::Message
+ }
+ Ok(value)
+ if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("off") =>
+ {
+ TeamsProcessingIndicator::Off
+ }
+ Ok(_) => {
+ tracing::warn!(
+ key = "TEAMS_PROCESSING_INDICATOR",
+ "invalid Teams processing indicator; using off"
+ );
+ TeamsProcessingIndicator::Off
+ }
+ Err(_) => TeamsProcessingIndicator::Off,
+ }
+ });
+ let scope_policy_configured = self.allowed_teams.is_some()
+ || self.allowed_channels.is_some()
+ || self.allow_personal.is_some()
+ || self.allow_group_chats.is_some()
+ || [
+ "TEAMS_ALLOWED_TEAMS",
+ "TEAMS_ALLOWED_CHANNELS",
+ "TEAMS_ALLOW_PERSONAL",
+ "TEAMS_ALLOW_GROUP_CHATS",
+ ]
+ .into_iter()
+ .any(|key| std::env::var_os(key).is_some());
ResolvedTeams {
app_id: opt_str(&self.app_id, "TEAMS_APP_ID"),
app_secret: opt_str(&self.app_secret, "TEAMS_APP_SECRET"),
@@ -1368,6 +1505,48 @@ impl TeamsConfig {
}),
webhook_path: opt_str(&self.webhook_path, "TEAMS_WEBHOOK_PATH")
.unwrap_or_else(|| "/webhook/teams".into()),
+ dedupe_ttl_secs: positive_u64(self.dedupe_ttl_secs, "TEAMS_DEDUPE_TTL_SECS", 600),
+ route_ttl_secs: positive_u64(self.route_ttl_secs, "TEAMS_ROUTE_TTL_SECS", 3600),
+ max_route_entries: positive_usize(
+ self.max_route_entries,
+ "TEAMS_MAX_ROUTE_ENTRIES",
+ 10_000,
+ ),
+ reactions_enabled: self.reactions_enabled.unwrap_or_else(|| {
+ std::env::var("TEAMS_REACTIONS_ENABLED")
+ .ok()
+ .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
+ }),
+ processing_indicator,
+ streaming: bool_with_default(self.streaming, "TEAMS_STREAMING", false),
+ inbound_attachments: bool_with_default(
+ self.inbound_attachments,
+ "TEAMS_INBOUND_ATTACHMENTS",
+ false,
+ ),
+ conversation_registry_path: opt_str(
+ &self.conversation_registry_path,
+ "TEAMS_CONVERSATION_REGISTRY_PATH",
+ ),
+ conversation_registry_max_entries: positive_usize(
+ self.conversation_registry_max_entries,
+ "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES",
+ 1_000,
+ ),
+ conversation_registry_ttl_secs: positive_u64(
+ self.conversation_registry_ttl_secs,
+ "TEAMS_CONVERSATION_REGISTRY_TTL_SECS",
+ 365 * 24 * 60 * 60,
+ ),
+ allowed_teams: csv(&self.allowed_teams, "TEAMS_ALLOWED_TEAMS"),
+ allowed_channels: csv(&self.allowed_channels, "TEAMS_ALLOWED_CHANNELS"),
+ allow_personal: bool_with_default(self.allow_personal, "TEAMS_ALLOW_PERSONAL", true),
+ allow_group_chats: bool_with_default(
+ self.allow_group_chats,
+ "TEAMS_ALLOW_GROUP_CHATS",
+ true,
+ ),
+ scope_policy_configured,
allow_all_users: self.allow_all_users.unwrap_or_else(|| {
std::env::var("TEAMS_ALLOW_ALL_USERS")
.ok()
@@ -1756,6 +1935,8 @@ pub struct CronJobConfig {
pub schedule: String,
/// Target channel ID
pub channel: String,
+ /// Required tenant identity for operator-owned Microsoft Teams jobs.
+ pub teams_tenant_id: Option,
/// Message to send to the agent
pub message: String,
/// Target platform (default: "discord")
@@ -2312,6 +2493,29 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result {
);
anyhow::ensure!(s.max_batch_tokens > 0, "slack.max_batch_tokens must be > 0");
}
+ if let Some(ref teams) = config.teams {
+ if let Some(value) = teams.dedupe_ttl_secs {
+ anyhow::ensure!(value > 0, "teams.dedupe_ttl_secs must be > 0");
+ }
+ if let Some(value) = teams.route_ttl_secs {
+ anyhow::ensure!(value > 0, "teams.route_ttl_secs must be > 0");
+ }
+ if let Some(value) = teams.max_route_entries {
+ anyhow::ensure!(value > 0, "teams.max_route_entries must be > 0");
+ }
+ if let Some(value) = teams.conversation_registry_max_entries {
+ anyhow::ensure!(
+ (1..=10_000).contains(&value),
+ "teams.conversation_registry_max_entries must be between 1 and 10000"
+ );
+ }
+ if let Some(value) = teams.conversation_registry_ttl_secs {
+ anyhow::ensure!(
+ value > 0 && i64::try_from(value).is_ok(),
+ "teams.conversation_registry_ttl_secs is out of range"
+ );
+ }
+ }
if let Some(ref g) = config.gateway {
anyhow::ensure!(
g.max_buffered_messages > 0,
@@ -2321,6 +2525,20 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result {
g.max_batch_tokens > 0,
"gateway.max_batch_tokens must be > 0"
);
+ anyhow::ensure!(
+ g.gateway_ack_timeout_secs > 0,
+ "gateway.gateway_ack_timeout_secs must be > 0"
+ );
+ anyhow::ensure!(
+ g.gateway_ack_timeout_secs < config.pool.prompt_hard_timeout_secs,
+ "gateway.gateway_ack_timeout_secs must be less than pool.prompt_hard_timeout_secs"
+ );
+ if g.platform == "teams" {
+ anyhow::ensure!(
+ g.gateway_ack_timeout_secs > 10,
+ "gateway.gateway_ack_timeout_secs must exceed the 10-second Teams Connector request timeout"
+ );
+ }
}
anyhow::ensure!(
config.pool.liveness_check_secs > 0,
@@ -3022,7 +3240,24 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
/// separate process, safe).
#[test]
fn teams_resolve_all_scenarios() {
- for k in ["TEAMS_APP_ID", "TEAMS_OAUTH_ENDPOINT"] {
+ for k in [
+ "TEAMS_APP_ID",
+ "TEAMS_OAUTH_ENDPOINT",
+ "TEAMS_DEDUPE_TTL_SECS",
+ "TEAMS_ROUTE_TTL_SECS",
+ "TEAMS_MAX_ROUTE_ENTRIES",
+ "TEAMS_REACTIONS_ENABLED",
+ "TEAMS_PROCESSING_INDICATOR",
+ "TEAMS_STREAMING",
+ "TEAMS_INBOUND_ATTACHMENTS",
+ "TEAMS_CONVERSATION_REGISTRY_PATH",
+ "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES",
+ "TEAMS_CONVERSATION_REGISTRY_TTL_SECS",
+ "TEAMS_ALLOWED_TEAMS",
+ "TEAMS_ALLOWED_CHANNELS",
+ "TEAMS_ALLOW_PERSONAL",
+ "TEAMS_ALLOW_GROUP_CHATS",
+ ] {
std::env::remove_var(k);
}
// --- defaults ---
@@ -3032,22 +3267,98 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
assert!(r.oauth_endpoint.contains("botframework.com"));
assert!(r.openid_metadata.contains("openidconfiguration"));
assert!(r.allowed_tenants.is_empty());
+ assert_eq!(r.dedupe_ttl_secs, 600);
+ assert_eq!(r.route_ttl_secs, 3600);
+ assert_eq!(r.max_route_entries, 10_000);
+ assert!(!r.reactions_enabled);
+ assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off);
+ assert!(!r.streaming);
+ assert!(!r.inbound_attachments);
+ assert!(r.conversation_registry_path.is_none());
+ assert_eq!(r.conversation_registry_max_entries, 1_000);
+ assert_eq!(r.conversation_registry_ttl_secs, 365 * 24 * 60 * 60);
+ assert!(r.allowed_teams.is_empty());
+ assert!(r.allowed_channels.is_empty());
+ assert!(r.allow_personal);
+ assert!(r.allow_group_chats);
+ assert!(!r.scope_policy_configured);
+ assert!(
+ TeamsConfig {
+ allowed_teams: Some(vec![]),
+ ..Default::default()
+ }
+ .resolve()
+ .scope_policy_configured
+ );
// --- config wins over env ---
std::env::set_var("TEAMS_APP_ID", "env-app");
std::env::set_var("TEAMS_OAUTH_ENDPOINT", "https://env.example/token");
+ std::env::set_var("TEAMS_DEDUPE_TTL_SECS", "41");
+ std::env::set_var("TEAMS_ROUTE_TTL_SECS", "83");
+ std::env::set_var("TEAMS_MAX_ROUTE_ENTRIES", "122");
+ std::env::set_var("TEAMS_REACTIONS_ENABLED", "false");
+ std::env::set_var("TEAMS_PROCESSING_INDICATOR", "off");
+ std::env::set_var("TEAMS_STREAMING", "false");
+ std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "false");
+ std::env::set_var("TEAMS_CONVERSATION_REGISTRY_PATH", "env-registry.json");
+ std::env::set_var("TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", "121");
+ std::env::set_var("TEAMS_CONVERSATION_REGISTRY_TTL_SECS", "82");
+ std::env::set_var("TEAMS_ALLOWED_TEAMS", "env-team");
+ std::env::set_var("TEAMS_ALLOWED_CHANNELS", "env-channel");
+ std::env::set_var("TEAMS_ALLOW_PERSONAL", "false");
+ std::env::set_var("TEAMS_ALLOW_GROUP_CHATS", "false");
let cfg = TeamsConfig {
app_id: Some("cfg-app".into()),
oauth_endpoint: Some("https://cfg.example/token".into()),
allowed_tenants: Some(vec!["t1".into(), "t2".into()]),
+ dedupe_ttl_secs: Some(42),
+ route_ttl_secs: Some(84),
+ max_route_entries: Some(123),
+ reactions_enabled: Some(true),
+ processing_indicator: Some(TeamsProcessingIndicator::Message),
+ streaming: Some(true),
+ inbound_attachments: Some(true),
+ conversation_registry_path: Some("cfg-registry.json".into()),
+ conversation_registry_max_entries: Some(124),
+ conversation_registry_ttl_secs: Some(85),
+ allowed_teams: Some(vec!["cfg-team".into()]),
+ allowed_channels: Some(vec![]),
+ allow_personal: Some(true),
+ allow_group_chats: Some(true),
..Default::default()
};
let r = cfg.resolve();
assert_eq!(r.app_id.as_deref(), Some("cfg-app"));
assert_eq!(r.oauth_endpoint, "https://cfg.example/token");
assert_eq!(r.allowed_tenants, vec!["t1".to_string(), "t2".to_string()]);
+ assert_eq!(r.dedupe_ttl_secs, 42);
+ assert_eq!(r.route_ttl_secs, 84);
+ assert_eq!(r.max_route_entries, 123);
+ assert!(r.reactions_enabled);
+ assert_eq!(
+ r.processing_indicator,
+ TeamsProcessingIndicator::Message
+ );
+ assert!(r.streaming);
+ assert!(r.inbound_attachments);
+ assert_eq!(
+ r.conversation_registry_path.as_deref(),
+ Some("cfg-registry.json")
+ );
+ assert_eq!(r.conversation_registry_max_entries, 124);
+ assert_eq!(r.conversation_registry_ttl_secs, 85);
+ assert_eq!(r.allowed_teams, vec!["cfg-team"]);
+ assert!(r.allowed_channels.is_empty());
+ assert!(r.allow_personal);
+ assert!(r.allow_group_chats);
+ assert!(r.scope_policy_configured);
// --- empty-string ${} expansion falls through to env ---
+ std::env::set_var("TEAMS_REACTIONS_ENABLED", "true");
+ std::env::set_var("TEAMS_PROCESSING_INDICATOR", "message");
+ std::env::set_var("TEAMS_STREAMING", "true");
+ std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "true");
let cfg = TeamsConfig {
app_id: Some("".into()),
..Default::default()
@@ -3055,6 +3366,49 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
let r = cfg.resolve();
assert_eq!(r.app_id.as_deref(), Some("env-app"));
assert_eq!(r.oauth_endpoint, "https://env.example/token");
+ assert_eq!(r.dedupe_ttl_secs, 41);
+ assert_eq!(r.route_ttl_secs, 83);
+ assert_eq!(r.max_route_entries, 122);
+ assert!(r.reactions_enabled);
+ assert_eq!(
+ r.processing_indicator,
+ TeamsProcessingIndicator::Message
+ );
+ assert!(r.streaming);
+ assert!(r.inbound_attachments);
+ assert_eq!(
+ r.conversation_registry_path.as_deref(),
+ Some("env-registry.json")
+ );
+ assert_eq!(r.conversation_registry_max_entries, 121);
+ assert_eq!(r.conversation_registry_ttl_secs, 82);
+ assert_eq!(r.allowed_teams, vec!["env-team"]);
+ assert_eq!(r.allowed_channels, vec!["env-channel"]);
+ assert!(!r.allow_personal);
+ assert!(!r.allow_group_chats);
+ assert!(r.scope_policy_configured);
+
+ // --- strict numeric boolean forms ---
+ std::env::set_var("TEAMS_STREAMING", "1");
+ std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "1");
+ assert!(TeamsConfig::default().resolve().streaming);
+ assert!(TeamsConfig::default().resolve().inbound_attachments);
+ std::env::set_var("TEAMS_STREAMING", "0");
+ std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "0");
+ assert!(!TeamsConfig::default().resolve().streaming);
+ assert!(!TeamsConfig::default().resolve().inbound_attachments);
+
+ // --- malformed switches fail closed ---
+ std::env::set_var("TEAMS_ALLOW_PERSONAL", "not-a-boolean");
+ std::env::set_var("TEAMS_PROCESSING_INDICATOR", "typing");
+ std::env::set_var("TEAMS_STREAMING", "not-a-boolean");
+ std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "not-a-boolean");
+ let r = TeamsConfig::default().resolve();
+ assert!(!r.allow_personal);
+ assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off);
+ assert!(!r.streaming);
+ assert!(!r.inbound_attachments);
+ assert!(r.scope_policy_configured);
// --- trust_config() view ---
let cfg = TeamsConfig {
@@ -3069,8 +3423,76 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"]
Some(&["29:abc".to_string()][..])
);
- std::env::remove_var("TEAMS_APP_ID");
- std::env::remove_var("TEAMS_OAUTH_ENDPOINT");
+ for k in [
+ "TEAMS_APP_ID",
+ "TEAMS_OAUTH_ENDPOINT",
+ "TEAMS_DEDUPE_TTL_SECS",
+ "TEAMS_ROUTE_TTL_SECS",
+ "TEAMS_MAX_ROUTE_ENTRIES",
+ "TEAMS_REACTIONS_ENABLED",
+ "TEAMS_PROCESSING_INDICATOR",
+ "TEAMS_STREAMING",
+ "TEAMS_INBOUND_ATTACHMENTS",
+ "TEAMS_CONVERSATION_REGISTRY_PATH",
+ "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES",
+ "TEAMS_CONVERSATION_REGISTRY_TTL_SECS",
+ "TEAMS_ALLOWED_TEAMS",
+ "TEAMS_ALLOWED_CHANNELS",
+ "TEAMS_ALLOW_PERSONAL",
+ "TEAMS_ALLOW_GROUP_CHATS",
+ ] {
+ std::env::remove_var(k);
+ }
+ }
+
+ #[test]
+ fn teams_processing_indicator_rejects_unknown_toml_value() {
+ let error = parse_config(
+ "[teams]\nprocessing_indicator = \"typing\"\n",
+ "test",
+ )
+ .unwrap_err();
+ assert!(error.to_string().contains("processing_indicator"));
+ }
+
+ #[test]
+ fn teams_streaming_rejects_non_boolean_toml_value() {
+ let error = parse_config("[teams]\nstreaming = \"yes\"\n", "test").unwrap_err();
+ assert!(error.to_string().contains("streaming"));
+ }
+
+ #[test]
+ fn teams_inbound_attachments_rejects_non_boolean_toml_value() {
+ let error = parse_config("[teams]\ninbound_attachments = \"yes\"\n", "test").unwrap_err();
+ assert!(error.to_string().contains("inbound_attachments"));
+ }
+
+ #[test]
+ fn teams_runtime_bounds_reject_zero() {
+ for key in ["dedupe_ttl_secs", "route_ttl_secs", "max_route_entries"] {
+ let raw = format!("[teams]\n{key} = 0\n");
+ let error = parse_config(&raw, "test").unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains(&format!("teams.{key} must be > 0")),
+ "unexpected error for {key}: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn teams_conversation_registry_bounds_are_closed() {
+ for value in [0, 10_001] {
+ let raw = format!("[teams]\nconversation_registry_max_entries = {value}\n");
+ let error = parse_config(&raw, "test").unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("conversation_registry_max_entries"));
+ }
+ let error =
+ parse_config("[teams]\nconversation_registry_ttl_secs = 0\n", "test").unwrap_err();
+ assert!(error.to_string().contains("conversation_registry_ttl_secs"));
}
/// All `FEISHU_*` env scenarios in ONE test (env is process-global).
@@ -3203,6 +3625,10 @@ allowed_users = ["users/123456789"]
[teams]
app_id = "app-1"
allow_all_users = true
+allowed_teams = ["team-1"]
+allowed_channels = ["channel-1"]
+allow_personal = false
+allow_group_chats = true
[lineworks]
bot_id = "123"
@@ -3226,6 +3652,16 @@ allowed_users = ["uuid-a", "uuid-b"]
let teams = cfg.teams.expect("teams section");
assert_eq!(teams.app_id.as_deref(), Some("app-1"));
assert_eq!(teams.allow_all_users, Some(true));
+ assert_eq!(
+ teams.allowed_teams.as_deref(),
+ Some(&["team-1".to_string()][..])
+ );
+ assert_eq!(
+ teams.allowed_channels.as_deref(),
+ Some(&["channel-1".to_string()][..])
+ );
+ assert_eq!(teams.allow_personal, Some(false));
+ assert_eq!(teams.allow_group_chats, Some(true));
let lw = cfg.lineworks.expect("lineworks section");
assert_eq!(lw.bot_id.as_deref(), Some("123"));
assert_eq!(lw.allow_all_users, None);
@@ -3545,6 +3981,7 @@ command = "echo"
let gw = cfg.gateway.unwrap();
assert_eq!(gw.url, "ws://gw:8080/ws");
assert_eq!(gw.platform, "telegram");
+ assert_eq!(gw.gateway_ack_timeout_secs, 12);
assert!(gw.allowed_users.is_empty());
assert!(gw.allowed_channels.is_empty());
assert!(gw.allow_all_users.is_none());
@@ -3557,6 +3994,57 @@ command = "echo"
));
}
+ #[test]
+ fn parse_gateway_ack_timeout_override() {
+ let toml = r#"
+[gateway]
+url = "wss://gw.example/ws"
+gateway_ack_timeout_secs = 30
+
+[agent]
+command = "echo"
+"#;
+ let cfg = parse_config(toml, "test").unwrap();
+ assert_eq!(cfg.gateway.unwrap().gateway_ack_timeout_secs, 30);
+ }
+
+ #[test]
+ fn parse_gateway_ack_timeout_rejects_invalid_budgets() {
+ let zero = r#"
+[gateway]
+url = "wss://gw.example/ws"
+gateway_ack_timeout_secs = 0
+
+[agent]
+command = "echo"
+"#;
+ assert!(parse_config(zero, "test").is_err());
+
+ let teams_too_short = r#"
+[gateway]
+url = "wss://gw.example/ws"
+platform = "teams"
+gateway_ack_timeout_secs = 10
+
+[agent]
+command = "echo"
+"#;
+ assert!(parse_config(teams_too_short, "test").is_err());
+
+ let beyond_turn = r#"
+[gateway]
+url = "wss://gw.example/ws"
+gateway_ack_timeout_secs = 12
+
+[pool]
+prompt_hard_timeout_secs = 12
+
+[agent]
+command = "echo"
+"#;
+ assert!(parse_config(beyond_turn, "test").is_err());
+ }
+
#[test]
fn parse_gateway_config_with_allowlists() {
let toml = r#"
diff --git a/crates/openab-core/src/cron.rs b/crates/openab-core/src/cron.rs
index 40e9a67ca..fa09bac4b 100644
--- a/crates/openab-core/src/cron.rs
+++ b/crates/openab-core/src/cron.rs
@@ -1,4 +1,6 @@
-use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, SenderContext};
+use crate::adapter::{
+ AdapterRouter, ChannelRef, ChatAdapter, PersistentConversationTarget, SenderContext,
+};
use crate::config::CronJobConfig;
use crate::format;
use chrono::{Timelike, Utc};
@@ -238,13 +240,21 @@ pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool {
}
/// Known platforms that have adapter support.
-const VALID_PLATFORMS: &[&str] = &["discord", "slack", "telegram", "googlechat", "lineworks"];
-
-/// Cron platforms that must NOT get a synthetic thread: Google Chat cron
-/// messages stay top-level by design, and LINE WORKS has no thread/topic API
-/// (its reply dispatch ignores topic creation), so a synthetic thread would
-/// silently deliver to the flat channel instead.
-const CRON_THREADLESS_PLATFORMS: &[&str] = &["googlechat", "lineworks"];
+const VALID_PLATFORMS: &[&str] = &[
+ "discord",
+ "slack",
+ "telegram",
+ "googlechat",
+ "lineworks",
+ "teams",
+];
+
+/// Cron platforms that must NOT get a synthetic thread. Their configured
+/// destination already is the complete routing surface.
+const CRON_THREADLESS_PLATFORMS: &[&str] = &["googlechat", "lineworks", "teams"];
+const TEAMS_BOT_FRAMEWORK_CHANNEL_ID: &str = "msteams";
+const TEAMS_TENANT_ID_MAX_BYTES: usize = 256;
+const TEAMS_CONVERSATION_ID_MAX_BYTES: usize = 2_048;
fn should_create_cron_thread(job: &CronJobConfig) -> bool {
job.thread_id.is_none() && !CRON_THREADLESS_PLATFORMS.contains(&job.platform.as_str())
@@ -257,12 +267,78 @@ fn cron_sender_thread_id(channel: &ChannelRef) -> Option {
.or_else(|| channel.parent_id.as_ref().map(|_| channel.channel_id.clone()))
}
-/// Validate all cronjob configs (fail-fast on bad cron expressions or timezones).
+fn valid_bounded_identifier(value: &str, max_bytes: usize) -> bool {
+ !value.trim().is_empty()
+ && value.trim() == value
+ && value.len() <= max_bytes
+ && !value.chars().any(char::is_control)
+}
+
+fn validate_cron_target_fields(index: usize, job: &CronJobConfig) -> anyhow::Result<()> {
+ if job.platform == "teams" {
+ let tenant_id = job.teams_tenant_id.as_deref().ok_or_else(|| {
+ anyhow::anyhow!("cronjobs[{index}]: Teams jobs require teams_tenant_id")
+ })?;
+ if !valid_bounded_identifier(tenant_id, TEAMS_TENANT_ID_MAX_BYTES) {
+ anyhow::bail!("cronjobs[{index}]: Teams tenant identity is invalid");
+ }
+ if !valid_bounded_identifier(&job.channel, TEAMS_CONVERSATION_ID_MAX_BYTES) {
+ anyhow::bail!("cronjobs[{index}]: Teams conversation identity is invalid");
+ }
+ if job.thread_id.is_some() {
+ anyhow::bail!("cronjobs[{index}]: Teams jobs must not set thread_id");
+ }
+ if job.id.is_some()
+ || job.disable_on_success.is_some()
+ || job.disable_on_success_match.is_some()
+ || job.disable_on_success_working_dir.is_some()
+ || job.disable_on_success_timeout_secs != 60
+ {
+ anyhow::bail!("cronjobs[{index}]: Teams baseline jobs contain a usercron-only field");
+ }
+ } else if job.teams_tenant_id.is_some() {
+ anyhow::bail!(
+ "cronjobs[{index}]: teams_tenant_id is only valid when platform is \"teams\""
+ );
+ }
+ Ok(())
+}
+
+fn cron_channel_ref(job: &CronJobConfig) -> anyhow::Result {
+ let persistent_conversation = if job.platform == "teams" {
+ let tenant_id = job
+ .teams_tenant_id
+ .as_deref()
+ .filter(|value| valid_bounded_identifier(value, TEAMS_TENANT_ID_MAX_BYTES))
+ .ok_or_else(|| anyhow::anyhow!("Teams cron target is invalid"))?;
+ if !valid_bounded_identifier(&job.channel, TEAMS_CONVERSATION_ID_MAX_BYTES) {
+ anyhow::bail!("Teams cron target is invalid");
+ }
+ Some(Box::new(PersistentConversationTarget {
+ tenant_id: tenant_id.to_owned(),
+ bot_framework_channel_id: TEAMS_BOT_FRAMEWORK_CHANNEL_ID.into(),
+ conversation_id: job.channel.clone(),
+ }))
+ } else {
+ None
+ };
+ Ok(ChannelRef {
+ platform: job.platform.clone(),
+ channel_id: job.channel.clone(),
+ thread_id: job.thread_id.clone(),
+ parent_id: None,
+ persistent_conversation,
+ origin_event_id: None,
+ })
+}
+
+/// Validate all baseline cron configs before scheduler or adapter side effects.
pub fn validate_cronjobs(
cronjobs: &[CronJobConfig],
configured_platforms: &[&str],
) -> anyhow::Result<()> {
for (i, job) in cronjobs.iter().enumerate() {
+ validate_cron_target_fields(i, job)?;
if !job.enabled {
continue;
}
@@ -327,8 +403,17 @@ pub fn load_usercron_file(path: &Path, configured_platforms: &[&str]) -> Vec,
) -> Vec {
- configs.iter().filter(|job| {
+ configs
+ .iter()
+ .filter(|job| {
if !job.enabled {
+ if job.platform == "teams" {
+ info!(schedule = %job.schedule, source, "Teams cronjob disabled, skipping");
+ } else {
info!(schedule = %job.schedule, channel = %job.channel, source, "cronjob disabled, skipping");
}
+ }
job.enabled
- }).filter_map(|job| {
+ })
+ .filter_map(|job| {
let schedule = match parse_cron_expr(&job.schedule) {
- Ok(s) => s,
- Err(e) => {
- error!(schedule = %job.schedule, error = %e, source, "invalid cron expression, skipping");
+ Ok(schedule) => schedule,
+ Err(error) => {
+ error!(schedule = %job.schedule, error = %error, source, "invalid cron expression, skipping");
return None;
}
};
let tz: Tz = match job.timezone.parse() {
- Ok(t) => t,
- Err(e) => {
- error!(timezone = %job.timezone, error = %e, source, "invalid timezone, skipping");
+ Ok(timezone) => timezone,
+ Err(error) => {
+ error!(timezone = %job.timezone, error = %error, source, "invalid timezone, skipping");
return None;
}
};
+ if job.platform == "teams" {
info!(
- schedule = %job.schedule, timezone = %job.timezone,
- channel = %job.channel, platform = %job.platform,
- message = %job.message, source,
+ schedule = %job.schedule,
+ timezone = %job.timezone,
+ platform = "teams",
+ source,
+ "Teams cronjob registered"
+ );
+ } else {
+ info!(
+ schedule = %job.schedule,
+ timezone = %job.timezone,
+ channel = %job.channel,
+ platform = %job.platform,
+ message = %job.message,
+ source,
"cronjob registered"
);
+ }
Some(ParsedJob {
schedule,
tz,
config: job.clone(),
usercron_path: usercron_path.map(Path::to_path_buf),
})
- }).collect()
+ })
+ .collect()
}
/// Run the internal cron scheduler. Evaluates cron expressions once per minute.
@@ -513,10 +619,22 @@ pub async fn run_scheduler(
{
let running = in_flight.lock().await;
if running.contains(&idx) {
+ if job.config.platform == "teams" {
+ warn!(schedule = %job.config.schedule, platform = "teams", "skipping cronjob, previous execution still running");
+ } else {
warn!(schedule = %job.config.schedule, channel = %job.config.channel, "skipping cronjob, previous execution still running");
+ }
continue;
}
}
+ if job.config.platform == "teams" {
+ info!(
+ schedule = %job.config.schedule,
+ platform = "teams",
+ source = if job.usercron_path.is_some() { "usercron" } else { "baseline" },
+ "cronjob fired"
+ );
+ } else {
info!(
schedule = %job.config.schedule,
channel = %job.config.channel,
@@ -525,6 +643,7 @@ pub async fn run_scheduler(
sender = %job.config.sender_name,
"🔔 cronjob fired"
);
+ }
in_flight.lock().await.insert(idx);
let config = job.config.clone();
@@ -591,13 +710,41 @@ async fn fire_cronjob(
};
let adapter = match adapters.get(&job.platform) {
- Some(a) => a.clone(),
+ Some(adapter) => adapter.clone(),
None => {
error!(platform = %job.platform, "no adapter for platform, skipping cronjob");
return;
}
};
+ if job.platform == "teams" {
+ let capabilities = adapter.capabilities("teams");
+ if !capabilities.send_ack || !capabilities.supports_persistent_conversation_send {
+ warn!(
+ platform = "teams",
+ operation = "persistent_trigger_send",
+ "Teams cron persistent-send capability is unavailable; skipping execution"
+ );
+ return;
+ }
+ }
+
+ let thread_channel = match cron_channel_ref(job) {
+ Ok(channel) => channel,
+ Err(error) => {
+ if job.platform == "teams" {
+ warn!(
+ platform = "teams",
+ operation = "target_build",
+ "Teams cron target is invalid"
+ );
+ } else {
+ error!(platform = %job.platform, error = %error, "failed to build cron target");
+ }
+ return;
+ }
+ };
+
if let Some(command) = non_empty_opt(job.disable_on_success.as_deref()) {
let marker = match non_empty_opt(job.disable_on_success_match.as_deref()) {
Some(marker) => marker,
@@ -612,16 +759,9 @@ async fn fire_cronjob(
if !marker.is_empty() {
match check_disable_on_success(job, command, marker).await {
DisableOnSuccessResult::Achieved => {
- let channel = ChannelRef {
- platform: job.platform.clone(),
- channel_id: job.channel.clone(),
- thread_id: job.thread_id.clone(),
- parent_id: None,
- origin_event_id: None,
- };
if let Err(e) = adapter
.send_message(
- &channel,
+ &thread_channel,
&format!(
"✅ Goal achieved: `{}` matched `{}`. Disabling cronjob.",
command, marker
@@ -655,14 +795,6 @@ async fn fire_cronjob(
}
}
- let thread_channel = ChannelRef {
- platform: job.platform.clone(),
- channel_id: job.channel.clone(),
- thread_id: job.thread_id.clone(),
- parent_id: None,
- origin_event_id: None,
- };
-
let trigger_msg = match adapter
.send_message(
&thread_channel,
@@ -670,9 +802,26 @@ async fn fire_cronjob(
)
.await
{
- Ok(msg) => msg,
- Err(e) => {
- error!(channel = %job.channel, error = %e, "failed to send cron message");
+ Ok(message) if job.platform == "teams" && message.message_id.trim().is_empty() => {
+ warn!(
+ platform = "teams",
+ operation = "persistent_trigger_send",
+ outcome = "unknown",
+ "Teams cron trigger returned no activity id; skipping agent work"
+ );
+ return;
+ }
+ Ok(message) => message,
+ Err(error) => {
+ if job.platform == "teams" {
+ warn!(
+ platform = "teams",
+ operation = "persistent_trigger_send",
+ "Teams cron trigger was not delivered; skipping agent work"
+ );
+ } else {
+ error!(channel = %job.channel, error = %error, "failed to send cron message");
+ }
return;
}
};
@@ -1255,6 +1404,7 @@ message = "hello"
assert_eq!(job.sender_name, "openab-cron");
assert_eq!(job.timezone, "UTC");
assert!(job.thread_id.is_none());
+ assert!(job.teams_tenant_id.is_none());
assert!(job.id.is_none());
assert!(job.disable_on_success.is_none());
assert!(job.disable_on_success_match.is_none());
@@ -1333,6 +1483,26 @@ message = "ping"
assert_eq!(jobs[0].message, "ping");
}
+ #[test]
+ fn load_usercron_rejects_teams_even_when_configured() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("cronjob.toml");
+ std::fs::write(
+ &path,
+ r#"
+[[jobs]]
+schedule = "* * * * *"
+channel = "conversation-1"
+teams_tenant_id = "tenant-1"
+message = "must not run"
+platform = "teams"
+"#,
+ )
+ .unwrap();
+
+ assert!(load_usercron_file(&path, &["teams"]).is_empty());
+ }
+
#[test]
fn load_usercron_invalid_toml_returns_empty() {
let dir = tempfile::tempdir().unwrap();
@@ -1439,6 +1609,7 @@ disable_on_success = "echo SUCCESS"
enabled: true,
schedule: "* * * * *".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1548,6 +1719,7 @@ message = "a"
enabled: true,
schedule: "* * * * *".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1571,6 +1743,31 @@ message = "a"
assert!(!should_create_cron_thread(&job));
}
+ #[test]
+ fn teams_cron_target_is_exact_and_threadless() {
+ let mut job = test_cron_job();
+ job.id = None;
+ job.platform = "teams".into();
+ job.channel = "conversation-1".into();
+ job.teams_tenant_id = Some("tenant-1".into());
+ job.disable_on_success = None;
+ job.disable_on_success_match = None;
+
+ assert!(!should_create_cron_thread(&job));
+ let channel = cron_channel_ref(&job).unwrap();
+ assert_eq!(channel.channel_id, "conversation-1");
+ assert!(channel.thread_id.is_none());
+ assert!(channel.origin_event_id.is_none());
+ assert_eq!(
+ channel.persistent_conversation,
+ Some(Box::new(PersistentConversationTarget {
+ tenant_id: "tenant-1".into(),
+ bot_framework_channel_id: "msteams".into(),
+ conversation_id: "conversation-1".into(),
+ }))
+ );
+ }
+
#[test]
fn lineworks_cron_never_requests_synthetic_thread() {
// LINE WORKS has no thread API — a synthetic cron thread would be
@@ -1598,6 +1795,7 @@ message = "a"
channel_id: "spaces/TEST".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
@@ -1611,6 +1809,7 @@ message = "a"
channel_id: "spaces/TEST".into(),
thread_id: Some("spaces/TEST/threads/THREAD".into()),
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
@@ -1627,10 +1826,14 @@ message = "a"
channel_id: "thread-456".into(),
thread_id: None,
parent_id: Some("channel-123".into()),
+ persistent_conversation: None,
origin_event_id: None,
};
- assert_eq!(cron_sender_thread_id(&channel).as_deref(), Some("thread-456"));
+ assert_eq!(
+ cron_sender_thread_id(&channel).as_deref(),
+ Some("thread-456")
+ );
}
// --- validate_cronjobs tests ---
@@ -1642,6 +1845,7 @@ message = "a"
enabled: true,
schedule: "0 9 * * 1-5".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1666,6 +1870,55 @@ message = "a"
assert!(validate_cronjobs(&[job], &["googlechat"]).is_ok());
}
+ #[test]
+ fn validate_cronjobs_accepts_bounded_teams_baseline() {
+ let mut job = test_cron_job();
+ job.id = None;
+ job.platform = "teams".into();
+ job.channel = "conversation-1".into();
+ job.teams_tenant_id = Some("tenant-1".into());
+ job.disable_on_success = None;
+ job.disable_on_success_match = None;
+
+ assert!(validate_cronjobs(&[job], &["teams"]).is_ok());
+ }
+
+ #[test]
+ fn validate_cronjobs_rejects_invalid_teams_target_shapes() {
+ let mut job = test_cron_job();
+ job.id = None;
+ job.platform = "teams".into();
+ job.channel = "conversation-1".into();
+ job.teams_tenant_id = Some("tenant-1".into());
+ job.disable_on_success = None;
+ job.disable_on_success_match = None;
+
+ let mut missing_tenant = job.clone();
+ missing_tenant.teams_tenant_id = None;
+ assert!(validate_cronjobs(&[missing_tenant], &["teams"]).is_err());
+
+ let mut empty_tenant = job.clone();
+ empty_tenant.teams_tenant_id = Some(String::new());
+ assert!(validate_cronjobs(&[empty_tenant], &["teams"]).is_err());
+
+ let mut oversized_tenant = job.clone();
+ oversized_tenant.teams_tenant_id = Some("t".repeat(TEAMS_TENANT_ID_MAX_BYTES + 1));
+ assert!(validate_cronjobs(&[oversized_tenant], &["teams"]).is_err());
+
+ let mut threaded = job.clone();
+ threaded.thread_id = Some("thread-1".into());
+ assert!(validate_cronjobs(&[threaded], &["teams"]).is_err());
+
+ let mut usercron_field = job.clone();
+ usercron_field.enabled = false;
+ usercron_field.disable_on_success = Some("echo done".into());
+ assert!(validate_cronjobs(&[usercron_field], &["teams"]).is_err());
+
+ let mut cross_platform = job;
+ cross_platform.platform = "discord".into();
+ assert!(validate_cronjobs(&[cross_platform], &["discord"]).is_err());
+ }
+
#[test]
fn validate_cronjobs_invalid_cron_fails() {
let jobs = vec![CronJobConfig {
@@ -1673,6 +1926,7 @@ message = "a"
enabled: true,
schedule: "bad".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1694,6 +1948,7 @@ message = "a"
enabled: true,
schedule: "* * * * *".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1715,6 +1970,7 @@ message = "a"
enabled: true,
schedule: "* * * * *".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "matrix".into(),
sender_name: "test".into(),
@@ -1736,6 +1992,7 @@ message = "a"
enabled: true,
schedule: "* * * * *".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "slack".into(),
sender_name: "test".into(),
@@ -1757,6 +2014,7 @@ message = "a"
enabled: false,
schedule: "bad".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1777,6 +2035,7 @@ message = "a"
enabled: true,
schedule: "bad".into(),
channel: "123".into(),
+ teams_tenant_id: None,
message: "hi".into(),
platform: "discord".into(),
sender_name: "test".into(),
@@ -1831,13 +2090,25 @@ schedule = "*/30 * * * *"
channel = "456"
message = "ping"
platform = "slack"
+
+[[cron.jobs]]
+schedule = "0 9 * * 1-5"
+channel = "conversation-1"
+teams_tenant_id = "tenant-1"
+message = "scheduled"
+platform = "teams"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert!(cfg.cron.usercron_enabled);
assert_eq!(cfg.cron.usercron_path.as_deref(), Some("cronjob.toml"));
- assert_eq!(cfg.cron.jobs.len(), 2);
+ assert_eq!(cfg.cron.jobs.len(), 3);
assert_eq!(cfg.cron.jobs[0].message, "hello");
assert_eq!(cfg.cron.jobs[1].platform, "slack");
+ assert_eq!(cfg.cron.jobs[2].platform, "teams");
+ assert_eq!(
+ cfg.cron.jobs[2].teams_tenant_id.as_deref(),
+ Some("tenant-1")
+ );
}
#[test]
diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs
index 609bd7459..23842159a 100644
--- a/crates/openab-core/src/discord.rs
+++ b/crates/openab-core/src/discord.rs
@@ -2,6 +2,10 @@ use crate::acp::protocol::{ConfigOption, UsageReport};
use crate::acp::ContentBlock;
use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext};
use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX};
+use crate::commands::{
+ render_text_result, Command as CoreCommand, CommandContext, CommandResult, CommandService,
+ ConfigCategory,
+};
use crate::config::{AllowBots, AllowUsers, SttConfig};
use crate::dispatch::DispatchTarget;
use crate::format;
@@ -20,7 +24,7 @@ use serenity::model::application::ButtonStyle;
use serenity::model::application::{Command, CommandOptionType, ComponentInteractionDataKind, Interaction};
use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, Reaction, ReactionType};
use serenity::model::gateway::Ready;
-use serenity::model::id::{ChannelId, MessageId, UserId};
+use serenity::model::id::{ChannelId, GuildId, MessageId, UserId};
use serenity::prelude::*;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
@@ -178,6 +182,7 @@ impl ChatAdapter for DiscordAdapter {
channel_id: thread.id.to_string(),
thread_id: None,
parent_id: Some(channel.channel_id.clone()),
+ persistent_conversation: None,
origin_event_id: None,
})
}
@@ -586,6 +591,7 @@ impl EventHandler for Handler {
channel_id: channel_id.to_string(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
@@ -718,8 +724,7 @@ impl EventHandler for Handler {
// @mention in an ambient context → discard buffer + normal dispatch.
// NOTE: Bot messages without @mention are already handled by the
// early-route above; this block handles human messages and bot @mentions.
- if in_ambient_context {
- let ambient = self.ambient.as_ref().unwrap();
+ if let Some(ambient) = self.ambient.as_ref().filter(|_| in_ambient_context) {
if !is_dm {
if is_mentioned {
// Discard ambient buffer — mention takes priority.
@@ -749,6 +754,7 @@ impl EventHandler for Handler {
channel_id: channel_id.to_string(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
@@ -1032,6 +1038,7 @@ impl EventHandler for Handler {
channel_id: msg.channel_id.get().to_string(),
thread_id: None,
parent_id: thread_parent_id.clone(),
+ persistent_conversation: None,
origin_event_id: None,
}
} else {
@@ -1123,7 +1130,7 @@ impl EventHandler for Handler {
return;
}
}
- let sender_json = serde_json::to_string(&sender).unwrap();
+ let sender_json = serde_json::to_string(&sender).unwrap_or_default();
let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id);
let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks);
let buf_msg = crate::dispatch::BufferedMessage {
@@ -1236,24 +1243,32 @@ impl EventHandler for Handler {
if !in_allowed_thread {
return;
}
- (ChannelRef {
- platform: "discord".into(),
- channel_id: channel_id.get().to_string(),
- thread_id: None,
- parent_id: parent.map(|p| p.to_string()),
- origin_event_id: None,
- }, true)
+ (
+ ChannelRef {
+ platform: "discord".into(),
+ channel_id: channel_id.get().to_string(),
+ thread_id: None,
+ parent_id: parent.map(|p| p.to_string()),
+ persistent_conversation: None,
+ origin_event_id: None,
+ },
+ true,
+ )
} else {
if !in_allowed_channel {
return;
}
- (ChannelRef {
- platform: "discord".into(),
- channel_id: channel_id.get().to_string(),
- thread_id: None,
- parent_id: None,
- origin_event_id: None,
- }, false)
+ (
+ ChannelRef {
+ platform: "discord".into(),
+ channel_id: channel_id.get().to_string(),
+ thread_id: None,
+ parent_id: None,
+ persistent_conversation: None,
+ origin_event_id: None,
+ },
+ false,
+ )
}
}
_ => return,
@@ -1344,6 +1359,7 @@ impl EventHandler for Handler {
channel_id: channel_id.get().to_string(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
},
message_id: message_id.to_string(),
@@ -1364,7 +1380,7 @@ impl EventHandler for Handler {
let sender_id = sender.sender_id.clone();
let sender_name_clone = sender.sender_name.clone();
- let sender_json = serde_json::to_string(&sender).unwrap();
+ let sender_json = serde_json::to_string(&sender).unwrap_or_default();
let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id);
let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &[]);
let buf_msg = crate::dispatch::BufferedMessage {
@@ -1485,22 +1501,48 @@ impl EventHandler for Handler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
- Interaction::Command(cmd) if cmd.data.name == "models" => {
- self.handle_config_command(&ctx, &cmd, "model", "model")
- .await;
- }
- Interaction::Command(cmd) if cmd.data.name == "agents" => {
- self.handle_config_command(&ctx, &cmd, "agent", "agent")
- .await;
- }
- Interaction::Command(cmd) if cmd.data.name == "cancel" => {
- self.handle_cancel_command(&ctx, &cmd).await;
- }
- Interaction::Command(cmd) if cmd.data.name == "cancel-all" => {
- self.handle_cancel_all_command(&ctx, &cmd).await;
- }
- Interaction::Command(cmd) if cmd.data.name == "reset" => {
- self.handle_reset_command(&ctx, &cmd).await;
+ Interaction::Command(cmd)
+ if matches!(
+ cmd.data.name.as_str(),
+ "models" | "agents" | "cancel" | "cancel-all" | "reset" | "usage"
+ ) =>
+ {
+ if let Err(message) = self
+ .shared_command_admission(
+ &ctx,
+ cmd.channel_id,
+ cmd.guild_id,
+ cmd.user.id,
+ cmd.user.bot,
+ )
+ .await
+ {
+ let response = CreateInteractionResponse::Message(
+ CreateInteractionResponseMessage::new()
+ .content(message)
+ .ephemeral(true),
+ );
+ if cmd.create_response(&ctx.http, response).await.is_err() {
+ tracing::error!("failed to deny Discord command interaction");
+ }
+ return;
+ }
+
+ match cmd.data.name.as_str() {
+ "models" => {
+ self.handle_config_command(&ctx, &cmd, ConfigCategory::Model, "model")
+ .await;
+ }
+ "agents" => {
+ self.handle_config_command(&ctx, &cmd, ConfigCategory::Agent, "agent")
+ .await;
+ }
+ "cancel" => self.handle_cancel_command(&ctx, &cmd).await,
+ "cancel-all" => self.handle_cancel_all_command(&ctx, &cmd).await,
+ "reset" => self.handle_reset_command(&ctx, &cmd).await,
+ "usage" => self.handle_usage_command(&ctx, &cmd).await,
+ _ => unreachable!("guard restricts shared command names"),
+ }
}
Interaction::Command(cmd) if cmd.data.name == "remind" => {
self.handle_remind_command(&ctx, &cmd).await;
@@ -1511,14 +1553,35 @@ impl EventHandler for Handler {
Interaction::Command(cmd) if cmd.data.name == "auth" => {
self.handle_auth_command(&ctx, &cmd).await;
}
- Interaction::Command(cmd) if cmd.data.name == "usage" => {
- self.handle_usage_command(&ctx, &cmd).await;
- }
- Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => {
- self.handle_config_select(&ctx, &comp).await;
- }
- Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_pg:") => {
- self.handle_pagination(&ctx, &comp).await;
+ Interaction::Component(comp)
+ if comp.data.custom_id.starts_with("acp_config_")
+ || comp.data.custom_id.starts_with("acp_pg:") =>
+ {
+ if let Err(message) = self
+ .shared_command_admission(
+ &ctx,
+ comp.channel_id,
+ comp.guild_id,
+ comp.user.id,
+ comp.user.bot,
+ )
+ .await
+ {
+ let response = CreateInteractionResponse::Message(
+ CreateInteractionResponseMessage::new()
+ .content(message)
+ .ephemeral(true),
+ );
+ if comp.create_response(&ctx.http, response).await.is_err() {
+ tracing::error!("failed to deny Discord command component");
+ }
+ return;
+ }
+ if comp.data.custom_id.starts_with("acp_config_") {
+ self.handle_config_select(&ctx, &comp).await;
+ } else {
+ self.handle_pagination(&ctx, &comp).await;
+ }
}
_ => {}
}
@@ -1528,6 +1591,80 @@ impl EventHandler for Handler {
// --- Slash command & interaction handlers ---
impl Handler {
+ fn shared_command_service(&self) -> CommandService {
+ CommandService::new(self.router.pool().clone(), self.dispatcher.clone())
+ }
+
+ fn shared_command_context(channel_id: ChannelId) -> CommandContext {
+ CommandContext::new("discord", channel_id.to_string(), true)
+ }
+
+ async fn shared_command_admission(
+ &self,
+ ctx: &Context,
+ channel_id: ChannelId,
+ guild_id: Option,
+ user_id: UserId,
+ user_is_bot: bool,
+ ) -> Result<(), &'static str> {
+ if user_is_bot {
+ return Err("🤖 Bots cannot use this command.");
+ }
+ if is_denied_user(
+ false,
+ self.allow_all_users,
+ &self.allowed_users,
+ user_id.get(),
+ ) {
+ return Err("🚫 You are not allowed to use this bot.");
+ }
+
+ let is_dm = guild_id.is_none();
+ let surface_allowed = if is_dm {
+ discord_command_surface_allowed(true, self.allow_dm, false, false)
+ } else {
+ match channel_id.to_channel(&ctx.http).await {
+ Ok(serenity::model::channel::Channel::Guild(channel)) => {
+ let in_allowed_channel = self.allow_all_channels
+ || self.allowed_channels.contains(&channel_id.get());
+ let (in_allowed_thread, _) = detect_thread(
+ channel.thread_metadata.is_some(),
+ channel.parent_id.map(|id| id.get()),
+ channel.owner_id.map(|id| id.get()),
+ ctx.cache.current_user().id.get(),
+ &self.allowed_channels,
+ self.allow_all_channels,
+ in_allowed_channel,
+ );
+ discord_command_surface_allowed(
+ false,
+ self.allow_dm,
+ in_allowed_channel,
+ in_allowed_thread,
+ )
+ }
+ _ => false,
+ }
+ };
+ if !surface_allowed {
+ return Err("⚠️ Run this command inside an allowed Discord channel, thread, or DM.");
+ }
+
+ if !self
+ .router
+ .gate_incoming(
+ "discord",
+ &channel_id.to_string(),
+ is_dm,
+ &user_id.to_string(),
+ )
+ .is_allowed()
+ {
+ return Err("🚫 You are not allowed to use this bot.");
+ }
+ Ok(())
+ }
+
/// Build a Discord select menu from ACP configOptions with the given category.
/// Paginates options in pages of 25 (Discord limit). The current selection is
/// always placed first so it appears on page 0.
@@ -1636,15 +1773,9 @@ impl Handler {
.iter()
.find(|o| o.category.as_deref() == Some(category))?;
let total_pages = opt.options.len().div_ceil(SELECT_MENU_PAGE_SIZE);
- let page = match page {
- Some(p) => p.min(total_pages.saturating_sub(1)),
- None => opt
- .options
- .iter()
- .position(|o| o.value == opt.current_value)
- .map(|i| i / SELECT_MENU_PAGE_SIZE)
- .unwrap_or(0),
- };
+ // build_config_select moves the current value to index zero, so a new
+ // interaction must start on page zero regardless of its original index.
+ let page = page.unwrap_or(0).min(total_pages.saturating_sub(1));
let select = Self::build_config_select(options, category, page)?;
let mut rows = vec![CreateActionRow::SelectMenu(select)];
@@ -1658,28 +1789,42 @@ impl Handler {
&self,
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
- category: &str,
+ category: ConfigCategory,
label: &str,
) {
- let thread_key = format!("discord:{}", cmd.channel_id.get());
- let config_options = self.router.pool().get_config_options(&thread_key).await;
-
- let response = match Self::build_config_components(&config_options, category, None) {
- Some(rows) => CreateInteractionResponse::Message(
- CreateInteractionResponseMessage::new()
- .content(format!("🔧 Select a {label}:"))
- .components(rows)
- .ephemeral(true),
- ),
- None => CreateInteractionResponse::Message(
+ let context = Self::shared_command_context(cmd.channel_id);
+ let result = self
+ .shared_command_service()
+ .execute(CoreCommand::ListConfig(category), &context)
+ .await;
+ let response = match &result {
+ CommandResult::ConfigOptions { options, .. } => {
+ match Self::build_config_components(options, category.as_str(), None) {
+ Some(rows) => CreateInteractionResponse::Message(
+ CreateInteractionResponseMessage::new()
+ .content(format!("🔧 Select a {label}:"))
+ .components(rows)
+ .ephemeral(true),
+ ),
+ None => CreateInteractionResponse::Message(
+ CreateInteractionResponseMessage::new()
+ .content(render_text_result(&result))
+ .ephemeral(true),
+ ),
+ }
+ }
+ _ => CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
- .content(format!("⚠️ No {label} options available. Start a conversation first by @mentioning the bot."))
+ .content(render_text_result(&result))
.ephemeral(true),
),
};
- if let Err(e) = cmd.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, category, "failed to respond to slash command");
+ if cmd.create_response(&ctx.http, response).await.is_err() {
+ tracing::error!(
+ category = category.as_str(),
+ "failed to respond to config command"
+ );
}
}
@@ -1688,102 +1833,78 @@ impl Handler {
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
) {
- let thread_key = format!("discord:{}", cmd.channel_id.get());
-
- if !self.router.pool().has_active_session(&thread_key).await {
- let response = CreateInteractionResponse::Message(
- CreateInteractionResponseMessage::new()
- .content("⚠️ No active session. Start a conversation first by @mentioning the bot.")
- .ephemeral(true),
- );
- if let Err(e) = cmd.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, "failed to respond to /usage command");
- }
- return;
- }
-
// The ACP round-trip can exceed Discord's 3-second interaction
// deadline — acknowledge with a deferred ephemeral response first.
let defer =
CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new().ephemeral(true));
- if let Err(e) = cmd.create_response(&ctx.http, defer).await {
- tracing::error!(error = %e, "failed to defer /usage response");
+ if cmd.create_response(&ctx.http, defer).await.is_err() {
+ tracing::error!("failed to defer /usage response");
return;
}
- let followup = match self.router.pool().get_usage(&thread_key).await {
- Ok(report) => {
- let (content, embed) = build_usage_reply(&report);
+ let context = Self::shared_command_context(cmd.channel_id);
+ let result = self
+ .shared_command_service()
+ .execute(CoreCommand::Usage, &context)
+ .await;
+ let followup = match &result {
+ CommandResult::Usage(report) => {
+ let (content, embed) = build_usage_reply(report);
CreateInteractionResponseFollowup::new()
.content(content)
.embed(embed)
.ephemeral(true)
}
- Err(e) => CreateInteractionResponseFollowup::new()
- .content(format!("⚠️ {e}"))
+ _ => CreateInteractionResponseFollowup::new()
+ .content(render_text_result(&result))
.ephemeral(true),
};
- if let Err(e) = cmd.create_followup(&ctx.http, followup).await {
- tracing::error!(error = %e, "failed to send /usage followup");
+ if cmd.create_followup(&ctx.http, followup).await.is_err() {
+ tracing::error!("failed to send /usage followup");
}
}
- async fn handle_cancel_command(
+ async fn handle_control_command(
&self,
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
+ command: CoreCommand,
) {
- let thread_key = format!("discord:{}", cmd.channel_id.get());
- let result = self.router.pool().cancel_session(&thread_key).await;
-
- let msg = match result {
- Ok(()) => "🛑 Cancel signal sent.".to_string(),
- Err(e) => format!("⚠️ {e}"),
- };
-
+ let command_name = command.name();
+ let context = Self::shared_command_context(cmd.channel_id);
+ let result = self
+ .shared_command_service()
+ .execute(command, &context)
+ .await;
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
- .content(msg)
+ .content(render_text_result(&result))
.ephemeral(true),
);
- if let Err(e) = cmd.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, "failed to respond to /cancel command");
+ if cmd.create_response(&ctx.http, response).await.is_err() {
+ tracing::error!(
+ command = command_name.as_str(),
+ "failed to respond to control command"
+ );
}
}
- async fn handle_cancel_all_command(
+ async fn handle_cancel_command(
&self,
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
) {
- // /cancel-all is the nuclear escape hatch: stop the in-flight turn AND clear
- // every lane's buffer in this thread, so a human can intervene from a clean slate.
- let session_key = format!("discord:{}", cmd.channel_id.get());
- let dropped = self
- .dispatcher
- .cancel_buffered_thread("discord", &cmd.channel_id.get().to_string());
-
- let cancel_result = self.router.pool().cancel_session(&session_key).await;
-
- // Buffer count is approximate (sweep races with new arrivals) so we surface
- // a binary "cleared / nothing" signal rather than a misleading exact number.
- let msg = match (cancel_result, dropped) {
- (Ok(()), 0) => "🛑 Cancel signal sent.".to_string(),
- (Ok(()), _) => "🛑 Cancel signal sent. Buffered messages cleared.".to_string(),
- (Err(_), 0) => {
- "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string()
- }
- (Err(_), _) => "🛑 Buffered messages cleared. No active session to cancel.".to_string(),
- };
+ self.handle_control_command(ctx, cmd, CoreCommand::Cancel)
+ .await;
+ }
- let response = CreateInteractionResponse::Message(
- CreateInteractionResponseMessage::new()
- .content(msg)
- .ephemeral(true),
- );
- if let Err(e) = cmd.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, "failed to respond to /cancel-all command");
- }
+ async fn handle_cancel_all_command(
+ &self,
+ ctx: &Context,
+ cmd: &serenity::model::application::CommandInteraction,
+ ) {
+ self.handle_control_command(ctx, cmd, CoreCommand::CancelAll)
+ .await;
}
async fn handle_reset_command(
@@ -1791,37 +1912,8 @@ impl Handler {
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
) {
- // /reset clears every lane's buffer in this thread and tears down the shared
- // ACP session — the next message in the thread starts a fresh conversation.
- let session_key = format!("discord:{}", cmd.channel_id.get());
- let dropped = self
- .dispatcher
- .cancel_buffered_thread("discord", &cmd.channel_id.get().to_string());
-
- let result = self.router.pool().reset_session(&session_key).await;
-
- let msg = match result {
- Ok(()) if dropped > 0 => {
- format!("🔄 Session reset. Dropped {dropped} buffered message(s). Start a new conversation!")
- }
- Ok(()) => "🔄 Session reset. Start a new conversation!".to_string(),
- Err(_) if dropped > 0 => {
- format!("🔄 Dropped {dropped} buffered message(s). No active session to reset.")
- }
- Err(_) => {
- "⚠️ No active session to reset. Start a conversation first by @mentioning the bot."
- .to_string()
- }
- };
-
- let response = CreateInteractionResponse::Message(
- CreateInteractionResponseMessage::new()
- .content(msg)
- .ephemeral(true),
- );
- if let Err(e) = cmd.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, "failed to respond to /reset command");
- }
+ self.handle_control_command(ctx, cmd, CoreCommand::Reset)
+ .await;
}
async fn handle_remind_command(
@@ -2452,53 +2544,26 @@ impl Handler {
.data
.custom_id
.strip_prefix("acp_config_")
- .unwrap_or("")
- .to_string();
-
- if config_id.is_empty() {
- return;
- }
-
+ .unwrap_or("");
let selected_value = match &comp.data.kind {
- ComponentInteractionDataKind::StringSelect { values } => match values.first() {
- Some(v) => v.clone(),
- None => return,
- },
- _ => return,
+ ComponentInteractionDataKind::StringSelect { values } => {
+ values.first().map(String::as_str).unwrap_or("")
+ }
+ _ => "",
};
-
- let thread_key = format!("discord:{}", comp.channel_id.get());
-
+ let context = Self::shared_command_context(comp.channel_id);
let result = self
- .router
- .pool()
- .set_config_option(&thread_key, &config_id, &selected_value)
+ .shared_command_service()
+ .set_config_value(&context, config_id, selected_value)
.await;
-
- let response_msg = match result {
- Ok(updated_options) => {
- let display_name = updated_options
- .iter()
- .find(|o| o.id == config_id)
- .and_then(|o| o.options.iter().find(|v| v.value == selected_value))
- .map(|v| v.name.as_str())
- .unwrap_or(&selected_value);
- format!("✅ Switched to **{}**", display_name)
- }
- Err(e) => {
- tracing::error!(error = %e, "failed to set config option");
- format!("❌ Failed to switch: {}", e)
- }
- };
-
let response = CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new()
- .content(response_msg)
+ .content(render_text_result(&result))
.components(vec![]),
);
- if let Err(e) = comp.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, "failed to respond to config select");
+ if comp.create_response(&ctx.http, response).await.is_err() {
+ tracing::error!("failed to respond to config select");
}
}
@@ -2507,39 +2572,56 @@ impl Handler {
ctx: &Context,
comp: &serenity::model::application::ComponentInteraction,
) {
- // Parse custom_id format: acp_pg:{category}:{page}
let parts: Vec<&str> = comp.data.custom_id.splitn(3, ':').collect();
- let (category, page) = match parts.as_slice() {
- [_, cat, pg] => match pg.parse::() {
- Ok(p) => (*cat, p),
- Err(_) => return,
- },
- _ => return,
+ let parsed = match parts.as_slice() {
+ [_, "model", page] => page
+ .parse::()
+ .ok()
+ .map(|page| (ConfigCategory::Model, page)),
+ [_, "agent", page] => page
+ .parse::()
+ .ok()
+ .map(|page| (ConfigCategory::Agent, page)),
+ _ => None,
};
- // Only allow known config categories.
- if !matches!(category, "model" | "agent") {
- return;
- }
-
- let thread_key = format!("discord:{}", comp.channel_id.get());
- let config_options = self.router.pool().get_config_options(&thread_key).await;
-
- let response = match Self::build_config_components(&config_options, category, Some(page)) {
- Some(rows) => CreateInteractionResponse::UpdateMessage(
- CreateInteractionResponseMessage::new()
- .content(format!("🔧 Select a {category}:"))
- .components(rows),
- ),
- None => CreateInteractionResponse::UpdateMessage(
+ let response = if let Some((category, page)) = parsed {
+ let context = Self::shared_command_context(comp.channel_id);
+ let result = self
+ .shared_command_service()
+ .execute(CoreCommand::ListConfig(category), &context)
+ .await;
+ match &result {
+ CommandResult::ConfigOptions { options, .. } => {
+ match Self::build_config_components(options, category.as_str(), Some(page)) {
+ Some(rows) => CreateInteractionResponse::UpdateMessage(
+ CreateInteractionResponseMessage::new()
+ .content(format!("🔧 Select a {}:", category.as_str()))
+ .components(rows),
+ ),
+ None => CreateInteractionResponse::UpdateMessage(
+ CreateInteractionResponseMessage::new()
+ .content(render_text_result(&result))
+ .components(vec![]),
+ ),
+ }
+ }
+ _ => CreateInteractionResponse::UpdateMessage(
+ CreateInteractionResponseMessage::new()
+ .content(render_text_result(&result))
+ .components(vec![]),
+ ),
+ }
+ } else {
+ CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new()
- .content(format!("⚠️ No {category} options available."))
+ .content("⚠️ This configuration menu is no longer valid.")
.components(vec![]),
- ),
+ )
};
- if let Err(e) = comp.create_response(&ctx.http, response).await {
- tracing::error!(error = %e, category, "failed to respond to pagination");
+ if comp.create_response(&ctx.http, response).await.is_err() {
+ tracing::error!("failed to respond to config pagination");
}
}
}
@@ -2622,6 +2704,7 @@ fn discord_msg_ref(msg: &Message) -> MessageRef {
channel_id: msg.channel_id.get().to_string(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
},
message_id: msg.id.to_string(),
@@ -2920,6 +3003,7 @@ async fn get_or_create_thread(
channel_id: msg.channel_id.get().to_string(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
});
}
@@ -2931,6 +3015,7 @@ async fn get_or_create_thread(
channel_id: msg.channel_id.get().to_string(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
let trigger_ref = discord_msg_ref(msg);
@@ -2965,6 +3050,7 @@ async fn get_or_create_thread(
channel_id: existing.id.to_string(),
thread_id: None,
parent_id: Some(msg.channel_id.get().to_string()),
+ persistent_conversation: None,
origin_event_id: None,
})
}
@@ -2984,8 +3070,10 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool {
msg.contains("160004") || msg.contains("already been created")
}
-static ROLE_MENTION_RE: LazyLock =
- LazyLock::new(|| regex::Regex::new(r"<@&\d+>").unwrap());
+static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"<@&\d+>")
+ .unwrap_or_else(|error| panic!("invalid role mention regex: {error}"))
+});
fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet) -> String {
// 1. Strip the bot's own trigger mention
@@ -3081,6 +3169,19 @@ fn build_sender_context(
/// https://docs.discord.com/developers/resources/channel#channel-object
/// - Thread Metadata ("thread-specific fields not needed by other channels"):
/// https://docs.discord.com/developers/resources/channel#thread-metadata-object
+fn discord_command_surface_allowed(
+ is_dm: bool,
+ allow_dm: bool,
+ in_allowed_channel: bool,
+ in_allowed_thread: bool,
+) -> bool {
+ if is_dm {
+ allow_dm
+ } else {
+ in_allowed_channel || in_allowed_thread
+ }
+}
+
fn detect_thread(
has_thread_metadata: bool,
parent_id: Option,
@@ -3223,8 +3324,10 @@ fn turn_limit_warning_present(messages: &[(bool, &str)]) -> bool {
/// Auth CLIs like `codex` emit these for terminal styling, but they render as
/// garbage in Discord messages.
fn strip_ansi_codes(s: &str) -> String {
- static ANSI_RE: LazyLock =
- LazyLock::new(|| regex::Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\([A-Z]").unwrap());
+ static ANSI_RE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\([A-Z]")
+ .unwrap_or_else(|error| panic!("invalid ANSI regex: {error}"))
+ });
ANSI_RE.replace_all(s, "").into_owned()
}
@@ -3233,8 +3336,10 @@ fn strip_ansi_codes(s: &str) -> String {
/// node is adjacent to a Text node, causing `accounthttps://...` rendering.
/// This inserts a newline before any URL that immediately follows a non-whitespace char.
fn ensure_url_separation(s: &str) -> String {
- static URL_RE: LazyLock =
- LazyLock::new(|| regex::Regex::new(r"(?P\S)(?Phttps?://)").unwrap());
+ static URL_RE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"(?P\S)(?Phttps?://)")
+ .unwrap_or_else(|error| panic!("invalid URL separation regex: {error}"))
+ });
URL_RE.replace_all(s, "${prev}\n${url}").into_owned()
}
@@ -3304,6 +3409,33 @@ mod tests {
assert!(out.ends_with('…'));
}
+ #[test]
+ fn config_components_keep_current_value_on_initial_page() {
+ let current_value = "value-29";
+ let options = vec![ConfigOption {
+ id: "model".into(),
+ name: "Model".into(),
+ description: None,
+ category: Some("model".into()),
+ option_type: "enum".into(),
+ current_value: current_value.into(),
+ options: (0..30)
+ .map(|index| crate::acp::protocol::ConfigOptionValue {
+ value: format!("value-{index}"),
+ name: format!("Model {index}"),
+ description: None,
+ })
+ .collect(),
+ }];
+ let Some(rows) = Handler::build_config_components(&options, "model", None) else {
+ panic!("model components must be available");
+ };
+ let Ok(serialized) = serde_json::to_string(&rows) else {
+ panic!("model components must serialize");
+ };
+ assert!(serialized.contains(current_value));
+ }
+
// --- format_usage_report tests (/usage slash command) ---
fn usage_breakdown() -> crate::acp::protocol::UsageBreakdown {
@@ -4204,6 +4336,7 @@ mod tests {
channel_id: "111".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
assert_eq!(DiscordAdapter::resolve_channel(&ch), "111");
@@ -4216,6 +4349,7 @@ mod tests {
channel_id: "111".into(),
thread_id: Some("222".into()),
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
};
assert_eq!(DiscordAdapter::resolve_channel(&ch), "222");
@@ -4413,6 +4547,15 @@ mod tests {
assert!(!is_denied_user(false, false, &allowed, 100));
}
+ #[test]
+ fn shared_command_scope_matches_discord_dm_channel_and_thread_policy() {
+ assert!(discord_command_surface_allowed(true, true, false, false));
+ assert!(!discord_command_surface_allowed(true, false, true, true));
+ assert!(discord_command_surface_allowed(false, false, true, false));
+ assert!(discord_command_surface_allowed(false, false, false, true));
+ assert!(!discord_command_surface_allowed(false, true, false, false));
+ }
+
/// DMs are treated as implicit @mention — should_process_user_message
/// is never called for DMs (the `!is_dm` guard skips it).
/// This test verifies the Involved mode would reject a non-thread,
diff --git a/crates/openab-core/src/dispatch.rs b/crates/openab-core/src/dispatch.rs
index 64ba68917..15ca1ed8d 100644
--- a/crates/openab-core/src/dispatch.rs
+++ b/crates/openab-core/src/dispatch.rs
@@ -18,7 +18,7 @@ use async_trait::async_trait;
use tracing::{debug, error, info, info_span, warn};
use crate::acp::ContentBlock;
-use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef};
+use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, StatusBackend};
use crate::config::ReactionsConfig;
use crate::error_display::format_user_error;
use crate::reactions::StatusReactionController;
@@ -286,19 +286,33 @@ impl Dispatcher {
/// Build the dispatcher key for a (platform, thread, sender) tuple.
///
+ /// Every segment is byte-length-prefixed because native IDs (notably Teams
+ /// conversation IDs) may contain `:`. A delimiter-only key can alias
+ /// `(thread = "a", sender = "b:c")` with `(thread = "a:b", sender = "c")`,
+ /// causing cross-thread buffering or cancellation.
+ ///
/// In `Thread` mode the sender is ignored; in `Lane` mode the sender is appended
/// so each (thread, sender) pair gets its own mpsc and consumer.
///
/// Note: this is the *dispatcher* key, not the *session pool* key. Session pool keys
- /// are always `:` regardless of grouping (the ACP session is
+ /// remain `:` regardless of grouping (the ACP session is
/// shared per-thread by design).
pub fn key(&self, platform: &str, thread_id: &str, sender_id: &str) -> String {
+ let base = Self::thread_key_prefix(platform, thread_id);
match self.grouping {
- BatchGrouping::Thread => format!("{platform}:{thread_id}"),
- BatchGrouping::Lane => format!("{platform}:{thread_id}:{sender_id}"),
+ BatchGrouping::Thread => base,
+ BatchGrouping::Lane => format!("{base}{}:{sender_id}", sender_id.len()),
}
}
+ fn thread_key_prefix(platform: &str, thread_id: &str) -> String {
+ format!(
+ "{}:{platform}{}:{thread_id}",
+ platform.len(),
+ thread_id.len()
+ )
+ }
+
/// Build the shared session pool key for a routed channel.
///
/// Unlike dispatcher keys, session keys never include sender identity.
@@ -340,7 +354,10 @@ impl Dispatcher {
let (tx, my_generation) = {
// SAFETY: no .await while this guard is held — guard drops at end of block.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
// Proactive stale-entry cleanup: if the consumer has exited (idle
// timeout or unexpected), remove the entry so `or_insert_with`
@@ -385,7 +402,10 @@ impl Dispatcher {
// retry acquisition below.
{
// SAFETY: no .await while this guard is held.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
Self::try_evict_locked(&mut map, &thread_key, my_generation);
}
let failed_msg = e.0;
@@ -395,7 +415,10 @@ impl Dispatcher {
let retry_g = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (retry_tx, retry_gen) = {
// SAFETY: no .await while this guard is held — guard drops at end of block.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
let entry = map.entry(thread_key.clone()).or_insert_with(|| {
let (tx, rx) = tokio::sync::mpsc::channel(cap);
let consumer = tokio::spawn(consumer_loop(
@@ -423,7 +446,10 @@ impl Dispatcher {
// Retry also failed — truly unexpected. Surface error.
{
// SAFETY: no .await while this guard is held.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
Self::try_evict_locked(&mut map, &thread_key, retry_gen);
}
let failed_msg = e2.0;
@@ -452,21 +478,23 @@ impl Dispatcher {
/// regardless of grouping, and abort each consumer (§2.5 / §4.4). Returns
/// the total number of buffered messages discarded across all lanes.
///
- /// Matches both Thread keys (`:`) and Lane keys
- /// (`::`). Used by `/reset` and
- /// `/cancel-all` to clear the entire thread, not just one lane.
+ /// Matches the exact length-prefixed platform/thread prefix for both
+ /// Thread and Lane grouping. Used by `/reset` and `/cancel-all` to clear
+ /// the entire thread, not just one lane.
///
/// Disjoint from SendError recovery: removal happens *before* abort, so any
/// fresh `submit` after this returns lands on a lazily-constructed new handle
/// instead of observing `SendError`.
pub fn cancel_buffered_thread(&self, platform: &str, thread_id: &str) -> usize {
- let prefix = format!("{platform}:{thread_id}");
- let lane_prefix = format!("{prefix}:");
+ let prefix = Self::thread_key_prefix(platform, thread_id);
// SAFETY: no .await while this guard is held — function is sync.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
let keys: Vec = map
.keys()
- .filter(|k| k.as_str() == prefix || k.starts_with(&lane_prefix))
+ .filter(|key| key.starts_with(&prefix))
.cloned()
.collect();
let mut dropped = 0;
@@ -504,7 +532,10 @@ impl Dispatcher {
/// receive a second `submit()`. Returns the number of entries swept.
pub fn sweep_stale(&self) -> usize {
// SAFETY: no .await while this guard is held — function is sync.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
let before = map.len();
map.retain(|_, handle| !handle.consumer.is_finished());
before - map.len()
@@ -513,7 +544,10 @@ impl Dispatcher {
/// Log buffered-message counts and drop all handles (called on SIGTERM).
pub fn shutdown(&self) {
// SAFETY: no .await while this guard is held — function is sync.
- let mut map = self.per_thread.lock().unwrap();
+ let mut map = self
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
for (thread_id, handle) in map.iter() {
let pending = handle.pending_count();
if pending > 0 {
@@ -594,8 +628,11 @@ async fn consumer_loop(
}
}
- // §2.6: read the freshest snapshot in the batch (batch is non-empty).
- let bot_present = batch.last().unwrap().other_bot_present;
+ // §2.6: read the freshest snapshot in the batch.
+ let Some(last_message) = batch.last() else {
+ continue;
+ };
+ let bot_present = last_message.other_bot_present;
dispatch_batch(
&thread_key,
@@ -625,14 +662,22 @@ async fn dispatch_batch(
let batch_size = batch.len();
let session_key = Dispatcher::session_key(thread_channel);
- // Apply 👀 reaction to every message in the batch before dispatch (§6.7).
- // Skip when assistant status API is active — uses
- // assistant.threads.setStatus instead of emoji reactions.
- let assistant_status = adapter.uses_assistant_status();
- if !assistant_status {
- let queued_emoji = &target.reactions_config().emojis.queued;
- for msg in batch.iter() {
- let _ = adapter.add_reaction(&msg.trigger_msg, queued_emoji).await;
+ let Some(trigger_msg) = batch.last().map(|msg| msg.trigger_msg.clone()) else {
+ return;
+ };
+ let reactions_config = target.reactions_config().clone();
+
+ // Apply a permanent 👀 receipt marker to every event in the batch, as
+ // required by turn-boundary-batching ADR §6.7. The progress controller
+ // below is intentionally separate and anchors only on the final event.
+ let capabilities = adapter.capabilities(&thread_channel.platform);
+ let reaction_status = capabilities.status_backend == StatusBackend::Reactions;
+ let receipt_reactions = reactions_config.enabled && capabilities.supports_reactions;
+ if receipt_reactions {
+ for msg in &batch {
+ let _ = adapter
+ .add_reaction(&msg.trigger_msg, &reactions_config.emojis.queued)
+ .await;
}
}
@@ -648,8 +693,6 @@ async fn dispatch_batch(
// batch attributes to the most recent sender; None for non-Slack/bot turns.
let recipient: Option<(String, String)> = batch.last().and_then(|m| m.recipient.clone());
- // Anchor reactions on the last message in the batch (before consuming).
- let trigger_msg = batch.last().unwrap().trigger_msg.clone();
let dispatch_channel = ChannelRef {
// Reply correlation is event-scoped, but the dispatcher consumer is
// thread-scoped. Rebuild the per-dispatch channel from the stable
@@ -757,7 +800,6 @@ async fn dispatch_batch(
}
let packed_block_count = content_blocks.len();
- let reactions_config = target.reactions_config().clone();
let reactions = Arc::new(StatusReactionController::new(
reactions_config.enabled,
adapter.clone(),
@@ -765,7 +807,8 @@ async fn dispatch_batch(
reactions_config.emojis.clone(),
reactions_config.timing.clone(),
));
- // 👀 already applied above; skip set_queued() to avoid double-reaction.
+ // 👀 receipt markers are intentionally outside this controller and remain
+ // visible after the turn completes (turn-boundary-batching ADR §6.7).
let result = target
.stream_prompt_blocks(
@@ -779,9 +822,9 @@ async fn dispatch_batch(
)
.await;
- // In assistant status mode, all status is conveyed via
- // assistant.threads.setStatus — skip emoji reactions entirely.
- if !assistant_status {
+ // Finalize only the reactions backend; other status lifecycles are handled
+ // independently by stream_prompt_blocks or their platform adapter.
+ if reaction_status {
match &result {
Ok(()) => reactions.set_done().await,
Err(_) => reactions.set_error().await,
@@ -802,9 +845,11 @@ async fn dispatch_batch(
}
if let Err(ref e) = result {
- let _ = adapter
- .send_message(&dispatch_channel, &format!("⚠️ {e}"))
- .await;
+ if !crate::progressive::is_ambiguous_delivery(e) {
+ let _ = adapter
+ .send_message(&dispatch_channel, &format!("⚠️ {e}"))
+ .await;
+ }
}
let agent_dispatch_ms = dispatch_start.elapsed().as_millis();
@@ -1172,7 +1217,7 @@ mod tests {
map.insert("t".into(), dummy_handle(8));
assert!(!Dispatcher::try_evict_locked(&mut map, "t", 7));
assert_eq!(map.len(), 1);
- assert_eq!(map.get("t").unwrap().generation, 8);
+ assert_eq!(map.get("t").map(|handle| handle.generation), Some(8));
}
#[tokio::test]
@@ -1219,17 +1264,27 @@ mod tests {
#[tokio::test]
async fn key_per_thread_ignores_sender() {
let d = make_dispatcher(BatchGrouping::Thread);
- assert_eq!(d.key("discord", "T1", "userA"), "discord:T1");
- assert_eq!(d.key("discord", "T1", "userB"), "discord:T1");
+ assert_eq!(
+ d.key("discord", "T1", "userA"),
+ d.key("discord", "T1", "userB")
+ );
+ assert_ne!(
+ d.key("discord", "T1", "userA"),
+ d.key("slack", "T1", "userA")
+ );
}
#[tokio::test]
- async fn key_per_lane_includes_sender() {
+ async fn key_per_lane_is_collision_safe_for_native_ids() {
let d = make_dispatcher(BatchGrouping::Lane);
- assert_eq!(d.key("discord", "T1", "userA"), "discord:T1:userA");
- assert_eq!(d.key("discord", "T1", "userB"), "discord:T1:userB");
- // Different threads remain distinct.
- assert_eq!(d.key("slack", "T2", "userA"), "slack:T2:userA");
+ assert_ne!(
+ d.key("discord", "T1", "userA"),
+ d.key("discord", "T1", "userB")
+ );
+ assert_ne!(
+ d.key("teams", "19", "user:x"),
+ d.key("teams", "19:user", "x")
+ );
}
fn insert_dummy_handle(d: &Dispatcher, key: &str) {
@@ -1242,45 +1297,66 @@ mod tests {
channel_id: "c".into(),
adapter_kind: "discord".into(),
};
- d.per_thread.lock().unwrap().insert(key.to_string(), handle);
+ d.per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .insert(key.to_string(), handle);
}
#[tokio::test]
async fn cancel_buffered_thread_drops_per_thread_key() {
let d = make_dispatcher(BatchGrouping::Thread);
- insert_dummy_handle(&d, "discord:T1");
- insert_dummy_handle(&d, "discord:T2"); // different thread, must survive
- assert_eq!(d.cancel_buffered_thread("discord", "T1"), 0); // no buffered msgs
- let map = d.per_thread.lock().unwrap();
- assert!(!map.contains_key("discord:T1"));
- assert!(map.contains_key("discord:T2"));
+ let t1 = d.key("discord", "T1", "ignored");
+ let t2 = d.key("discord", "T2", "ignored");
+ insert_dummy_handle(&d, &t1);
+ insert_dummy_handle(&d, &t2);
+ assert_eq!(d.cancel_buffered_thread("discord", "T1"), 0);
+ let map = d
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ assert!(!map.contains_key(&t1));
+ assert!(map.contains_key(&t2));
}
#[tokio::test]
async fn cancel_buffered_thread_drops_all_lanes() {
let d = make_dispatcher(BatchGrouping::Lane);
- insert_dummy_handle(&d, "discord:T1:userA");
- insert_dummy_handle(&d, "discord:T1:userB");
- insert_dummy_handle(&d, "discord:T2:userA"); // different thread
- insert_dummy_handle(&d, "slack:T1:userA"); // different platform
+ let t1a = d.key("discord", "T1", "userA");
+ let t1b = d.key("discord", "T1", "userB");
+ let t2a = d.key("discord", "T2", "userA");
+ let slack = d.key("slack", "T1", "userA");
+ for key in [&t1a, &t1b, &t2a, &slack] {
+ insert_dummy_handle(&d, key);
+ }
d.cancel_buffered_thread("discord", "T1");
- let map = d.per_thread.lock().unwrap();
- assert!(!map.contains_key("discord:T1:userA"));
- assert!(!map.contains_key("discord:T1:userB"));
- assert!(map.contains_key("discord:T2:userA"));
- assert!(map.contains_key("slack:T1:userA"));
+ let map = d
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ assert!(!map.contains_key(&t1a));
+ assert!(!map.contains_key(&t1b));
+ assert!(map.contains_key(&t2a));
+ assert!(map.contains_key(&slack));
}
#[tokio::test]
- async fn cancel_buffered_thread_does_not_match_thread_id_prefix() {
- // T1 must not match T10 / T11 (substring trap).
+ async fn cancel_buffered_thread_does_not_cross_colon_or_prefix_boundaries() {
let d = make_dispatcher(BatchGrouping::Lane);
- insert_dummy_handle(&d, "discord:T1:userA");
- insert_dummy_handle(&d, "discord:T10:userA");
- d.cancel_buffered_thread("discord", "T1");
- let map = d.per_thread.lock().unwrap();
- assert!(!map.contains_key("discord:T1:userA"));
- assert!(map.contains_key("discord:T10:userA"));
+ let target = d.key("teams", "19", "user:x");
+ let colon_thread = d.key("teams", "19:user", "x");
+ let prefix_thread = d.key("teams", "190", "user:x");
+ for key in [&target, &colon_thread, &prefix_thread] {
+ insert_dummy_handle(&d, key);
+ }
+ d.cancel_buffered_thread("teams", "19");
+ let map = d
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ assert!(!map.contains_key(&target));
+ assert!(map.contains_key(&colon_thread));
+ assert!(map.contains_key(&prefix_thread));
}
// Long-running consumer that parks until aborted — used by sweep_stale /
@@ -1309,7 +1385,11 @@ mod tests {
tokio::time::sleep(Duration::from_millis(10)).await;
let swept = d.sweep_stale();
assert_eq!(swept, 2);
- assert!(d.per_thread.lock().unwrap().is_empty());
+ assert!(d
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .is_empty());
}
#[tokio::test]
@@ -1318,12 +1398,19 @@ mod tests {
let abort = {
let h = alive_consumer_handle();
let a = h.consumer.abort_handle();
- d.per_thread.lock().unwrap().insert("alive".into(), h);
+ d.per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .insert("alive".into(), h);
a
};
let swept = d.sweep_stale();
assert_eq!(swept, 0);
- assert!(d.per_thread.lock().unwrap().contains_key("alive"));
+ assert!(d
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .contains_key("alive"));
// Cleanup so the parked task doesn't linger across tests.
abort.abort();
}
@@ -1335,7 +1422,11 @@ mod tests {
insert_dummy_handle(&d, "k2");
insert_dummy_handle(&d, "k3");
d.shutdown();
- assert!(d.per_thread.lock().unwrap().is_empty());
+ assert!(d
+ .per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .is_empty());
}
#[tokio::test]
@@ -1344,7 +1435,10 @@ mod tests {
let abort = {
let h = alive_consumer_handle();
let a = h.consumer.abort_handle();
- d.per_thread.lock().unwrap().insert("k".into(), h);
+ d.per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .insert("k".into(), h);
a
};
d.shutdown();
@@ -1392,7 +1486,10 @@ mod tests {
}
fn calls(&self) -> Vec {
- self.calls.lock().unwrap().clone()
+ self.calls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone()
}
}
@@ -1415,7 +1512,12 @@ mod tests {
_session_key: &str,
_working_dir: Option<&str>,
) -> Result {
- if let Some(msg) = self.ensure_err.lock().unwrap().take() {
+ if let Some(msg) = self
+ .ensure_err
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .take()
+ {
return Err(anyhow::anyhow!(msg));
}
Ok(true)
@@ -1433,22 +1535,63 @@ mod tests {
other_bot_present: bool,
_recipient: Option<(String, String)>,
) -> Result<()> {
- self.calls.lock().unwrap().push(RecordedDispatch {
- block_count: content_blocks.len(),
- other_bot_present,
- dispatch_channel: thread_channel.clone(),
- });
- if let Some(msg) = self.stream_err.lock().unwrap().take() {
+ self.calls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .push(RecordedDispatch {
+ block_count: content_blocks.len(),
+ other_bot_present,
+ dispatch_channel: thread_channel.clone(),
+ });
+ if let Some(msg) = self
+ .stream_err
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .take()
+ {
return Err(anyhow::anyhow!(msg));
}
Ok(())
}
}
- /// Mock `ChatAdapter` — every method is a no-op success. The dispatch loop
- /// invokes `add_reaction` (queued 👀), `platform`, and on the error path
- /// `send_message`; nothing else needs real behavior here.
- struct MockChatAdapter;
+ /// Mock `ChatAdapter` — records reaction lifecycle calls and otherwise
+ /// returns success without touching a platform API.
+ struct MockChatAdapter {
+ reaction_events: Mutex>,
+ status_backend: StatusBackend,
+ supports_reactions: bool,
+ }
+
+ impl Default for MockChatAdapter {
+ fn default() -> Self {
+ Self {
+ reaction_events: Mutex::new(Vec::new()),
+ status_backend: StatusBackend::Reactions,
+ supports_reactions: true,
+ }
+ }
+ }
+
+ impl MockChatAdapter {
+ fn message_status_with_receipts() -> Self {
+ Self {
+ status_backend: StatusBackend::Message,
+ supports_reactions: true,
+ ..Self::default()
+ }
+ }
+
+ fn reaction_events_mut(&self) -> std::sync::MutexGuard<'_, Vec<(String, String, String)>> {
+ self.reaction_events
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ fn reaction_events(&self) -> Vec<(String, String, String)> {
+ self.reaction_events_mut().clone()
+ }
+ }
#[async_trait]
impl ChatAdapter for MockChatAdapter {
@@ -1459,6 +1602,14 @@ mod tests {
2000
}
+ fn capabilities(&self, _platform: &str) -> crate::adapter::AdapterCapabilities {
+ crate::adapter::AdapterCapabilities {
+ supports_reactions: self.supports_reactions,
+ status_backend: self.status_backend,
+ ..crate::adapter::AdapterCapabilities::default()
+ }
+ }
+
async fn send_message(&self, channel: &ChannelRef, _content: &str) -> Result {
Ok(MessageRef {
channel: channel.clone(),
@@ -1475,10 +1626,17 @@ mod tests {
Ok(channel.clone())
}
- async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> {
+ async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> {
+ self.reaction_events_mut()
+ .push(("add".into(), msg.message_id.clone(), emoji.into()));
Ok(())
}
- async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> {
+ async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> {
+ self.reaction_events_mut().push((
+ "remove".into(),
+ msg.message_id.clone(),
+ emoji.into(),
+ ));
Ok(())
}
fn use_streaming(&self, _other_bot_present: bool) -> bool {
@@ -1492,6 +1650,7 @@ mod tests {
channel_id: thread.into(),
thread_id: Some(thread.into()),
parent_id: None,
+ persistent_conversation: None,
origin_event_id: None,
}
}
@@ -1523,10 +1682,10 @@ mod tests {
) -> Vec {
let mock = Arc::new(MockDispatchTarget::new());
let target: Arc = mock.clone();
- let adapter: Arc = Arc::new(MockChatAdapter);
+ let adapter: Arc = Arc::new(MockChatAdapter::default());
let (tx, rx) = tokio::sync::mpsc::channel::(msgs.len().max(1));
for m in msgs {
- tx.send(m).await.unwrap();
+ assert!(tx.send(m).await.is_ok());
}
drop(tx);
@@ -1545,6 +1704,64 @@ mod tests {
mock.calls()
}
+ #[tokio::test]
+ async fn dispatch_preserves_batch_receipts_and_anchors_progress_on_last_event() {
+ let mock = Arc::new(MockDispatchTarget::new());
+ let target: Arc = mock;
+ let recording = Arc::new(MockChatAdapter::default());
+ let adapter: Arc = recording.clone();
+
+ dispatch_batch(
+ "mock:T",
+ &make_channel("T"),
+ &target,
+ &adapter,
+ vec![make_msg("first", 10), make_msg("last", 10)],
+ false,
+ )
+ .await;
+
+ let events = recording.reaction_events();
+ assert_eq!(events.len(), 4, "unexpected reaction lifecycle: {events:?}");
+ assert_eq!(events[0], ("add".into(), "m-first".into(), "👀".into()));
+ assert_eq!(events[1], ("add".into(), "m-last".into(), "👀".into()));
+ assert_eq!(events[2], ("add".into(), "m-last".into(), "🆗".into()));
+ assert_eq!(events[3].0, "add");
+ assert_eq!(events[3].1, "m-last");
+ assert!(
+ events
+ .iter()
+ .all(|(operation, _, emoji)| operation != "remove" || emoji != "👀"),
+ "batch receipt markers must remain visible after dispatch: {events:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn message_progress_backend_keeps_all_receipts_without_reaction_progress() {
+ let mock = Arc::new(MockDispatchTarget::new());
+ let target: Arc = mock;
+ let recording = Arc::new(MockChatAdapter::message_status_with_receipts());
+ let adapter: Arc = recording.clone();
+
+ dispatch_batch(
+ "mock:T",
+ &make_channel("T"),
+ &target,
+ &adapter,
+ vec![make_msg("first", 10), make_msg("last", 10)],
+ false,
+ )
+ .await;
+
+ assert_eq!(
+ recording.reaction_events(),
+ vec![
+ ("add".into(), "m-first".into(), "👀".into()),
+ ("add".into(), "m-last".into(), "👀".into()),
+ ]
+ );
+ }
+
#[tokio::test]
async fn consumer_dispatches_single_message_as_one_batch() {
let calls = run_consumer_with_messages(vec![make_msg("hi", 10)], 10, 24_000).await;
@@ -1600,7 +1817,7 @@ mod tests {
async fn consumer_dispatch_preserves_thread_route_while_refreshing_origin_event_id() {
let mock = Arc::new(MockDispatchTarget::new());
let target: Arc = mock.clone();
- let adapter: Arc = Arc::new(MockChatAdapter);
+ let adapter: Arc = Arc::new(MockChatAdapter::default());
let (tx, rx) = tokio::sync::mpsc::channel::(1);
let mut msg = make_msg("hi", 10);
@@ -1609,9 +1826,10 @@ mod tests {
channel_id: "parent-channel".into(),
thread_id: None,
parent_id: None,
+ persistent_conversation: None,
origin_event_id: Some("evt-fresh".into()),
};
- tx.send(msg).await.unwrap();
+ assert!(tx.send(msg).await.is_ok());
drop(tx);
consumer_loop(
@@ -1621,6 +1839,7 @@ mod tests {
channel_id: "topic-42".into(),
thread_id: Some("topic-42".into()),
parent_id: Some("parent-channel".into()),
+ persistent_conversation: None,
origin_event_id: Some("evt-stale".into()),
},
rx,
@@ -1656,7 +1875,7 @@ mod tests {
// "all senders dropped" branch.
let mock = Arc::new(MockDispatchTarget::new());
let target: Arc = mock.clone();
- let adapter: Arc = Arc::new(MockChatAdapter);
+ let adapter: Arc = Arc::new(MockChatAdapter::default());
let (tx, rx) = tokio::sync::mpsc::channel::(1);
let consumer = tokio::spawn(consumer_loop(
"mock:T".into(),
@@ -1694,7 +1913,7 @@ mod tests {
BatchGrouping::Thread,
DEFAULT_CONSUMER_IDLE_TIMEOUT,
);
- let adapter: Arc = Arc::new(MockChatAdapter);
+ let adapter: Arc = Arc::new(MockChatAdapter::default());
let key = "mock:T".to_string();
let parked = {
@@ -1709,7 +1928,10 @@ mod tests {
channel_id: "T".into(),
adapter_kind: "mock".into(),
};
- d.per_thread.lock().unwrap().insert(key.clone(), handle);
+ d.per_thread
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .insert(key.clone(), handle);
abort
};
diff --git a/crates/openab-core/src/format.rs b/crates/openab-core/src/format.rs
index 4fa1ce9e1..88a359077 100644
--- a/crates/openab-core/src/format.rs
+++ b/crates/openab-core/src/format.rs
@@ -1,209 +1,322 @@
+use std::fmt;
use unicode_segmentation::UnicodeSegmentation;
-/// Byte index after at most `max_chars` (>=1) Unicode scalar values — a last-resort
-/// split used ONLY when a single grapheme cluster is itself wider than the target width.
-/// It splits inside the cluster by codepoint (unavoidable: a cluster wider than the
-/// whole limit cannot be both kept intact and fit) so every emitted chunk still honors
-/// the caller's hard char limit. Guarantees forward progress (>=1 char).
-fn codepoint_split_point(s: &str, max_chars: usize) -> usize {
- s.char_indices()
- .nth(max_chars.max(1))
- .map_or(s.len(), |(i, _)| i)
+/// Internal measurement used by the final-content splitter. Wire capabilities
+/// map to this type without collapsing byte-based limits into character counts.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum TextBudget {
+ Characters(usize),
+ Bytes(usize),
+ Utf16Bytes(usize),
+ Unlimited,
}
-/// Byte index at which to cut `s` so the prefix is at most `max_chars` Unicode scalar
-/// values **without splitting a grapheme cluster** (emoji, ZWJ sequences, regional-
-/// indicator flags, VS16, combining marks all stay whole). When `word_wrap` and the cut
-/// would land mid-word, it backtracks to just after the last whitespace in the prefix so
-/// words / CJK runs are not broken mid-token. Returns `0` when not even the first
-/// grapheme fits in `max_chars` (the caller decides whether to flush or force it).
-fn split_point(s: &str, max_chars: usize, word_wrap: bool) -> usize {
- let mut chars = 0usize;
+impl TextBudget {
+ fn max(self) -> Option {
+ match self {
+ Self::Characters(max) | Self::Bytes(max) | Self::Utf16Bytes(max) => Some(max),
+ Self::Unlimited => None,
+ }
+ }
+
+ pub(crate) fn measure(self, value: &str) -> usize {
+ match self {
+ Self::Characters(_) => value.chars().count(),
+ Self::Bytes(_) => value.len(),
+ Self::Utf16Bytes(_) => value.encode_utf16().count().saturating_mul(2),
+ Self::Unlimited => 0,
+ }
+ }
+
+ fn scalar_cost(self, value: char) -> usize {
+ match self {
+ Self::Characters(_) => 1,
+ Self::Bytes(_) => value.len_utf8(),
+ Self::Utf16Bytes(_) => value.len_utf16().saturating_mul(2),
+ Self::Unlimited => 0,
+ }
+ }
+
+ fn unit(self) -> &'static str {
+ match self {
+ Self::Characters(_) => "characters",
+ Self::Bytes(_) => "bytes",
+ Self::Utf16Bytes(_) => "UTF-16 bytes",
+ Self::Unlimited => "unlimited",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct SplitMessageError {
+ unit: &'static str,
+ max: usize,
+ required: usize,
+}
+
+impl SplitMessageError {
+ fn new(budget: TextBudget, max: usize, required: usize) -> Self {
+ Self {
+ unit: budget.unit(),
+ max,
+ required,
+ }
+ }
+}
+
+impl fmt::Display for SplitMessageError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ formatter,
+ "message cannot be split within a {} budget of {} (smallest required unit costs {})",
+ self.unit, self.max, self.required
+ )
+ }
+}
+
+impl std::error::Error for SplitMessageError {}
+
+/// Last-resort scalar-boundary split used only when one extended grapheme is
+/// wider than the whole budget. Returns an error when even one Unicode scalar
+/// cannot fit, because emitting invalid UTF-8 or an oversized chunk is unsafe.
+fn scalar_split_point(
+ value: &str,
+ max: usize,
+ budget: TextBudget,
+) -> Result {
+ let mut used = 0usize;
let mut byte = 0usize;
- let mut last_ws_byte = 0usize; // byte index just past the last whitespace grapheme
- for (start, g) in s.grapheme_indices(true) {
- let g_chars = g.chars().count();
- if chars + g_chars > max_chars {
+ let mut first_cost = 0usize;
+ for (start, scalar) in value.char_indices() {
+ let cost = budget.scalar_cost(scalar);
+ if first_cost == 0 {
+ first_cost = cost;
+ }
+ if used.saturating_add(cost) > max {
break;
}
- chars += g_chars;
- byte = start + g.len();
- if g.chars().all(char::is_whitespace) {
+ used += cost;
+ byte = start + scalar.len_utf8();
+ }
+ if byte == 0 && !value.is_empty() {
+ Err(SplitMessageError::new(budget, max, first_cost))
+ } else {
+ Ok(byte)
+ }
+}
+
+/// Byte index at which to cut `value` without splitting an extended grapheme.
+/// When `word_wrap` is true, prefer the last whitespace boundary in the fitting
+/// prefix. Returns zero when the first grapheme does not fit.
+fn split_point(value: &str, max: usize, word_wrap: bool, budget: TextBudget) -> usize {
+ let mut used = 0usize;
+ let mut byte = 0usize;
+ let mut last_ws_byte = 0usize;
+ for (start, grapheme) in value.grapheme_indices(true) {
+ let cost = budget.measure(grapheme);
+ if used.saturating_add(cost) > max {
+ break;
+ }
+ used += cost;
+ byte = start + grapheme.len();
+ if grapheme.chars().all(char::is_whitespace) {
last_ws_byte = byte;
}
}
- if word_wrap && byte < s.len() && last_ws_byte > 0 {
+ if word_wrap && byte < value.len() && last_ws_byte > 0 {
return last_ws_byte;
}
byte
}
-/// Split text into chunks at line boundaries, each <= limit Unicode characters (UTF-8 safe).
-/// Discord's message limit counts Unicode characters, not bytes.
-///
-/// Fenced code blocks (``` ... ```) are handled specially: if a split falls inside a
-/// code block, the current chunk is closed with ``` and the next chunk is reopened with
-/// the original opener (preserving language tag), so each chunk renders correctly.
-///
-/// Hard-splitting an over-long line breaks on **grapheme cluster** boundaries (never
-/// mid-emoji / ZWJ sequence / combining mark / CJK codepoint); outside code fences it
-/// also prefers whitespace boundaries so words stay intact.
-///
-/// Invariant: every returned chunk satisfies `chunk.chars().count() <= limit`. A single
-/// grapheme cluster wider than `limit` is split by codepoint as a last resort so the
-/// limit still holds (such a cluster cannot be kept intact and also fit).
+/// Compatibility wrapper for callers whose platform limit is measured in
+/// Unicode scalar values. A zero legacy limit is clamped to one so malformed
+/// configuration cannot create an infinite loop.
pub fn split_message(text: &str, limit: usize) -> Vec {
- if text.chars().count() <= limit {
- return vec![text.to_string()];
+ match split_message_with_budget(text, TextBudget::Characters(limit.max(1))) {
+ Ok(chunks) => chunks,
+ // A positive character budget can fit every Unicode scalar. Retain a
+ // content-preserving fallback if that internal invariant ever regresses.
+ Err(_) => text.chars().map(|value| value.to_string()).collect(),
}
+}
+/// Split final content according to an exact platform budget. Fenced code blocks
+/// are closed and reopened around splits, with those synthetic markers charged
+/// to the same budget as the content.
+pub(crate) fn split_message_with_budget(
+ text: &str,
+ budget: TextBudget,
+) -> Result, SplitMessageError> {
+ let Some(limit) = budget.max() else {
+ return Ok(vec![text.to_string()]);
+ };
+ if text.is_empty() {
+ return Ok(vec![String::new()]);
+ }
+ if limit == 0 {
+ let required = text
+ .chars()
+ .next()
+ .map_or(1, |value| budget.scalar_cost(value));
+ return Err(SplitMessageError::new(budget, limit, required));
+ }
+ if budget.measure(text) <= limit {
+ return Ok(vec![text.to_string()]);
+ }
+
+ let newline_cost = budget.measure("\n");
+ let close_marker = "\n```";
+ let close_cost = budget.measure(close_marker);
let mut chunks = Vec::new();
let mut current = String::new();
- let mut current_len: usize = 0;
- // When inside a fenced code block, holds the full opener line (e.g. "```rust").
+ let mut current_len = 0usize;
let mut fence_opener: Option = None;
- // Cost of appending "\n```" to close a fence before emitting a chunk.
- const CLOSE_COST: usize = 4; // '\n' + '`' + '`' + '`'
-
for line in text.split('\n') {
- let line_chars = line.chars().count();
+ let line_len = budget.measure(line);
let is_fence_line = line.starts_with("```");
-
- // Determine overhead that must be reserved when inside a fence.
- let close_reserve = if fence_opener.is_some() && !is_fence_line {
- CLOSE_COST
+ let opens_fence = is_fence_line && fence_opener.is_none();
+ let close_reserve = if opens_fence || (fence_opener.is_some() && !is_fence_line) {
+ close_cost
} else {
0
};
- // Check whether appending this line (+ newline separator + close reserve) overflows.
- if !current.is_empty() && current_len + 1 + line_chars + close_reserve > limit {
- // Emit current chunk, closing fence if needed.
+ if !current.is_empty()
+ && current_len
+ .saturating_add(newline_cost)
+ .saturating_add(line_len)
+ .saturating_add(close_reserve)
+ > limit
+ {
if let Some(ref opener) = fence_opener {
- if !is_fence_line {
- current.push_str("\n```");
- }
+ // Close the active block before every split, including when an
+ // unusually long original closing-fence line caused the split.
+ current.push_str(close_marker);
chunks.push(std::mem::take(&mut current));
- // Reopen fence in next chunk with full opener (preserves language tag).
current.push_str(opener);
- current_len = opener.chars().count();
+ current_len = budget.measure(opener);
if is_fence_line {
- // The closing fence marker itself triggers the split.
fence_opener = None;
current.push('\n');
- current_len += 1;
+ current_len = current_len.saturating_add(newline_cost);
current.push_str(line);
- current_len += line_chars;
+ current_len = current_len.saturating_add(line_len);
continue;
- } else if current_len + 1 + line_chars + CLOSE_COST <= limit {
- // Line fits in the reopened chunk (with room for \n + line + close marker).
+ } else if current_len
+ .saturating_add(newline_cost)
+ .saturating_add(line_len)
+ .saturating_add(close_cost)
+ <= limit
+ {
current.push('\n');
- current_len += 1;
+ current_len += newline_cost;
current.push_str(line);
- current_len += line_chars;
+ current_len += line_len;
continue;
}
- // Otherwise: line doesn't fit even in a fresh reopened chunk.
- // Fall through to the normal line-processing logic below,
- // which will hit the hard-split path if line_chars > limit,
- // or the normal append path otherwise.
} else {
chunks.push(std::mem::take(&mut current));
current_len = 0;
}
}
- // Newline separator between lines within a chunk.
if !current.is_empty() {
current.push('\n');
- current_len += 1;
+ current_len = current_len.saturating_add(newline_cost);
}
- // Track fence state.
if is_fence_line {
if fence_opener.is_some() {
fence_opener = None;
} else {
+ let required = line_len.saturating_add(close_cost);
+ if required > limit {
+ return Err(SplitMessageError::new(budget, limit, required));
+ }
fence_opener = Some(line.to_string());
}
}
- // Hard-split: single line exceeds available space.
- // This triggers when the line itself is longer than limit, OR when the
- // line doesn't fit in the current chunk even after accounting for fence
- // close overhead (e.g. after a reopen where opener already consumed space).
let effective_avail = if fence_opener.is_some() {
- limit.saturating_sub(current_len + CLOSE_COST)
+ limit.saturating_sub(current_len.saturating_add(close_cost))
} else {
limit.saturating_sub(current_len)
};
- if line_chars > effective_avail {
- let overhead = if let Some(ref opener) = fence_opener {
- // opener + '\n' at start, '\n```' at end
- opener.chars().count() + 1 + CLOSE_COST
- } else {
- 0
- };
- // If limit can't even fit overhead, fall back to unfenced hard-split.
+ if line_len > effective_avail {
+ let overhead = fence_opener.as_ref().map_or(0, |opener| {
+ budget
+ .measure(opener)
+ .saturating_add(newline_cost)
+ .saturating_add(close_cost)
+ });
let capacity = limit.saturating_sub(overhead);
- if let Some(opener) = fence_opener.as_ref().filter(|_| capacity > 0) {
- // Fenced hard-split: each mid chunk = opener\n + chars + \n```.
- // Grapheme-safe (never split an emoji / ZWJ / combining mark); no
- // word-wrap — code must not be reflowed at spaces.
- let opener_len = opener.chars().count();
- let mut rest = line;
+ if let Some(opener) = fence_opener.as_ref() {
+ if capacity == 0 {
+ let scalar_cost = line
+ .chars()
+ .next()
+ .map_or(1, |value| budget.scalar_cost(value));
+ return Err(SplitMessageError::new(
+ budget,
+ limit,
+ overhead.saturating_add(scalar_cost),
+ ));
+ }
- // Fill remaining space in current chunk first.
+ let opener_len = budget.measure(opener);
+ let mut rest = line;
let avail_first = if current_len > 0 {
- limit.saturating_sub(current_len + CLOSE_COST)
+ limit.saturating_sub(current_len.saturating_add(close_cost))
} else {
capacity
};
- let cut = split_point(rest, avail_first, false);
+ let cut = split_point(rest, avail_first, false, budget);
current.push_str(&rest[..cut]);
- current_len += rest[..cut].chars().count();
+ current_len = current_len.saturating_add(budget.measure(&rest[..cut]));
rest = &rest[cut..];
while !rest.is_empty() {
- // Close current fenced chunk.
- current.push_str("\n```");
+ current.push_str(close_marker);
chunks.push(std::mem::take(&mut current));
- // Reopen.
current.push_str(opener);
current.push('\n');
- current_len = opener_len + 1;
- let mut cut = split_point(rest, capacity, false);
+ current_len = opener_len.saturating_add(newline_cost);
+ let mut cut = split_point(rest, capacity, false, budget);
if cut == 0 {
- // grapheme wider than capacity → codepoint-split to stay <= limit
- cut = codepoint_split_point(rest, capacity);
+ cut = match scalar_split_point(rest, capacity, budget) {
+ Ok(cut) => cut,
+ Err(error) => {
+ return Err(SplitMessageError::new(
+ budget,
+ limit,
+ overhead.saturating_add(error.required),
+ ));
+ }
+ };
}
current.push_str(&rest[..cut]);
- current_len += rest[..cut].chars().count();
+ current_len = current_len.saturating_add(budget.measure(&rest[..cut]));
rest = &rest[cut..];
}
} else {
- // Plain hard-split (no fence or limit too small for fence wrapping).
- // Grapheme-safe + prefer whitespace boundaries so words / CJK / emoji
- // stay intact.
let mut rest = line;
while !rest.is_empty() {
let avail = limit.saturating_sub(current_len);
- let mut cut = split_point(rest, avail, true);
+ let mut cut = split_point(rest, avail, true, budget);
if cut == 0 {
if current.is_empty() {
- // grapheme wider than limit → codepoint-split to stay <= limit
- cut = codepoint_split_point(rest, avail);
+ cut = scalar_split_point(rest, avail, budget)?;
} else {
- // Nothing more fits in this chunk — flush and retry fresh.
chunks.push(std::mem::take(&mut current));
current_len = 0;
continue;
}
}
current.push_str(&rest[..cut]);
- current_len += rest[..cut].chars().count();
+ current_len = current_len.saturating_add(budget.measure(&rest[..cut]));
rest = &rest[cut..];
if !rest.is_empty() {
chunks.push(std::mem::take(&mut current));
@@ -213,18 +326,25 @@ pub fn split_message(text: &str, limit: usize) -> Vec {
}
} else {
current.push_str(line);
- current_len += line_chars;
+ current_len = current_len.saturating_add(line_len);
}
}
if !current.is_empty() {
- // Close any trailing open fence.
if fence_opener.is_some() {
- current.push_str("\n```");
+ current.push_str(close_marker);
}
chunks.push(current);
}
- chunks
+
+ if let Some(oversized) = chunks
+ .iter()
+ .map(|chunk| budget.measure(chunk))
+ .find(|measured| *measured > limit)
+ {
+ return Err(SplitMessageError::new(budget, limit, oversized));
+ }
+ Ok(chunks)
}
/// Shorten a prompt into a thread title: collapse GitHub URLs and cap at 40 chars.
@@ -269,6 +389,30 @@ mod tests {
}
}
+ fn assert_budget_invariant(chunks: &[String], budget: TextBudget, limit: usize) {
+ for (index, chunk) in chunks.iter().enumerate() {
+ let measured = budget.measure(chunk);
+ assert!(
+ measured <= limit,
+ "chunk {index} measures {measured}, exceeds {limit}: {chunk:?}"
+ );
+ }
+ }
+
+ fn split_for_test(text: &str, budget: TextBudget) -> Vec {
+ match split_message_with_budget(text, budget) {
+ Ok(chunks) => chunks,
+ Err(error) => panic!("expected split success: {error}"),
+ }
+ }
+
+ fn split_error_for_test(text: &str, budget: TextBudget) -> SplitMessageError {
+ match split_message_with_budget(text, budget) {
+ Ok(chunks) => panic!("expected split failure, got {} chunks", chunks.len()),
+ Err(error) => error,
+ }
+ }
+
#[test]
fn no_split_under_limit() {
let text = "hello\nworld";
@@ -360,6 +504,55 @@ mod tests {
assert_length_invariant(&chunks, 50);
}
+ #[test]
+ fn closing_fence_with_suffix_keeps_every_split_chunk_balanced() {
+ let text = "```\naaaaaa\n``` x";
+ let budget = TextBudget::Characters(15);
+ let chunks = split_for_test(text, budget);
+ assert_eq!(chunks.len(), 2);
+ assert_budget_invariant(&chunks, budget, 15);
+ assert_eq!(
+ chunks
+ .iter()
+ .map(|chunk| chunk.matches('a').count())
+ .sum::(),
+ 6
+ );
+ assert_eq!(
+ chunks
+ .iter()
+ .filter(|chunk| chunk.lines().any(|line| line == "``` x"))
+ .count(),
+ 1
+ );
+ for chunk in chunks {
+ let fences = chunk.lines().filter(|line| line.starts_with("```")).count();
+ assert!(fences.is_multiple_of(2), "unbalanced chunk: {chunk:?}");
+ }
+ }
+
+ #[test]
+ fn fence_overhead_that_cannot_fit_fails_closed() {
+ let no_content_capacity = split_error_for_test("```\nx\n```", TextBudget::Characters(8));
+ assert_eq!(no_content_capacity.max, 8);
+ assert_eq!(no_content_capacity.required, 9);
+
+ let oversized_opener = split_error_for_test("```rust\nx\n```", TextBudget::Characters(10));
+ assert_eq!(oversized_opener.max, 10);
+ assert_eq!(oversized_opener.required, 11);
+ }
+
+ #[test]
+ fn prose_splits_before_an_opener_that_needs_close_reserve() {
+ let text = "aaaaa\n```\nx\n```";
+ let budget = TextBudget::Characters(10);
+ let chunks = split_for_test(text, budget);
+ assert_eq!(chunks.len(), 2);
+ assert_budget_invariant(&chunks, budget, 10);
+ assert_eq!(chunks[0], "aaaaa");
+ assert_eq!(chunks[1], "```\nx\n```");
+ }
+
#[test]
fn multi_fence_blocks() {
let text = "text\n```python\ncode1\ncode2\n```\nmore text\n```js\ncode3\n```";
@@ -468,4 +661,116 @@ mod tests {
assert_length_invariant(&chunks, effective);
assert_eq!(chunks.concat(), text, "content lost with mention reserve");
}
+
+ #[test]
+ fn utf16_budget_counts_bmp_and_supplementary_scalars_exactly() {
+ let text = "A🙂B🙂C🙂D";
+ let budget = TextBudget::Utf16Bytes(10);
+ let chunks = split_for_test(text, budget);
+ assert_budget_invariant(&chunks, budget, 10);
+ assert_eq!(chunks.concat(), text);
+ assert_eq!(budget.measure("A"), 2);
+ assert_eq!(budget.measure("🙂"), 4);
+ assert!(chunks.len() > 1);
+ }
+
+ #[test]
+ fn utf8_byte_budget_differs_from_utf16_budget() {
+ let text = "éé🙂abc";
+ let byte_budget = TextBudget::Bytes(6);
+ let utf16_budget = TextBudget::Utf16Bytes(6);
+ let byte_chunks = split_for_test(text, byte_budget);
+ let utf16_chunks = split_for_test(text, utf16_budget);
+ assert_budget_invariant(&byte_chunks, byte_budget, 6);
+ assert_budget_invariant(&utf16_chunks, utf16_budget, 6);
+ assert_eq!(byte_chunks.concat(), text);
+ assert_eq!(utf16_chunks.concat(), text);
+ assert_ne!(byte_chunks, utf16_chunks);
+ }
+
+ #[test]
+ fn mixed_unicode_exact_budgets_preserve_content_and_bounds() {
+ let text = "A你e\u{301}🙂👨👩👧👦 Z".repeat(5);
+ let budgets = [
+ TextBudget::Characters(4),
+ TextBudget::Bytes(4),
+ TextBudget::Utf16Bytes(4),
+ ];
+ for budget in budgets {
+ let chunks = split_for_test(&text, budget);
+ let limit = budget.max().unwrap_or_default();
+ assert_budget_invariant(&chunks, budget, limit);
+ assert_eq!(chunks.concat(), text);
+ }
+ }
+
+ #[test]
+ fn teams_decimal_utf16_budget_is_exact_at_supplementary_boundary() {
+ let text = format!("{}🙂", "a".repeat(39_999));
+ let budget = TextBudget::Utf16Bytes(80_000);
+ let chunks = split_for_test(&text, budget);
+ assert_eq!(chunks.len(), 2);
+ assert_budget_invariant(&chunks, budget, 80_000);
+ assert_eq!(chunks.concat(), text);
+ assert_eq!(budget.measure(&chunks[0]), 79_998);
+ assert_eq!(budget.measure(&chunks[1]), 4);
+ }
+
+ #[test]
+ fn utf16_fenced_chunks_charge_synthetic_markers() {
+ let content = "🙂".repeat(20);
+ let text = format!("```rust\n{content}\n```");
+ let budget = TextBudget::Utf16Bytes(48);
+ let chunks = split_for_test(&text, budget);
+ assert_budget_invariant(&chunks, budget, 48);
+ assert!(chunks.len() > 1);
+ assert_eq!(
+ chunks
+ .iter()
+ .map(|chunk| chunk.matches('🙂').count())
+ .sum::(),
+ 20
+ );
+ for chunk in chunks {
+ let fences = chunk.lines().filter(|line| line.starts_with("```")).count();
+ assert!(fences.is_multiple_of(2), "unbalanced chunk: {chunk:?}");
+ }
+ }
+
+ #[test]
+ fn budget_split_keeps_graphemes_when_they_fit() {
+ let family = "👨👩👧👦";
+ let text = format!("{family} {family} {family}");
+ let one_family = TextBudget::Utf16Bytes(usize::MAX).measure(family);
+ let budget = TextBudget::Utf16Bytes(one_family + 2);
+ let chunks = split_for_test(&text, budget);
+ assert_budget_invariant(&chunks, budget, one_family + 2);
+ let flattened: Vec<&str> = chunks
+ .iter()
+ .flat_map(|chunk| chunk.graphemes(true))
+ .collect();
+ let original: Vec<&str> = text.graphemes(true).collect();
+ assert_eq!(flattened, original);
+ }
+
+ #[test]
+ fn unlimited_budget_returns_one_unchanged_chunk() {
+ let text = "```rust\nfn main() {}\n```\n🙂".repeat(100);
+ assert_eq!(split_for_test(&text, TextBudget::Unlimited), vec![text]);
+ }
+
+ #[test]
+ fn impossible_budget_fails_without_invalid_utf8_or_oversize() {
+ let utf16 = split_error_for_test("🙂", TextBudget::Utf16Bytes(2));
+ assert_eq!(utf16.max, 2);
+ assert_eq!(utf16.required, 4);
+
+ let utf8 = split_error_for_test("é", TextBudget::Bytes(1));
+ assert_eq!(utf8.max, 1);
+ assert_eq!(utf8.required, 2);
+
+ let zero = split_error_for_test("a", TextBudget::Characters(0));
+ assert_eq!(zero.max, 0);
+ assert_eq!(zero.required, 1);
+ }
}
diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs
index a3b74adbd..d9dcd3166 100644
--- a/crates/openab-core/src/gateway.rs
+++ b/crates/openab-core/src/gateway.rs
@@ -1,68 +1,153 @@
use crate::acp::ContentBlock;
-use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext};
+use crate::adapter::{
+ AdapterCapabilities, ChannelRef, ChatAdapter, MaterializedAttachment, MessageLimit, MessageRef,
+ PersistentConversationTarget, SenderContext, StatusBackend, StreamingMode, WriteFailure,
+ WriteOutcome, WriteOutcomeKind,
+};
+use crate::commands::{parse_command, render_text_result, Command, CommandContext, CommandService};
use anyhow::Result;
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
-use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
+use std::sync::{Arc, RwLock};
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use tracing::{error, info, warn};
-/// Timeout for waiting on gateway reply acknowledgement.
-const GATEWAY_REPLY_TIMEOUT_SECS: u64 = 5;
+const LEGACY_GATEWAY_REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
+const ATTACHMENT_MATERIALIZATION_RESPONSE_TIMEOUT: std::time::Duration =
+ std::time::Duration::from_secs(45);
+const GATEWAY_WS_MESSAGE_LIMIT: usize = 8 * 1024 * 1024;
-/// Platforms whose gateway adapter emits a `GatewayResponse` for `edit_message`
-/// so core can observe edit success or failure (used to gate the per-edit
-/// response-wait below).
-///
-/// Today only Feishu does, because it is the only adapter with a known
-/// per-message edit cap (errcode 230072) that requires core-side recovery, and
-/// the only one wired to ack edits.
-///
-/// NOTE: this gates the `edit_message` response-wait only. `delete_message` is
-/// unconditionally fire-and-forget (the recovery path sends fresh content
-/// regardless of the delete outcome), so it does not consult this list.
-///
-/// TECH DEBT: this is platform-identity standing in for a *capability*. The
-/// right model is a capability handshake at gateway-connect time ("does this
-/// adapter acknowledge edits?") rather than a hardcoded platform name. We
-/// accept the hardcode now because there is no handshake protocol yet; when one
-/// lands, replace this allowlist with a negotiated capability flag. Any new
-/// adapter that wires request/response for edits MUST be added here, or its
-/// edit failures stay invisible to core (silent failure mode).
-const EDIT_RESPONSE_PLATFORMS: &[&str] = &["feishu"];
-
-/// Whether `platform` acknowledges `edit_message` with a `GatewayResponse`.
-/// See `EDIT_RESPONSE_PLATFORMS`.
-fn platform_acks_writes(platform: &str) -> bool {
- EDIT_RESPONSE_PLATFORMS.contains(&platform)
+fn write_failure(outcome: WriteOutcome) -> anyhow::Error {
+ WriteFailure::new(outcome).into()
}
-/// Gateway platforms whose messaging API cannot edit a message after it is sent.
-///
-/// Cosmetic (typewriter) streaming works by posting a placeholder and then
-/// repeatedly editing it in place with the growing text. On a platform with no
-/// edit endpoint, each of those "edits" is delivered as a brand-new message
-/// instead — so the user sees the same reply posted several times, each copy
-/// longer than the last. Streaming is therefore force-disabled (send-once) for
-/// these platforms regardless of the configured `streaming` flag.
-///
-/// LINE's Messaging API only exposes reply/push (no edit), so it lives here.
-/// (The in-process unified adapter additionally hard-drops stray edit_message
-/// commands in the LINE adapter itself — see `dispatch_line_reply`.)
-///
-/// NOTE: like `EDIT_RESPONSE_PLATFORMS`, this is platform-identity standing in
-/// for a *capability*. The right long-term model is a capability handshake at
-/// gateway-connect time ("can this adapter edit messages?"); until that exists,
-/// any new gateway platform that lacks a message-edit API MUST be added here.
-const NON_EDITABLE_PLATFORMS: &[&str] = &["line", "lineworks"];
-
-/// Whether cosmetic streaming (placeholder + in-place edits) is possible on
-/// `platform`. See `NON_EDITABLE_PLATFORMS`.
-fn platform_supports_streaming(platform: &str) -> bool {
- !NON_EDITABLE_PLATFORMS.contains(&platform)
+fn unknown_write_failure(code: &str, message: impl Into) -> anyhow::Error {
+ write_failure(WriteOutcome::Unknown {
+ code: code.to_owned(),
+ message: message.into(),
+ })
+}
+
+fn command_target_fields(
+ msg: &MessageRef,
+ negotiated: bool,
+ capabilities: &AdapterCapabilities,
+) -> (String, Option) {
+ if negotiated && capabilities.supports_target_message_id {
+ (
+ msg.channel.origin_event_id.clone().unwrap_or_default(),
+ Some(msg.message_id.clone()),
+ )
+ } else {
+ // Old Gateways know only the overloaded command form where `reply_to`
+ // carries the platform message target.
+ (msg.message_id.clone(), None)
+ }
+}
+
+/// Capability fallback used only when the peer does not negotiate a hello.
+/// It preserves the pre-handshake behavior while keeping platform identity out
+/// of the write and streaming control paths themselves.
+fn legacy_gateway_capabilities(
+ platform: &str,
+ streaming: bool,
+ streaming_placeholder: bool,
+) -> AdapterCapabilities {
+ // Preserve the pre-handshake platform behavior exactly. ACP was already
+ // forced send-once by the router; LINE and LINE WORKS were the only legacy
+ // gateway platforms on the non-editable allowlist.
+ let can_edit = !matches!(platform, "line" | "lineworks" | "acp");
+ AdapterCapabilities {
+ send_ack: false,
+ edit_ack: platform == "feishu",
+ delete_ack: false,
+ supports_target_message_id: false,
+ supports_reactions: true,
+ supports_attachment_materialization: false,
+ supports_conversation_registry: false,
+ supports_persistent_conversation_send: false,
+ can_edit,
+ can_delete: platform == "feishu",
+ streaming_mode: if streaming && can_edit {
+ StreamingMode::Edit
+ } else {
+ StreamingMode::Disabled
+ },
+ show_streaming_placeholder: streaming_placeholder,
+ message_limit: if platform == "acp" {
+ MessageLimit::Unlimited
+ } else {
+ MessageLimit::Characters { max: 4096 }
+ },
+ status_backend: StatusBackend::Reactions,
+ }
+}
+
+fn teams_message_status_supported(
+ negotiated: bool,
+ capabilities: &AdapterCapabilities,
+) -> bool {
+ negotiated
+ && capabilities.send_ack
+ && capabilities.edit_ack
+ && capabilities.delete_ack
+ && capabilities.supports_target_message_id
+ && capabilities.can_edit
+ && capabilities.can_delete
+}
+
+fn normalize_reaction_support(capabilities: &mut AdapterCapabilities) {
+ capabilities.supports_reactions |=
+ capabilities.status_backend == StatusBackend::Reactions;
+}
+
+fn teams_progressive_response_supported(
+ negotiated: bool,
+ capabilities: &AdapterCapabilities,
+) -> bool {
+ negotiated
+ && capabilities.send_ack
+ && capabilities.edit_ack
+ && capabilities.delete_ack
+ && capabilities.supports_target_message_id
+ && capabilities.can_edit
+ && capabilities.can_delete
+ && capabilities.show_streaming_placeholder
+}
+
+/// Apply the same fail-closed Teams progressive-response predicate in
+/// Standalone and Unified deployment modes.
+pub fn apply_teams_progressive_capabilities(
+ available: bool,
+ enabled: bool,
+ capabilities: &mut AdapterCapabilities,
+) {
+ capabilities.streaming_mode =
+ if enabled && teams_progressive_response_supported(available, capabilities) {
+ StreamingMode::Edit
+ } else {
+ StreamingMode::Disabled
+ };
+}
+
+fn apply_teams_processing_indicator(
+ negotiated: bool,
+ enabled: bool,
+ capabilities: &mut AdapterCapabilities,
+) {
+ if !enabled {
+ return;
+ }
+ capabilities.status_backend =
+ if teams_message_status_supported(negotiated, capabilities) {
+ StatusBackend::Message
+ } else {
+ StatusBackend::None
+ };
}
/// Shared filter parameters for gateway event gating.
@@ -94,8 +179,32 @@ fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool {
tracing::info!(sender = %event.sender.id, "gateway: user not in allowed_users, skipping");
return true;
}
- // @mention gating: in groups, only respond if bot is mentioned
- let is_group = event.channel.channel_type == "group" || event.channel.channel_type == "supergroup";
+ // Teams trusts structured mention entity IDs, never display text. Personal
+ // chat needs no mention; groupChat/channel always require a recipient
+ // mention and do not gain an ambient/thread bypass.
+ if event.platform.eq_ignore_ascii_case("teams") {
+ if let Some(scope) = event.scope.as_ref() {
+ return match scope.conversation_type.as_str() {
+ "personal" => !scope.is_dm,
+ "groupChat" | "channel" if !scope.is_dm => event
+ .recipient
+ .as_ref()
+ .map(|recipient| recipient.id.as_str())
+ .filter(|id| !id.trim().is_empty())
+ .is_none_or(|recipient_id| {
+ !event
+ .mentions
+ .iter()
+ .any(|mention_id| mention_id == recipient_id)
+ }),
+ _ => true,
+ };
+ }
+ }
+
+ // Legacy/non-Teams @mention gating retains the existing group behavior.
+ let is_group =
+ event.channel.channel_type == "group" || event.channel.channel_type == "supergroup";
let in_thread = event.channel.thread_id.is_some();
if is_group && !in_thread {
if let Some(bot_name) = filter.bot_username {
@@ -121,9 +230,42 @@ struct GatewayEvent {
sender: GwSender,
content: GwContent,
#[serde(default)]
- #[allow(dead_code)]
mentions: Vec,
message_id: String,
+ #[serde(default)]
+ scope: Option,
+ #[serde(default)]
+ recipient: Option,
+ #[serde(default)]
+ mention_entities: Vec,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
+struct GwScope {
+ #[serde(default)]
+ tenant_id: Option,
+ #[serde(default)]
+ team_id: Option,
+ #[serde(default)]
+ channel_id: Option,
+ conversation_type: String,
+ trust_scope_id: String,
+ is_dm: bool,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
+struct GwRecipient {
+ id: String,
+ #[serde(default)]
+ #[allow(dead_code)]
+ name: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
+struct GwMention {
+ id: String,
+ #[serde(default)]
+ text: String,
}
#[derive(Clone, Debug, Deserialize)]
@@ -159,6 +301,8 @@ struct GwAttachment {
filename: String,
mime_type: String,
#[serde(default)]
+ reference: Option,
+ #[serde(default)]
data: String,
#[allow(dead_code)]
size: u64,
@@ -170,6 +314,257 @@ struct GwAttachment {
status: Option,
}
+/// Teams-specific L2 policy for authenticated typed Gateway scope. Identity
+/// remains in the shared trust registry and is evaluated only after this gate.
+#[derive(Clone, Debug)]
+pub struct TeamsScopePolicy {
+ typed_configured: bool,
+ allowed_teams: HashSet,
+ allowed_channels: HashSet,
+ allow_personal: bool,
+ allow_group_chats: bool,
+ legacy_allow_all_channels: bool,
+ legacy_allowed_conversations: HashSet,
+}
+
+fn typed_scope_shape_is_valid(conversation_id: &str, channel_type: &str, scope: &GwScope) -> bool {
+ let present = |value: Option<&str>| value.is_some_and(|value| !value.trim().is_empty());
+ if conversation_id.trim().is_empty()
+ || !present(scope.tenant_id.as_deref())
+ || scope.trust_scope_id.trim().is_empty()
+ || scope.conversation_type != channel_type
+ {
+ return false;
+ }
+
+ match scope.conversation_type.as_str() {
+ "personal" => scope.is_dm,
+ "groupChat" => !scope.is_dm,
+ "channel" => {
+ !scope.is_dm
+ && present(scope.team_id.as_deref())
+ && present(scope.channel_id.as_deref())
+ }
+ _ => false,
+ }
+}
+
+impl TeamsScopePolicy {
+ pub fn new(
+ typed_configured: bool,
+ allowed_teams: impl IntoIterator- ,
+ allowed_channels: impl IntoIterator
- ,
+ allow_personal: bool,
+ allow_group_chats: bool,
+ legacy_allow_all_channels: bool,
+ legacy_allowed_conversations: impl IntoIterator
- ,
+ ) -> Self {
+ Self {
+ typed_configured,
+ allowed_teams: allowed_teams.into_iter().collect(),
+ allowed_channels: allowed_channels.into_iter().collect(),
+ allow_personal,
+ allow_group_chats,
+ legacy_allow_all_channels,
+ legacy_allowed_conversations: legacy_allowed_conversations.into_iter().collect(),
+ }
+ }
+
+ pub fn uses_legacy_fallback(&self) -> bool {
+ !self.typed_configured
+ }
+
+ pub fn legacy_scope_restricted(&self) -> bool {
+ !self.legacy_allow_all_channels
+ }
+
+ fn surface_allowed(&self, conversation_id: &str, channel_type: &str, scope: &GwScope) -> bool {
+ if !typed_scope_shape_is_valid(conversation_id, channel_type, scope) {
+ return false;
+ }
+
+ if !self.typed_configured {
+ return self.legacy_allow_all_channels
+ || self.legacy_allowed_conversations.contains(conversation_id);
+ }
+
+ match scope.conversation_type.as_str() {
+ "personal" => self.allow_personal,
+ "groupChat" => self.allow_group_chats,
+ "channel" => {
+ (self.allowed_teams.is_empty() && self.allowed_channels.is_empty())
+ || scope
+ .team_id
+ .as_ref()
+ .is_some_and(|team| self.allowed_teams.contains(team))
+ || scope
+ .channel_id
+ .as_ref()
+ .is_some_and(|channel| self.allowed_channels.contains(channel))
+ }
+ _ => false,
+ }
+ }
+}
+
+impl Default for TeamsScopePolicy {
+ fn default() -> Self {
+ Self::new(
+ false,
+ Vec::::new(),
+ Vec::::new(),
+ true,
+ true,
+ true,
+ Vec::::new(),
+ )
+ }
+}
+
+fn strip_recipient_mention(event: &GatewayEvent) -> String {
+ if !event.platform.eq_ignore_ascii_case("teams") {
+ return event.content.text.clone();
+ }
+ let Some(recipient_id) = event
+ .recipient
+ .as_ref()
+ .map(|recipient| recipient.id.as_str())
+ .filter(|id| !id.trim().is_empty())
+ else {
+ return event.content.text.clone();
+ };
+
+ let mut ranges = Vec::new();
+ let mut cursor = 0;
+ for mention in &event.mention_entities {
+ if mention.text.is_empty() || cursor > event.content.text.len() {
+ continue;
+ }
+ let Some(relative_start) = event.content.text[cursor..].find(&mention.text) else {
+ continue;
+ };
+ let start = cursor + relative_start;
+ let end = start + mention.text.len();
+ cursor = end;
+ if mention.id == recipient_id {
+ ranges.push(start..end);
+ }
+ }
+
+ let mut prompt = event.content.text.clone();
+ for range in ranges.into_iter().rev() {
+ prompt.replace_range(range, "");
+ }
+ prompt.trim().to_owned()
+}
+
+fn gateway_command_context(event: &GatewayEvent) -> CommandContext {
+ let logical_thread_id = event
+ .channel
+ .thread_id
+ .as_deref()
+ .unwrap_or(&event.channel.id);
+ let response_is_private = event.platform.eq_ignore_ascii_case("teams")
+ && event.scope.as_ref().is_some_and(|scope| {
+ scope.conversation_type == "personal"
+ && scope.is_dm
+ && typed_scope_shape_is_valid(&event.channel.id, &event.channel.channel_type, scope)
+ });
+ CommandContext::new(
+ event.platform.clone(),
+ logical_thread_id.to_string(),
+ response_is_private,
+ )
+}
+
+fn spawn_gateway_command(
+ tasks: &mut tokio::task::JoinSet<()>,
+ command: Command,
+ context: CommandContext,
+ service: CommandService,
+ adapter: Arc,
+ channel: ChannelRef,
+) {
+ tasks.spawn(execute_gateway_command(
+ command, context, service, adapter, channel,
+ ));
+}
+
+fn trusted_conversation_registration_allowed(event: &GatewayEvent) -> bool {
+ event.platform.eq_ignore_ascii_case("teams")
+ && event.scope.as_ref().is_some_and(|scope| {
+ typed_scope_shape_is_valid(&event.channel.id, &event.channel.channel_type, scope)
+ })
+}
+
+async fn register_trusted_gateway_conversation(
+ adapter: Arc,
+ channel: ChannelRef,
+ typed_scope_allowed: bool,
+) {
+ if !typed_scope_allowed
+ || !channel.platform.eq_ignore_ascii_case("teams")
+ || !adapter
+ .capabilities(&channel.platform)
+ .supports_conversation_registry
+ {
+ return;
+ }
+ if adapter.register_conversation(&channel).await.is_err() {
+ warn!(
+ platform = "teams",
+ outcome = "not_completed",
+ "trusted conversation registration did not complete"
+ );
+ }
+}
+
+fn spawn_teams_gateway_event(
+ tasks: &mut tokio::task::JoinSet<()>,
+ event_json: String,
+ event_context: Arc,
+ event_order: Arc>,
+ serialize: bool,
+) {
+ tasks.spawn(async move {
+ let result = if serialize {
+ let _guard = event_order.lock().await;
+ process_gateway_event(&event_json, &event_context).await
+ } else {
+ process_gateway_event(&event_json, &event_context).await
+ };
+ if let Err(error) = result {
+ warn!(error = %error, "teams event processing failed");
+ }
+ });
+}
+
+async fn execute_gateway_command(
+ command: Command,
+ context: CommandContext,
+ service: CommandService,
+ adapter: Arc,
+ channel: ChannelRef,
+) {
+ let command_name = command.name();
+ let result = service.execute(command, &context).await;
+ let semantic_outcome = result.outcome_class();
+ let content = render_text_result(&result);
+ let write_outcome = adapter.send_message_outcome(&channel, &content).await;
+ let write_outcome = match write_outcome {
+ WriteOutcome::Delivered { .. } => "delivered",
+ WriteOutcome::Rejected { .. } => "rejected",
+ WriteOutcome::Unknown { .. } => "unknown",
+ };
+ tracing::info!(
+ platform = %context.platform,
+ command = command_name.as_str(),
+ semantic_outcome,
+ write_outcome,
+ "gateway command completed"
+ );
+}
+
#[derive(Serialize)]
struct GatewayReply {
schema: String,
@@ -186,6 +581,15 @@ struct GatewayReply {
/// the visual reply/quote UI on the platform. Falls back to plain send on failure.
#[serde(skip_serializing_if = "Option::is_none")]
quote_message_id: Option,
+ /// Platform message targeted by an edit/delete/reaction command. New peers
+ /// keep `reply_to` as origin event correlation; legacy peers receive the
+ /// command target in `reply_to` instead.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ target_message_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ attachment_ref: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ persistent_conversation: Option>,
}
#[derive(Serialize)]
@@ -211,11 +615,158 @@ struct GatewayResponse {
thread_id: Option,
message_id: Option,
error: Option,
+ #[serde(default)]
+ outcome: Option,
+ #[serde(default)]
+ error_code: Option,
+ #[serde(default)]
+ retry_after_ms: Option,
+ #[serde(default)]
+ attachment: Option,
+}
+
+impl GatewayResponse {
+ fn write_outcome(&self) -> WriteOutcome {
+ match self.outcome {
+ Some(WriteOutcomeKind::Delivered) => WriteOutcome::Delivered {
+ message_id: self.message_id.clone(),
+ },
+ Some(WriteOutcomeKind::Rejected) => WriteOutcome::Rejected {
+ code: self.error_code.clone().unwrap_or_else(|| "rejected".into()),
+ message: self
+ .error
+ .clone()
+ .unwrap_or_else(|| "gateway rejected write".into()),
+ retry_after_ms: self.retry_after_ms,
+ },
+ Some(WriteOutcomeKind::Unknown) => WriteOutcome::Unknown {
+ code: self.error_code.clone().unwrap_or_else(|| "unknown".into()),
+ message: self
+ .error
+ .clone()
+ .unwrap_or_else(|| "gateway write outcome is unknown".into()),
+ },
+ None if self.success => WriteOutcome::Delivered {
+ message_id: self.message_id.clone(),
+ },
+ None => WriteOutcome::Rejected {
+ code: "legacy_failure".into(),
+ message: self
+ .error
+ .clone()
+ .unwrap_or_else(|| "gateway reported failure".into()),
+ retry_after_ms: None,
+ },
+ }
+ }
+}
+
+const CLIENT_HELLO_SCHEMA: &str = "openab.gateway.client_hello.v1";
+const GATEWAY_HELLO_SCHEMA: &str = "openab.gateway.hello.v1";
+const GATEWAY_PROTOCOL_VERSION: u32 = 1;
+
+#[derive(Debug, Deserialize)]
+struct GatewayEnvelope {
+ schema: String,
+}
+
+#[derive(Debug, Serialize)]
+struct GatewayClientHello {
+ schema: String,
+ protocol_version: u32,
+ client_name: Option,
+ requested_platforms: Vec,
+}
+
+fn build_client_hello() -> GatewayClientHello {
+ GatewayClientHello {
+ schema: CLIENT_HELLO_SCHEMA.into(),
+ protocol_version: GATEWAY_PROTOCOL_VERSION,
+ client_name: Some(format!("openab-core/{}", env!("CARGO_PKG_VERSION"))),
+ // A standalone Gateway can publish several platforms over one socket,
+ // so Core requests the full configured capability map.
+ requested_platforms: Vec::new(),
+ }
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct GatewayHello {
+ schema: String,
+ protocol_version: u32,
+ #[serde(default)]
+ capabilities: HashMap,
+ topology: GatewayTopology,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct GatewayTopology {
+ active_consumers: usize,
+ supported: bool,
+ delivery_mode: String,
+}
+
+#[derive(Default)]
+struct GatewayCapabilityState {
+ hello: std::sync::RwLock