ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
parent
8139f83175
commit
ea15819ecf
@ -112,6 +112,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
||||||
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
||||||
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk.
|
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk.
|
||||||
|
- ACP/acpx: keep plugin-local backend installs under `extensions/acpx` in live repo checkouts so rebuilds no longer delete the runtime binary, and avoid package-lock churn during runtime repair.
|
||||||
- Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman.
|
- Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman.
|
||||||
- Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj.
|
- Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj.
|
||||||
- Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx.
|
- Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx.
|
||||||
@ -127,6 +128,8 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
|
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
|
||||||
- macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67.
|
- macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67.
|
||||||
- macOS/launch at login: stop emitting `KeepAlive` for the desktop app launch agent so OpenClaw no longer relaunches immediately after a manual quit while launch at login remains enabled. (#40213) Thanks @stablegenius49.
|
- macOS/launch at login: stop emitting `KeepAlive` for the desktop app launch agent so OpenClaw no longer relaunches immediately after a manual quit while launch at login remains enabled. (#40213) Thanks @stablegenius49.
|
||||||
|
- ACP/gateway startup: use direct Telegram and Discord startup/status helpers instead of routing probes through the plugin runtime, and prepend the selected daemon Node bin dir to service PATH so plugin-local installs can still find `npm` and `pnpm`.
|
||||||
|
- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime.
|
||||||
|
|
||||||
## 2026.3.13
|
## 2026.3.13
|
||||||
|
|
||||||
|
|||||||
519
experiments/acp-pluginification-architecture-plan.md
Normal file
519
experiments/acp-pluginification-architecture-plan.md
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
# Bindings Capability Architecture Plan
|
||||||
|
|
||||||
|
Status: in progress
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The goal is not to move all ACP code out of core.
|
||||||
|
|
||||||
|
The goal is to make `bindings` a small core capability, keep the ACP session kernel in core, and move ACP-specific binding policy plus codex app server policy out of core.
|
||||||
|
|
||||||
|
That gives us a lightweight core without hiding core semantics behind plugin indirection.
|
||||||
|
|
||||||
|
## Current Conclusion
|
||||||
|
|
||||||
|
The current architecture should converge on this split:
|
||||||
|
|
||||||
|
- Core owns the generic binding capability.
|
||||||
|
- Core owns the generic ACP session kernel.
|
||||||
|
- Channel plugins own channel-specific binding semantics.
|
||||||
|
- ACP backend plugins own runtime protocol details.
|
||||||
|
- Product-level consumers like ACP configured bindings and the codex app server sit on top of the binding capability instead of hardcoding their own binding plumbing.
|
||||||
|
|
||||||
|
This is different from "everything becomes a plugin".
|
||||||
|
|
||||||
|
## Why This Changed
|
||||||
|
|
||||||
|
The current codebase already shows that there are really three different layers:
|
||||||
|
|
||||||
|
- binding and conversation ownership
|
||||||
|
- long-lived session and runtime-handle orchestration
|
||||||
|
- product-specific turn logic
|
||||||
|
|
||||||
|
Those layers should not all be forced into one runtime engine.
|
||||||
|
|
||||||
|
Today the duplication is mostly in the execution/control-plane shape, not in storage or binding plumbing:
|
||||||
|
|
||||||
|
- the main harness has its own turn engine
|
||||||
|
- ACP has its own session control plane
|
||||||
|
- the codex app server plugin path likely owns its own app-level turn engine outside this repo
|
||||||
|
|
||||||
|
The right move is to share the stable control-plane contracts, not to force all three into one giant executor.
|
||||||
|
|
||||||
|
## Verified Current State
|
||||||
|
|
||||||
|
### Generic binding pieces already exist
|
||||||
|
|
||||||
|
- `src/infra/outbound/session-binding-service.ts` already provides a generic binding store and adapter model.
|
||||||
|
- `src/plugins/conversation-binding.ts` already lets plugins request a conversation binding and stores plugin-owned binding metadata.
|
||||||
|
- `src/plugins/types.ts` already exposes plugin-facing binding APIs.
|
||||||
|
- `src/plugins/types.ts` already exposes the generic `inbound_claim` hook.
|
||||||
|
|
||||||
|
### ACP is only partially pluginified
|
||||||
|
|
||||||
|
- `src/channels/plugins/configured-binding-registry.ts` now owns generic configured binding compilation and lookup.
|
||||||
|
- `src/channels/plugins/binding-routing.ts` and `src/channels/plugins/binding-targets.ts` now own the generic route and target lifecycle seams.
|
||||||
|
- ACP now plugs into that seam through `src/channels/plugins/acp-configured-binding-consumer.ts` and `src/channels/plugins/acp-stateful-target-driver.ts`.
|
||||||
|
- `src/acp/persistent-bindings.lifecycle.ts` still owns configured ACP ensure and reset behavior.
|
||||||
|
- runtime-created plugin conversation bindings still use a separate path in `src/plugins/conversation-binding.ts`.
|
||||||
|
|
||||||
|
### Codex app server is already closer to the desired shape
|
||||||
|
|
||||||
|
From this repo's side, the codex app server path is much thinner:
|
||||||
|
|
||||||
|
- a plugin binds a conversation
|
||||||
|
- core stores that binding
|
||||||
|
- inbound dispatch targets the plugin's `inbound_claim` hook
|
||||||
|
|
||||||
|
What core does not provide for the codex app server path is an ACP-like shared session kernel. If the app server needs retries, long-lived runtime handles, cancellation, or session health logic, it must own that itself today.
|
||||||
|
|
||||||
|
## The Durable Split
|
||||||
|
|
||||||
|
### 1. Core Binding Capability
|
||||||
|
|
||||||
|
This should become the primary shared seam.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- canonical `ConversationRef`
|
||||||
|
- binding record storage
|
||||||
|
- configured binding compilation
|
||||||
|
- runtime-created binding storage
|
||||||
|
- fast binding lookup on inbound
|
||||||
|
- binding touch/unbind lifecycle
|
||||||
|
- generic dispatch handoff to the binding target
|
||||||
|
|
||||||
|
What core binding capability must not own:
|
||||||
|
|
||||||
|
- Discord thread rules
|
||||||
|
- Telegram topic rules
|
||||||
|
- Feishu chat rules
|
||||||
|
- ACP session orchestration
|
||||||
|
- codex app server business logic
|
||||||
|
|
||||||
|
### 2. Core Stateful Target Kernel
|
||||||
|
|
||||||
|
This is the small generic kernel for long-lived bound targets.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- ensure target ready
|
||||||
|
- run turn
|
||||||
|
- cancel turn
|
||||||
|
- close target
|
||||||
|
- reset target
|
||||||
|
- status and health
|
||||||
|
- persistence of target metadata
|
||||||
|
- retries and runtime-handle safety
|
||||||
|
- per-target serialization and concurrency
|
||||||
|
|
||||||
|
ACP is the first real implementation of this shape.
|
||||||
|
|
||||||
|
This kernel should stay in core because it is mandatory infrastructure and has strict startup, reset, and recovery semantics.
|
||||||
|
|
||||||
|
### 3. Channel Binding Providers
|
||||||
|
|
||||||
|
Each channel plugin should own the meaning of "this channel conversation maps to this binding rule".
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- normalize configured binding targets
|
||||||
|
- normalize inbound conversations
|
||||||
|
- match inbound conversations against compiled bindings
|
||||||
|
- define channel-specific matching priority
|
||||||
|
- optionally provide binding description text for status and logs
|
||||||
|
|
||||||
|
This is where Discord channel vs thread logic, Telegram topic rules, and Feishu conversation rules belong.
|
||||||
|
|
||||||
|
### 4. Product Consumers
|
||||||
|
|
||||||
|
Bindings are a shared capability. Different products should consume it differently.
|
||||||
|
|
||||||
|
ACP configured bindings:
|
||||||
|
|
||||||
|
- compile config rules
|
||||||
|
- resolve a target session
|
||||||
|
- ensure the ACP session is ready through the ACP kernel
|
||||||
|
|
||||||
|
Codex app server:
|
||||||
|
|
||||||
|
- create runtime-requested bindings
|
||||||
|
- claim inbound messages through plugin hooks
|
||||||
|
- optionally adopt the shared stateful target contract later if it really needs long-lived session orchestration
|
||||||
|
|
||||||
|
Main harness:
|
||||||
|
|
||||||
|
- does not need to become "a binding product"
|
||||||
|
- may eventually share small lifecycle contracts, but it should not be forced into the same engine as ACP
|
||||||
|
|
||||||
|
## The Key Architectural Decision
|
||||||
|
|
||||||
|
The shared abstraction should be:
|
||||||
|
|
||||||
|
- `bindings` as the capability
|
||||||
|
- `stateful target drivers` as an optional lower-level contract
|
||||||
|
|
||||||
|
The shared abstraction should not be:
|
||||||
|
|
||||||
|
- "one runtime engine for main harness, ACP, and codex app server"
|
||||||
|
|
||||||
|
That would overfit very different systems into one executor.
|
||||||
|
|
||||||
|
## Stable Nouns
|
||||||
|
|
||||||
|
Core should understand only stable nouns.
|
||||||
|
|
||||||
|
The stable nouns are:
|
||||||
|
|
||||||
|
- `ConversationRef`
|
||||||
|
- `BindingRule`
|
||||||
|
- `CompiledBinding`
|
||||||
|
- `BindingResolution`
|
||||||
|
- `BindingTargetDescriptor`
|
||||||
|
- `StatefulTargetDriver`
|
||||||
|
- `StatefulTargetHandle`
|
||||||
|
|
||||||
|
ACP, codex app server, and future products should compile down to those nouns instead of leaking product-specific routing rules through core.
|
||||||
|
|
||||||
|
## Proposed Capability Model
|
||||||
|
|
||||||
|
### Binding capability
|
||||||
|
|
||||||
|
The binding capability should support both configured bindings and runtime-created bindings.
|
||||||
|
|
||||||
|
Required operations:
|
||||||
|
|
||||||
|
- compile configured bindings at startup or reload
|
||||||
|
- resolve a binding from an inbound `ConversationRef`
|
||||||
|
- create a runtime binding
|
||||||
|
- touch and unbind an existing binding
|
||||||
|
- dispatch a resolved binding to its target
|
||||||
|
|
||||||
|
### Binding target descriptor
|
||||||
|
|
||||||
|
A resolved binding should point to a typed target descriptor rather than ad hoc ACP- or plugin-specific metadata blobs.
|
||||||
|
|
||||||
|
The descriptor should be able to represent at least:
|
||||||
|
|
||||||
|
- plugin-owned inbound claim targets
|
||||||
|
- stateful target drivers
|
||||||
|
|
||||||
|
That means the same binding capability can support both:
|
||||||
|
|
||||||
|
- codex app server plugin-bound conversations
|
||||||
|
- ACP configured bindings
|
||||||
|
|
||||||
|
without pretending they are the same product.
|
||||||
|
|
||||||
|
### Stateful target driver
|
||||||
|
|
||||||
|
This is the reusable control-plane contract for long-lived bound targets.
|
||||||
|
|
||||||
|
Required operations:
|
||||||
|
|
||||||
|
- `ensureReady`
|
||||||
|
- `runTurn`
|
||||||
|
- `cancel`
|
||||||
|
- `close`
|
||||||
|
- `reset`
|
||||||
|
- `status`
|
||||||
|
- `health`
|
||||||
|
|
||||||
|
ACP should remain the first built-in driver.
|
||||||
|
|
||||||
|
If the codex app server later proves that it also needs durable session handles, it can either:
|
||||||
|
|
||||||
|
- use a driver that consumes this contract, or
|
||||||
|
- keep its own product-owned runtime if that remains simpler
|
||||||
|
|
||||||
|
That should be a product decision, not something forced by the binding capability.
|
||||||
|
|
||||||
|
## Why ACP Kernel Stays In Core
|
||||||
|
|
||||||
|
ACP's kernel should remain in core because session lifecycle, persistence, retries, cancellation, and runtime-handle safety are generic platform machinery.
|
||||||
|
|
||||||
|
Those concerns are not channel-specific, and they are not codex-app-server-specific.
|
||||||
|
|
||||||
|
If we move that machinery into an ordinary plugin, we create circular bootstrapping:
|
||||||
|
|
||||||
|
- channels need it during startup and inbound routing
|
||||||
|
- reset and recovery need it when plugins may already be degraded
|
||||||
|
- failure semantics become special-case core logic anyway
|
||||||
|
|
||||||
|
If we later wrap it in a "built-in capability module", that is still effectively core.
|
||||||
|
|
||||||
|
## What Should Move Out Of Core
|
||||||
|
|
||||||
|
The following should move out of ACP-shaped core code:
|
||||||
|
|
||||||
|
- channel-specific configured binding matching
|
||||||
|
- channel-specific binding target normalization
|
||||||
|
- channel-specific recovery UX
|
||||||
|
- ACP-specific route wrapping helpers as named ACP seams
|
||||||
|
- codex app server fallback policy beyond generic plugin-bound dispatch behavior
|
||||||
|
|
||||||
|
The following should stay:
|
||||||
|
|
||||||
|
- generic binding storage and dispatch
|
||||||
|
- generic ACP control plane
|
||||||
|
- generic stateful target driver contract
|
||||||
|
|
||||||
|
## Current Problems To Remove
|
||||||
|
|
||||||
|
### Residual cleanup is now small
|
||||||
|
|
||||||
|
Most ACP-era compatibility names are gone from the generic seam.
|
||||||
|
|
||||||
|
The remaining cleanup is smaller:
|
||||||
|
|
||||||
|
- `src/acp/persistent-bindings.ts` compatibility barrel can be deleted once tests stop importing it
|
||||||
|
- ACP-named tests and mocks can be renamed over time for consistency
|
||||||
|
- docs should stop describing already-removed ACP wrappers as if they still exist
|
||||||
|
|
||||||
|
### Configured binding implementation is still too monolithic
|
||||||
|
|
||||||
|
`src/channels/plugins/configured-binding-registry.ts` still mixes:
|
||||||
|
|
||||||
|
- registry compilation
|
||||||
|
- cache invalidation
|
||||||
|
- inbound matching
|
||||||
|
- materialization of binding targets
|
||||||
|
- session-key reverse lookup
|
||||||
|
|
||||||
|
That file is now generic, but still too large and too coupled.
|
||||||
|
|
||||||
|
### Runtime-created plugin bindings still use a separate stack
|
||||||
|
|
||||||
|
`src/plugins/conversation-binding.ts` is still a separate implementation path for plugin-created bindings.
|
||||||
|
|
||||||
|
That means configured bindings and runtime-created bindings share storage, but not one consistent capability layer.
|
||||||
|
|
||||||
|
### Generic registries still hardcode ACP as a built-in
|
||||||
|
|
||||||
|
`src/channels/plugins/configured-binding-consumers.ts` and `src/channels/plugins/stateful-target-drivers.ts` still import ACP directly.
|
||||||
|
|
||||||
|
That is acceptable for now, but the clean final shape is to keep ACP built in while registering it from a dedicated bootstrap point instead of wiring it inside the generic registry files.
|
||||||
|
|
||||||
|
## Target Contracts
|
||||||
|
|
||||||
|
### Channel binding provider contract
|
||||||
|
|
||||||
|
Conceptually, each channel plugin should support:
|
||||||
|
|
||||||
|
- `compileConfiguredBinding(binding, cfg) -> CompiledBinding | null`
|
||||||
|
- `resolveInboundConversation(event) -> ConversationRef | null`
|
||||||
|
- `matchInboundConversation(compiledBinding, conversation) -> BindingMatch | null`
|
||||||
|
- `describeBinding(compiledBinding) -> string | undefined`
|
||||||
|
|
||||||
|
### Binding capability contract
|
||||||
|
|
||||||
|
Core should support:
|
||||||
|
|
||||||
|
- `compileConfiguredBindings(cfg, plugins) -> CompiledBindingRegistry`
|
||||||
|
- `resolveBinding(conversationRef) -> BindingResolution | null`
|
||||||
|
- `createRuntimeBinding(target, conversationRef, metadata) -> BindingRecord`
|
||||||
|
- `touchBinding(bindingId)`
|
||||||
|
- `unbindBinding(bindingId | target)`
|
||||||
|
- `dispatchResolvedBinding(bindingResolution, inboundEvent)`
|
||||||
|
|
||||||
|
### Stateful target driver contract
|
||||||
|
|
||||||
|
Core should support:
|
||||||
|
|
||||||
|
- `ensureReady(targetRef, cfg)`
|
||||||
|
- `runTurn(targetRef, input)`
|
||||||
|
- `cancel(targetRef, reason)`
|
||||||
|
- `close(targetRef, reason)`
|
||||||
|
- `reset(targetRef, reason)`
|
||||||
|
- `status(targetRef)`
|
||||||
|
- `health(targetRef)`
|
||||||
|
|
||||||
|
## File-Level Transition Plan
|
||||||
|
|
||||||
|
### Keep
|
||||||
|
|
||||||
|
- `src/infra/outbound/session-binding-service.ts`
|
||||||
|
- `src/acp/control-plane/*`
|
||||||
|
- `extensions/acpx/*`
|
||||||
|
|
||||||
|
### Generalize
|
||||||
|
|
||||||
|
- `src/plugins/conversation-binding.ts`
|
||||||
|
- fold runtime-created plugin bindings into the same generic binding capability instead of keeping a separate implementation stack
|
||||||
|
- `src/channels/plugins/configured-binding-registry.ts`
|
||||||
|
- split into compiler, matcher, and session-key resolution modules with a thin facade
|
||||||
|
- `src/channels/plugins/types.adapters.ts`
|
||||||
|
- finish removing ACP-era aliases after the deprecation window
|
||||||
|
- `src/plugin-sdk/conversation-runtime.ts`
|
||||||
|
- export only the generic binding capability surfaces
|
||||||
|
- `src/acp/persistent-bindings.lifecycle.ts`
|
||||||
|
- either become a generic stateful target driver consumer or be renamed to ACP driver-specific lifecycle code
|
||||||
|
|
||||||
|
### Shrink Or Delete
|
||||||
|
|
||||||
|
- `src/acp/persistent-bindings.ts`
|
||||||
|
- delete the compatibility barrel once tests import the real modules directly
|
||||||
|
- `src/acp/persistent-bindings.resolve.ts`
|
||||||
|
- keep only while ACP-specific compatibility helpers are still useful to internal callers
|
||||||
|
- ACP-named test files
|
||||||
|
- rename over time once the behavior is stable and there is no risk of mixing behavioral and naming churn
|
||||||
|
|
||||||
|
## Recommended Refactor Order
|
||||||
|
|
||||||
|
### Completed groundwork
|
||||||
|
|
||||||
|
The current branch has already completed most of the first migration wave:
|
||||||
|
|
||||||
|
- stable generic binding nouns exist
|
||||||
|
- configured bindings compile through a generic registry
|
||||||
|
- inbound routing goes through generic binding resolution
|
||||||
|
- configured binding lookup no longer performs fallback plugin discovery
|
||||||
|
- ACP is expressed as a configured-binding consumer plus a built-in stateful target driver
|
||||||
|
|
||||||
|
The remaining work is cleanup and unification, not first-principles redesign.
|
||||||
|
|
||||||
|
### Phase 1: Freeze the nouns
|
||||||
|
|
||||||
|
Introduce and document the stable binding and target types:
|
||||||
|
|
||||||
|
- `ConversationRef`
|
||||||
|
- `CompiledBinding`
|
||||||
|
- `BindingResolution`
|
||||||
|
- `BindingTargetDescriptor`
|
||||||
|
- `StatefulTargetDriver`
|
||||||
|
|
||||||
|
Do this before more movement so the rest of the refactor has firm vocabulary.
|
||||||
|
|
||||||
|
### Phase 2: Promote bindings to a first-class core capability
|
||||||
|
|
||||||
|
Refactor the existing generic binding store into an explicit capability layer.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- runtime-created bindings stay supported
|
||||||
|
- configured bindings become first-class
|
||||||
|
- lookup becomes channel-agnostic
|
||||||
|
|
||||||
|
### Phase 3: Compile configured bindings at startup and reload
|
||||||
|
|
||||||
|
Move configured binding compilation off the inbound hot path.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- load enabled channel plugins once
|
||||||
|
- compile configured bindings once
|
||||||
|
- rebuild on config or plugin reload
|
||||||
|
- inbound path becomes pure registry lookup
|
||||||
|
|
||||||
|
### Phase 4: Expand the channel provider seam
|
||||||
|
|
||||||
|
Replace the ACP-specific adapter shape with a generic channel binding provider contract.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- channel plugins own normalization and matching
|
||||||
|
- core no longer knows channel-specific configured binding rules
|
||||||
|
|
||||||
|
### Phase 5: Re-express ACP as a binding consumer plus built-in stateful target driver
|
||||||
|
|
||||||
|
Move ACP configured binding policy to the new binding capability while keeping ACP runtime orchestration in core.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- ACP configured bindings resolve through the generic binding registry
|
||||||
|
- ACP target readiness uses the ACP driver contract
|
||||||
|
- ACP-specific naming disappears from generic binding code
|
||||||
|
|
||||||
|
### Phase 6: Finish residual ACP cleanup
|
||||||
|
|
||||||
|
Remove the last compatibility leftovers and stale naming.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- delete `src/acp/persistent-bindings.ts`
|
||||||
|
- rename ACP-named tests where that improves clarity without changing behavior
|
||||||
|
- keep docs synchronized with the actual generic seam instead of the earlier transition state
|
||||||
|
|
||||||
|
### Phase 7: Split the configured binding registry by responsibility
|
||||||
|
|
||||||
|
Refactor `src/channels/plugins/configured-binding-registry.ts` into smaller modules.
|
||||||
|
|
||||||
|
Suggested split:
|
||||||
|
|
||||||
|
- compiler module
|
||||||
|
- inbound matcher module
|
||||||
|
- session-key reverse lookup module
|
||||||
|
- thin public facade
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- caching behavior remains unchanged
|
||||||
|
- matching behavior remains unchanged
|
||||||
|
- session-key resolution behavior remains unchanged
|
||||||
|
|
||||||
|
### Phase 8: Keep codex app server on the same binding capability
|
||||||
|
|
||||||
|
Do not force the codex app server into ACP semantics.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- codex app server keeps runtime-created bindings through the same binding capability
|
||||||
|
- inbound claim remains the default delivery path
|
||||||
|
- only adopt the stateful target driver seam if the app server truly needs long-lived target orchestration
|
||||||
|
- `src/plugins/conversation-binding.ts` stops being a separate binding stack and becomes a consumer of the generic binding capability
|
||||||
|
|
||||||
|
### Phase 9: Decouple built-in ACP registration from generic registry files
|
||||||
|
|
||||||
|
Keep ACP built in, but stop importing it directly from the generic registry modules.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- `src/channels/plugins/configured-binding-consumers.ts` no longer hardcodes ACP imports
|
||||||
|
- `src/channels/plugins/stateful-target-drivers.ts` no longer hardcodes ACP imports
|
||||||
|
- ACP still registers by default during normal startup
|
||||||
|
- generic registry files remain product-agnostic
|
||||||
|
|
||||||
|
### Phase 10: Remove ACP-shaped compatibility facades
|
||||||
|
|
||||||
|
Once all call sites are on the generic capability:
|
||||||
|
|
||||||
|
- delete ACP-shaped routing helpers
|
||||||
|
- delete hot-path plugin bootstrapping logic
|
||||||
|
- keep only thin compatibility exports if external plugins still need a deprecation window
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
The architecture is done when all of these are true:
|
||||||
|
|
||||||
|
- no inbound configured-binding resolution performs plugin discovery
|
||||||
|
- no channel-specific binding semantics remain in generic core binding code
|
||||||
|
- ACP still uses a core session kernel
|
||||||
|
- codex app server and ACP both sit on top of the same binding capability
|
||||||
|
- the binding capability can represent both configured and runtime-created bindings
|
||||||
|
- runtime-created plugin bindings do not use a separate implementation stack
|
||||||
|
- long-lived target orchestration is shared through a small core driver contract
|
||||||
|
- generic registry files do not import ACP directly
|
||||||
|
- ACP-era alias names are gone from the generic/plugin SDK surface
|
||||||
|
- the main harness is not forced into the ACP engine
|
||||||
|
- external plugins can use the same capability without internal imports
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
These are not goals of the remaining refactor:
|
||||||
|
|
||||||
|
- moving the ACP session kernel into an ordinary plugin
|
||||||
|
- forcing the main harness, ACP, and codex app server into one executor
|
||||||
|
- making every channel implement its own retry and session-safety logic
|
||||||
|
- keeping ACP-shaped naming in the long-term generic binding layer
|
||||||
|
|
||||||
|
## Bottom Line
|
||||||
|
|
||||||
|
The right 20-year split is:
|
||||||
|
|
||||||
|
- bindings are the shared core capability
|
||||||
|
- ACP session orchestration remains a small built-in core kernel
|
||||||
|
- channel plugins own binding semantics
|
||||||
|
- backend plugins own runtime protocol details
|
||||||
|
- product consumers like ACP configured bindings and codex app server build on the same binding capability without being forced into one runtime engine
|
||||||
|
|
||||||
|
That is the leanest core that still has honest boundaries.
|
||||||
@ -39,6 +39,25 @@ describe("acpx plugin config parsing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers the workspace plugin root for dist/extensions/acpx bundles", () => {
|
||||||
|
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-workspace-"));
|
||||||
|
const workspacePluginRoot = path.join(repoRoot, "extensions", "acpx");
|
||||||
|
const bundledPluginRoot = path.join(repoRoot, "dist", "extensions", "acpx");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(workspacePluginRoot, { recursive: true });
|
||||||
|
fs.mkdirSync(bundledPluginRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8");
|
||||||
|
fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||||
|
fs.writeFileSync(path.join(bundledPluginRoot, "package.json"), "{}\n", "utf8");
|
||||||
|
fs.writeFileSync(path.join(bundledPluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(path.join(bundledPluginRoot, "index.js")).href;
|
||||||
|
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves bundled acpx with pinned version by default", () => {
|
it("resolves bundled acpx with pinned version by default", () => {
|
||||||
const resolved = resolveAcpxPluginConfig({
|
const resolved = resolveAcpxPluginConfig({
|
||||||
rawConfig: {
|
rawConfig: {
|
||||||
|
|||||||
@ -13,14 +13,18 @@ export const ACPX_PINNED_VERSION = "0.1.16";
|
|||||||
export const ACPX_VERSION_ANY = "any";
|
export const ACPX_VERSION_ANY = "any";
|
||||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||||
|
|
||||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
function isAcpxPluginRoot(dir: string): boolean {
|
||||||
|
return (
|
||||||
|
fs.existsSync(path.join(dir, "openclaw.plugin.json")) &&
|
||||||
|
fs.existsSync(path.join(dir, "package.json"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
|
||||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||||
for (let i = 0; i < 3; i += 1) {
|
for (let i = 0; i < 3; i += 1) {
|
||||||
// Bundled entries live at the plugin root while source files still live under src/.
|
// Bundled entries live at the plugin root while source files still live under src/.
|
||||||
if (
|
if (isAcpxPluginRoot(cursor)) {
|
||||||
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
|
|
||||||
fs.existsSync(path.join(cursor, "package.json"))
|
|
||||||
) {
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
const parent = path.dirname(cursor);
|
const parent = path.dirname(cursor);
|
||||||
@ -32,10 +36,29 @@ export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): stri
|
|||||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
|
||||||
|
if (
|
||||||
|
path.basename(currentRoot) !== "acpx" ||
|
||||||
|
path.basename(path.dirname(currentRoot)) !== "extensions" ||
|
||||||
|
path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
|
||||||
|
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||||
|
const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
|
||||||
|
// In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
|
||||||
|
// Prefer the stable source plugin root when a built extension is running beside it.
|
||||||
|
return resolveWorkspaceAcpxPluginRoot(resolvedRoot) ?? resolvedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||||
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||||
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
|
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
|
||||||
return `npm install --omit=dev --no-save acpx@${version}`;
|
return `npm install --omit=dev --no-save --package-lock=false acpx@${version}`;
|
||||||
}
|
}
|
||||||
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
|
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
|
||||||
|
|
||||||
|
|||||||
@ -85,7 +85,13 @@ describe("acpx ensure", () => {
|
|||||||
});
|
});
|
||||||
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||||
command: "npm",
|
command: "npm",
|
||||||
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
|
args: [
|
||||||
|
"install",
|
||||||
|
"--omit=dev",
|
||||||
|
"--no-save",
|
||||||
|
"--package-lock=false",
|
||||||
|
`acpx@${ACPX_PINNED_VERSION}`,
|
||||||
|
],
|
||||||
cwd: "/plugin",
|
cwd: "/plugin",
|
||||||
stripProviderAuthEnvVars,
|
stripProviderAuthEnvVars,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -233,7 +233,13 @@ export async function ensureAcpx(params: {
|
|||||||
|
|
||||||
const install = await spawnAndCollect({
|
const install = await spawnAndCollect({
|
||||||
command: "npm",
|
command: "npm",
|
||||||
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
|
args: [
|
||||||
|
"install",
|
||||||
|
"--omit=dev",
|
||||||
|
"--no-save",
|
||||||
|
"--package-lock=false",
|
||||||
|
`acpx@${installVersion}`,
|
||||||
|
],
|
||||||
cwd: pluginRoot,
|
cwd: pluginRoot,
|
||||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@ -64,6 +64,58 @@ describe("resolveSpawnCommand", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes node shebang wrappers through the current node runtime on posix", async () => {
|
||||||
|
const dir = await createTempDir();
|
||||||
|
const scriptPath = path.join(dir, "acpx");
|
||||||
|
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||||
|
await chmod(scriptPath, 0o755);
|
||||||
|
|
||||||
|
const resolved = resolveSpawnCommand(
|
||||||
|
{
|
||||||
|
command: scriptPath,
|
||||||
|
args: ["--help"],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
platform: "linux",
|
||||||
|
env: {},
|
||||||
|
execPath: "/custom/node",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
command: "/custom/node",
|
||||||
|
args: [scriptPath, "--help"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes PATH-resolved node shebang wrappers through the current node runtime on posix", async () => {
|
||||||
|
const dir = await createTempDir();
|
||||||
|
const binDir = path.join(dir, "bin");
|
||||||
|
const scriptPath = path.join(binDir, "acpx");
|
||||||
|
await mkdir(binDir, { recursive: true });
|
||||||
|
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||||
|
await chmod(scriptPath, 0o755);
|
||||||
|
|
||||||
|
const resolved = resolveSpawnCommand(
|
||||||
|
{
|
||||||
|
command: "acpx",
|
||||||
|
args: ["--help"],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
platform: "linux",
|
||||||
|
env: { PATH: binDir },
|
||||||
|
execPath: "/custom/node",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
command: "/custom/node",
|
||||||
|
args: [scriptPath, "--help"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("routes .js command execution through node on windows", () => {
|
it("routes .js command execution through node on windows", () => {
|
||||||
const resolved = resolveSpawnCommand(
|
const resolved = resolveSpawnCommand(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import type {
|
import type {
|
||||||
WindowsSpawnProgram,
|
WindowsSpawnProgram,
|
||||||
WindowsSpawnProgramCandidate,
|
WindowsSpawnProgramCandidate,
|
||||||
@ -57,11 +58,76 @@ const DEFAULT_RUNTIME: SpawnRuntime = {
|
|||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
|
||||||
|
try {
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (platform === "win32") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
accessSync(filePath, fsConstants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined {
|
||||||
|
const pathEnv = runtime.env.PATH ?? runtime.env.Path;
|
||||||
|
if (!pathEnv) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) {
|
||||||
|
const candidate = path.join(entry, command);
|
||||||
|
if (isExecutableFile(candidate, runtime.platform)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined {
|
||||||
|
const commandPath =
|
||||||
|
path.isAbsolute(command) || command.includes(path.sep)
|
||||||
|
? command
|
||||||
|
: resolveExecutableFromPath(command, runtime);
|
||||||
|
if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? "";
|
||||||
|
if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) {
|
||||||
|
return commandPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSpawnCommand(
|
export function resolveSpawnCommand(
|
||||||
params: { command: string; args: string[] },
|
params: { command: string; args: string[] },
|
||||||
options?: SpawnCommandOptions,
|
options?: SpawnCommandOptions,
|
||||||
runtime: SpawnRuntime = DEFAULT_RUNTIME,
|
runtime: SpawnRuntime = DEFAULT_RUNTIME,
|
||||||
): ResolvedSpawnCommand {
|
): ResolvedSpawnCommand {
|
||||||
|
if (runtime.platform !== "win32") {
|
||||||
|
const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime);
|
||||||
|
if (nodeShebangScript) {
|
||||||
|
options?.onResolved?.({
|
||||||
|
command: params.command,
|
||||||
|
cacheHit: false,
|
||||||
|
strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true,
|
||||||
|
resolution: "direct",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
command: runtime.execPath,
|
||||||
|
args: [nodeShebangScript, ...params.args],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true;
|
const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true;
|
||||||
const cacheKey = params.command;
|
const cacheKey = params.command;
|
||||||
const cachedProgram = options?.cache;
|
const cachedProgram = options?.cache;
|
||||||
|
|||||||
@ -154,6 +154,90 @@ describe("AcpxRuntime", () => {
|
|||||||
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
|
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces dead named sessions returned by sessions ensure", async () => {
|
||||||
|
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
|
||||||
|
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
|
||||||
|
try {
|
||||||
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||||
|
const sessionKey = "agent:codex:acp:dead-session";
|
||||||
|
|
||||||
|
const handle = await runtime.ensureSession({
|
||||||
|
sessionKey,
|
||||||
|
agent: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handle.backend).toBe("acpx");
|
||||||
|
const logs = await readMockRuntimeLogEntries(logPath);
|
||||||
|
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||||
|
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||||
|
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||||
|
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||||
|
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses a live named session when sessions ensure exits before returning identifiers", async () => {
|
||||||
|
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
|
||||||
|
process.env.MOCK_ACPX_STATUS_STATUS = "alive";
|
||||||
|
try {
|
||||||
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||||
|
const sessionKey = "agent:codex:acp:ensure-fallback-alive";
|
||||||
|
|
||||||
|
const handle = await runtime.ensureSession({
|
||||||
|
sessionKey,
|
||||||
|
agent: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handle.backend).toBe("acpx");
|
||||||
|
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
|
||||||
|
const logs = await readMockRuntimeLogEntries(logPath);
|
||||||
|
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||||
|
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||||
|
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||||
|
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||||
|
expect(newIndex).toBe(-1);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a fresh named session when sessions ensure exits and status is dead", async () => {
|
||||||
|
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
|
||||||
|
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
|
||||||
|
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
|
||||||
|
try {
|
||||||
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||||
|
const sessionKey = "agent:codex:acp:ensure-fallback-dead";
|
||||||
|
|
||||||
|
const handle = await runtime.ensureSession({
|
||||||
|
sessionKey,
|
||||||
|
agent: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handle.backend).toBe("acpx");
|
||||||
|
const logs = await readMockRuntimeLogEntries(logPath);
|
||||||
|
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||||
|
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||||
|
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||||
|
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||||
|
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||||
|
} finally {
|
||||||
|
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes text plus image attachments into ACP prompt blocks", async () => {
|
it("serializes text plus image attachments into ACP prompt blocks", async () => {
|
||||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||||
|
|
||||||
|
|||||||
@ -92,6 +92,26 @@ function formatAcpxExitMessage(params: {
|
|||||||
return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`;
|
return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeLogText(text: string, maxChars = 240): string {
|
||||||
|
const normalized = text.trim().replace(/\s+/g, " ");
|
||||||
|
if (!normalized) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (normalized.length <= maxChars) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `${normalized.slice(0, maxChars)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSessionIdentifierEvent(events: AcpxJsonObject[]): AcpxJsonObject | undefined {
|
||||||
|
return events.find(
|
||||||
|
(event) =>
|
||||||
|
asOptionalString(event.agentSessionId) ||
|
||||||
|
asOptionalString(event.acpxSessionId) ||
|
||||||
|
asOptionalString(event.acpxRecordId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
|
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
|
||||||
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||||||
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
||||||
@ -252,6 +272,146 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
this.healthy = result.ok;
|
this.healthy = result.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async createNamedSession(params: {
|
||||||
|
agent: string;
|
||||||
|
cwd: string;
|
||||||
|
sessionName: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
|
}): Promise<AcpxJsonObject[]> {
|
||||||
|
const command = params.resumeSessionId
|
||||||
|
? [
|
||||||
|
"sessions",
|
||||||
|
"new",
|
||||||
|
"--name",
|
||||||
|
params.sessionName,
|
||||||
|
"--resume-session",
|
||||||
|
params.resumeSessionId,
|
||||||
|
]
|
||||||
|
: ["sessions", "new", "--name", params.sessionName];
|
||||||
|
return await this.runControlCommand({
|
||||||
|
args: await this.buildVerbArgs({
|
||||||
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
cwd: params.cwd,
|
||||||
|
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shouldReplaceEnsuredSession(params: {
|
||||||
|
sessionName: string;
|
||||||
|
agent: string;
|
||||||
|
cwd: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
command: ["status", "--session", params.sessionName],
|
||||||
|
});
|
||||||
|
let events: AcpxJsonObject[];
|
||||||
|
try {
|
||||||
|
events = await this.runControlCommand({
|
||||||
|
args,
|
||||||
|
cwd: params.cwd,
|
||||||
|
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||||
|
ignoreNoSession: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession status probe failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(error instanceof Error ? error.message : String(error)) || "<empty>"}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION");
|
||||||
|
if (noSession) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession replacing missing named session: session=${params.sessionName} cwd=${params.cwd}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = events.find((event) => !toAcpxErrorEvent(event));
|
||||||
|
const status = asTrimmedString(detail?.status)?.toLowerCase();
|
||||||
|
if (status === "dead") {
|
||||||
|
const summary = summarizeLogText(asOptionalString(detail?.summary) ?? "");
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession replacing dead named session: session=${params.sessionName} cwd=${params.cwd} status=${status} summary=${summary || "<empty>"}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recoverEnsureFailure(params: {
|
||||||
|
sessionName: string;
|
||||||
|
agent: string;
|
||||||
|
cwd: string;
|
||||||
|
error: unknown;
|
||||||
|
}): Promise<AcpxJsonObject[] | null> {
|
||||||
|
const errorMessage = summarizeLogText(
|
||||||
|
params.error instanceof Error ? params.error.message : String(params.error),
|
||||||
|
);
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession probing named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} error=${errorMessage || "<empty>"}`,
|
||||||
|
);
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
command: ["status", "--session", params.sessionName],
|
||||||
|
});
|
||||||
|
let events: AcpxJsonObject[];
|
||||||
|
try {
|
||||||
|
events = await this.runControlCommand({
|
||||||
|
args,
|
||||||
|
cwd: params.cwd,
|
||||||
|
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||||
|
ignoreNoSession: true,
|
||||||
|
});
|
||||||
|
} catch (statusError) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession status fallback failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(statusError instanceof Error ? statusError.message : String(statusError)) || "<empty>"}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION");
|
||||||
|
if (noSession) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession creating named session after ensure failure and missing status: session=${params.sessionName} cwd=${params.cwd}`,
|
||||||
|
);
|
||||||
|
return await this.createNamedSession({
|
||||||
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
sessionName: params.sessionName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = events.find((event) => !toAcpxErrorEvent(event));
|
||||||
|
const status = asTrimmedString(detail?.status)?.toLowerCase();
|
||||||
|
if (status === "dead") {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession replacing dead named session after ensure failure: session=${params.sessionName} cwd=${params.cwd}`,
|
||||||
|
);
|
||||||
|
return await this.createNamedSession({
|
||||||
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
sessionName: params.sessionName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "alive" || findSessionIdentifierEvent(events)) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession reusing live named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} status=${status || "unknown"}`,
|
||||||
|
);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||||
const sessionName = asTrimmedString(input.sessionKey);
|
const sessionName = asTrimmedString(input.sessionKey);
|
||||||
if (!sessionName) {
|
if (!sessionName) {
|
||||||
@ -264,45 +424,80 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||||
const mode = input.mode;
|
const mode = input.mode;
|
||||||
const resumeSessionId = asTrimmedString(input.resumeSessionId);
|
const resumeSessionId = asTrimmedString(input.resumeSessionId);
|
||||||
const ensureSubcommand = resumeSessionId
|
let events: AcpxJsonObject[];
|
||||||
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
|
if (resumeSessionId) {
|
||||||
: ["sessions", "ensure", "--name", sessionName];
|
events = await this.createNamedSession({
|
||||||
const ensureCommand = await this.buildVerbArgs({
|
|
||||||
agent,
|
|
||||||
cwd,
|
|
||||||
command: ensureSubcommand,
|
|
||||||
});
|
|
||||||
|
|
||||||
let events = await this.runControlCommand({
|
|
||||||
args: ensureCommand,
|
|
||||||
cwd,
|
|
||||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
|
||||||
});
|
|
||||||
let ensuredEvent = events.find(
|
|
||||||
(event) =>
|
|
||||||
asOptionalString(event.agentSessionId) ||
|
|
||||||
asOptionalString(event.acpxSessionId) ||
|
|
||||||
asOptionalString(event.acpxRecordId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ensuredEvent && !resumeSessionId) {
|
|
||||||
const newCommand = await this.buildVerbArgs({
|
|
||||||
agent,
|
agent,
|
||||||
cwd,
|
cwd,
|
||||||
command: ["sessions", "new", "--name", sessionName],
|
sessionName,
|
||||||
|
resumeSessionId,
|
||||||
});
|
});
|
||||||
events = await this.runControlCommand({
|
} else {
|
||||||
args: newCommand,
|
try {
|
||||||
cwd,
|
events = await this.runControlCommand({
|
||||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
args: await this.buildVerbArgs({
|
||||||
});
|
agent,
|
||||||
ensuredEvent = events.find(
|
cwd,
|
||||||
(event) =>
|
command: ["sessions", "ensure", "--name", sessionName],
|
||||||
asOptionalString(event.agentSessionId) ||
|
}),
|
||||||
asOptionalString(event.acpxSessionId) ||
|
cwd,
|
||||||
asOptionalString(event.acpxRecordId),
|
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const recovered = await this.recoverEnsureFailure({
|
||||||
|
sessionName,
|
||||||
|
agent,
|
||||||
|
cwd,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
if (!recovered) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
events = recovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession returned no events after sessions ensure: session=${sessionName} agent=${agent} cwd=${cwd}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let ensuredEvent = findSessionIdentifierEvent(events);
|
||||||
|
|
||||||
|
if (
|
||||||
|
ensuredEvent &&
|
||||||
|
!resumeSessionId &&
|
||||||
|
(await this.shouldReplaceEnsuredSession({
|
||||||
|
sessionName,
|
||||||
|
agent,
|
||||||
|
cwd,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
events = await this.createNamedSession({
|
||||||
|
agent,
|
||||||
|
cwd,
|
||||||
|
sessionName,
|
||||||
|
});
|
||||||
|
if (events.length === 0) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession returned no events after replacing dead session: session=${sessionName} agent=${agent} cwd=${cwd}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ensuredEvent = findSessionIdentifierEvent(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensuredEvent && !resumeSessionId) {
|
||||||
|
events = await this.createNamedSession({
|
||||||
|
agent,
|
||||||
|
cwd,
|
||||||
|
sessionName,
|
||||||
|
});
|
||||||
|
if (events.length === 0) {
|
||||||
|
this.logger?.warn?.(
|
||||||
|
`acpx ensureSession returned no events after sessions new: session=${sessionName} agent=${agent} cwd=${cwd}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ensuredEvent = findSessionIdentifierEvent(events);
|
||||||
|
}
|
||||||
if (!ensuredEvent) {
|
if (!ensuredEvent) {
|
||||||
throw new AcpRuntimeError(
|
throw new AcpRuntimeError(
|
||||||
"ACP_SESSION_INIT_FAILED",
|
"ACP_SESSION_INIT_FAILED",
|
||||||
|
|||||||
@ -76,6 +76,17 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
|
|||||||
|
|
||||||
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
||||||
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
|
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
|
||||||
|
if (process.env.MOCK_ACPX_ENSURE_EXIT_1 === "1") {
|
||||||
|
emitJson({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: null,
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: "mock ensure failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") {
|
if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") {
|
||||||
emitJson({ action: "session_ensured", name: ensureName });
|
emitJson({ action: "session_ensured", name: ensureName });
|
||||||
} else {
|
} else {
|
||||||
@ -173,11 +184,14 @@ if (command === "set") {
|
|||||||
|
|
||||||
if (command === "status") {
|
if (command === "status") {
|
||||||
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
|
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
|
||||||
|
const status = process.env.MOCK_ACPX_STATUS_STATUS || (sessionFromOption ? "alive" : "no-session");
|
||||||
|
const summary = process.env.MOCK_ACPX_STATUS_SUMMARY || "";
|
||||||
emitJson({
|
emitJson({
|
||||||
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
|
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
|
||||||
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
|
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
|
||||||
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
|
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
|
||||||
status: sessionFromOption ? "alive" : "no-session",
|
status,
|
||||||
|
...(summary ? { summary } : {}),
|
||||||
pid: 4242,
|
pid: 4242,
|
||||||
uptime: 120,
|
uptime: 120,
|
||||||
});
|
});
|
||||||
@ -382,6 +396,9 @@ export async function readMockRuntimeLogEntries(
|
|||||||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||||
delete process.env.MOCK_ACPX_LOG;
|
delete process.env.MOCK_ACPX_LOG;
|
||||||
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||||
|
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||||
|
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||||
sharedMockCliScriptPath = null;
|
sharedMockCliScriptPath = null;
|
||||||
logFileSequence = 0;
|
logFileSequence = 0;
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
|
|||||||
@ -1,8 +1,87 @@
|
|||||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/discord";
|
import type {
|
||||||
import { describe, expect, it, vi } from "vitest";
|
ChannelAccountSnapshot,
|
||||||
|
ChannelGatewayContext,
|
||||||
|
OpenClawConfig,
|
||||||
|
PluginRuntime,
|
||||||
|
} from "openclaw/plugin-sdk/discord";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||||
|
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||||
import { discordPlugin } from "./channel.js";
|
import { discordPlugin } from "./channel.js";
|
||||||
import { setDiscordRuntime } from "./runtime.js";
|
import { setDiscordRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const probeDiscordMock = vi.hoisted(() => vi.fn());
|
||||||
|
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
|
||||||
|
const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("./probe.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./probe.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
probeDiscord: probeDiscordMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./monitor.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./monitor.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
monitorDiscordProvider: monitorDiscordProviderMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./audit.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./audit.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createCfg(): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
enabled: true,
|
||||||
|
token: "discord-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStartAccountCtx(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId: string;
|
||||||
|
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||||
|
}): ChannelGatewayContext<ResolvedDiscordAccount> {
|
||||||
|
const account = discordPlugin.config.resolveAccount(
|
||||||
|
params.cfg,
|
||||||
|
params.accountId,
|
||||||
|
) as ResolvedDiscordAccount;
|
||||||
|
const snapshot: ChannelAccountSnapshot = {
|
||||||
|
accountId: params.accountId,
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
accountId: params.accountId,
|
||||||
|
account,
|
||||||
|
cfg: params.cfg,
|
||||||
|
runtime: params.runtime,
|
||||||
|
abortSignal: new AbortController().signal,
|
||||||
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
getStatus: () => snapshot,
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
probeDiscordMock.mockReset();
|
||||||
|
monitorDiscordProviderMock.mockReset();
|
||||||
|
auditDiscordChannelPermissionsMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
describe("discordPlugin outbound", () => {
|
describe("discordPlugin outbound", () => {
|
||||||
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
||||||
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
||||||
@ -33,4 +112,100 @@ describe("discordPlugin outbound", () => {
|
|||||||
);
|
);
|
||||||
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
|
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses direct Discord probe helpers for status probes", async () => {
|
||||||
|
const runtimeProbeDiscord = vi.fn(async () => {
|
||||||
|
throw new Error("runtime Discord probe should not be used");
|
||||||
|
});
|
||||||
|
setDiscordRuntime({
|
||||||
|
channel: {
|
||||||
|
discord: {
|
||||||
|
probeDiscord: runtimeProbeDiscord,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
shouldLogVerbose: () => false,
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime);
|
||||||
|
probeDiscordMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
bot: { username: "Bob" },
|
||||||
|
application: {
|
||||||
|
intents: {
|
||||||
|
messageContent: "limited",
|
||||||
|
guildMembers: "disabled",
|
||||||
|
presence: "disabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
elapsedMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = createCfg();
|
||||||
|
const account = discordPlugin.config.resolveAccount(cfg, "default");
|
||||||
|
|
||||||
|
await discordPlugin.status!.probeAccount!({
|
||||||
|
account,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, {
|
||||||
|
includeApplication: true,
|
||||||
|
});
|
||||||
|
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses direct Discord startup helpers before monitoring", async () => {
|
||||||
|
const runtimeProbeDiscord = vi.fn(async () => {
|
||||||
|
throw new Error("runtime Discord probe should not be used");
|
||||||
|
});
|
||||||
|
const runtimeMonitorDiscordProvider = vi.fn(async () => {
|
||||||
|
throw new Error("runtime Discord monitor should not be used");
|
||||||
|
});
|
||||||
|
setDiscordRuntime({
|
||||||
|
channel: {
|
||||||
|
discord: {
|
||||||
|
probeDiscord: runtimeProbeDiscord,
|
||||||
|
monitorDiscordProvider: runtimeMonitorDiscordProvider,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
shouldLogVerbose: () => false,
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime);
|
||||||
|
probeDiscordMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
bot: { username: "Bob" },
|
||||||
|
application: {
|
||||||
|
intents: {
|
||||||
|
messageContent: "limited",
|
||||||
|
guildMembers: "disabled",
|
||||||
|
presence: "disabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
elapsedMs: 1,
|
||||||
|
});
|
||||||
|
monitorDiscordProviderMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const cfg = createCfg();
|
||||||
|
await discordPlugin.gateway!.startAccount!(
|
||||||
|
createStartAccountCtx({
|
||||||
|
cfg,
|
||||||
|
accountId: "default",
|
||||||
|
runtime: createRuntimeEnv(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
|
||||||
|
includeApplication: true,
|
||||||
|
});
|
||||||
|
expect(monitorDiscordProviderMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
token: "discord-token",
|
||||||
|
accountId: "default",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,17 +35,18 @@ import {
|
|||||||
resolveDiscordAccount,
|
resolveDiscordAccount,
|
||||||
type ResolvedDiscordAccount,
|
type ResolvedDiscordAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { collectDiscordAuditChannelIds } from "./audit.js";
|
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
|
||||||
import {
|
import {
|
||||||
isDiscordExecApprovalClientEnabled,
|
isDiscordExecApprovalClientEnabled,
|
||||||
shouldSuppressLocalDiscordExecApprovalPrompt,
|
shouldSuppressLocalDiscordExecApprovalPrompt,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
import { monitorDiscordProvider } from "./monitor.js";
|
||||||
import {
|
import {
|
||||||
looksLikeDiscordTargetId,
|
looksLikeDiscordTargetId,
|
||||||
normalizeDiscordMessagingTarget,
|
normalizeDiscordMessagingTarget,
|
||||||
normalizeDiscordOutboundTarget,
|
normalizeDiscordOutboundTarget,
|
||||||
} from "./normalize.js";
|
} from "./normalize.js";
|
||||||
import type { DiscordProbe } from "./probe.js";
|
import { probeDiscord, type DiscordProbe } from "./probe.js";
|
||||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||||
import { getDiscordRuntime } from "./runtime.js";
|
import { getDiscordRuntime } from "./runtime.js";
|
||||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||||
@ -491,11 +492,15 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
silent: silent ?? undefined,
|
silent: silent ?? undefined,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
acpBindings: {
|
bindings: {
|
||||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
compileConfiguredBinding: ({ conversationId }) =>
|
||||||
normalizeDiscordAcpConversationId(conversationId),
|
normalizeDiscordAcpConversationId(conversationId),
|
||||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||||
matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
matchDiscordAcpConversation({
|
||||||
|
bindingConversationId: compiledBinding.conversationId,
|
||||||
|
conversationId,
|
||||||
|
parentConversationId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
defaultRuntime: {
|
defaultRuntime: {
|
||||||
@ -514,7 +519,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
buildChannelSummary: ({ snapshot }) =>
|
buildChannelSummary: ({ snapshot }) =>
|
||||||
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
|
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
|
||||||
probeAccount: async ({ account, timeoutMs }) =>
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
probeDiscord(account.token, timeoutMs, {
|
||||||
includeApplication: true,
|
includeApplication: true,
|
||||||
}),
|
}),
|
||||||
formatCapabilitiesProbe: ({ probe }) => {
|
formatCapabilitiesProbe: ({ probe }) => {
|
||||||
@ -620,7 +625,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
const audit = await auditDiscordChannelPermissions({
|
||||||
token: botToken,
|
token: botToken,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
channelIds,
|
channelIds,
|
||||||
@ -661,7 +666,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
const token = account.token.trim();
|
const token = account.token.trim();
|
||||||
let discordBotLabel = "";
|
let discordBotLabel = "";
|
||||||
try {
|
try {
|
||||||
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
const probe = await probeDiscord(token, 2500, {
|
||||||
includeApplication: true,
|
includeApplication: true,
|
||||||
});
|
});
|
||||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||||
@ -689,7 +694,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
||||||
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
return monitorDiscordProvider({
|
||||||
token,
|
token,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import WebSocket from "ws";
|
|||||||
|
|
||||||
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
|
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
|
||||||
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
|
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
|
||||||
|
const DISCORD_GATEWAY_INFO_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
|
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
|
||||||
type DiscordGatewayFetchInit = Record<string, unknown> & {
|
type DiscordGatewayFetchInit = Record<string, unknown> & {
|
||||||
@ -19,6 +20,8 @@ type DiscordGatewayFetch = (
|
|||||||
init?: DiscordGatewayFetchInit,
|
init?: DiscordGatewayFetchInit,
|
||||||
) => Promise<DiscordGatewayMetadataResponse>;
|
) => Promise<DiscordGatewayMetadataResponse>;
|
||||||
|
|
||||||
|
type DiscordGatewayMetadataError = Error & { transient?: boolean };
|
||||||
|
|
||||||
export function resolveDiscordGatewayIntents(
|
export function resolveDiscordGatewayIntents(
|
||||||
intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig,
|
intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig,
|
||||||
): number {
|
): number {
|
||||||
@ -64,14 +67,36 @@ function createGatewayMetadataError(params: {
|
|||||||
transient: boolean;
|
transient: boolean;
|
||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
}): Error {
|
}): Error {
|
||||||
if (params.transient) {
|
const error = new Error(
|
||||||
return new Error("Failed to get gateway information from Discord: fetch failed", {
|
params.transient
|
||||||
cause: params.cause ?? new Error(params.detail),
|
? "Failed to get gateway information from Discord: fetch failed"
|
||||||
});
|
: `Failed to get gateway information from Discord: ${params.detail}`,
|
||||||
}
|
{
|
||||||
return new Error(`Failed to get gateway information from Discord: ${params.detail}`, {
|
cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined),
|
||||||
cause: params.cause,
|
},
|
||||||
|
) as DiscordGatewayMetadataError;
|
||||||
|
Object.defineProperty(error, "transient", {
|
||||||
|
value: params.transient,
|
||||||
|
enumerable: false,
|
||||||
});
|
});
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransientGatewayMetadataError(error: unknown): boolean {
|
||||||
|
return Boolean((error as DiscordGatewayMetadataError | undefined)?.transient);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultGatewayInfo(): APIGatewayBotInfo {
|
||||||
|
return {
|
||||||
|
url: DEFAULT_DISCORD_GATEWAY_URL,
|
||||||
|
shards: 1,
|
||||||
|
session_start_limit: {
|
||||||
|
total: 1,
|
||||||
|
remaining: 1,
|
||||||
|
reset_after: 0,
|
||||||
|
max_concurrency: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDiscordGatewayInfo(params: {
|
async function fetchDiscordGatewayInfo(params: {
|
||||||
@ -134,6 +159,65 @@ async function fetchDiscordGatewayInfo(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDiscordGatewayInfoWithTimeout(params: {
|
||||||
|
token: string;
|
||||||
|
fetchImpl: DiscordGatewayFetch;
|
||||||
|
fetchInit?: DiscordGatewayFetchInit;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<APIGatewayBotInfo> {
|
||||||
|
const timeoutMs = Math.max(1, params.timeoutMs ?? DISCORD_GATEWAY_INFO_TIMEOUT_MS);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
reject(
|
||||||
|
createGatewayMetadataError({
|
||||||
|
detail: `Discord API /gateway/bot timed out after ${timeoutMs}ms`,
|
||||||
|
transient: true,
|
||||||
|
cause: new Error("gateway metadata timeout"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
timeoutId.unref?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
fetchDiscordGatewayInfo({
|
||||||
|
token: params.token,
|
||||||
|
fetchImpl: params.fetchImpl,
|
||||||
|
fetchInit: {
|
||||||
|
...params.fetchInit,
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: unknown }): {
|
||||||
|
info: APIGatewayBotInfo;
|
||||||
|
usedFallback: boolean;
|
||||||
|
} {
|
||||||
|
if (!isTransientGatewayMetadataError(params.error)) {
|
||||||
|
throw params.error;
|
||||||
|
}
|
||||||
|
const message = params.error instanceof Error ? params.error.message : String(params.error);
|
||||||
|
params.runtime?.log?.(
|
||||||
|
`discord: gateway metadata lookup failed transiently; using default gateway url (${message})`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
info: createDefaultGatewayInfo(),
|
||||||
|
usedFallback: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createGatewayPlugin(params: {
|
function createGatewayPlugin(params: {
|
||||||
options: {
|
options: {
|
||||||
reconnect: { maxAttempts: number };
|
reconnect: { maxAttempts: number };
|
||||||
@ -143,19 +227,29 @@ function createGatewayPlugin(params: {
|
|||||||
fetchImpl: DiscordGatewayFetch;
|
fetchImpl: DiscordGatewayFetch;
|
||||||
fetchInit?: DiscordGatewayFetchInit;
|
fetchInit?: DiscordGatewayFetchInit;
|
||||||
wsAgent?: HttpsProxyAgent<string>;
|
wsAgent?: HttpsProxyAgent<string>;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
}): GatewayPlugin {
|
}): GatewayPlugin {
|
||||||
class SafeGatewayPlugin extends GatewayPlugin {
|
class SafeGatewayPlugin extends GatewayPlugin {
|
||||||
|
private gatewayInfoUsedFallback = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(params.options);
|
super(params.options);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
|
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
|
||||||
if (!this.gatewayInfo) {
|
if (!this.gatewayInfo || this.gatewayInfoUsedFallback) {
|
||||||
this.gatewayInfo = await fetchDiscordGatewayInfo({
|
const resolved = await fetchDiscordGatewayInfoWithTimeout({
|
||||||
token: client.options.token,
|
token: client.options.token,
|
||||||
fetchImpl: params.fetchImpl,
|
fetchImpl: params.fetchImpl,
|
||||||
fetchInit: params.fetchInit,
|
fetchInit: params.fetchInit,
|
||||||
});
|
})
|
||||||
|
.then((info) => ({
|
||||||
|
info,
|
||||||
|
usedFallback: false,
|
||||||
|
}))
|
||||||
|
.catch((error) => resolveGatewayInfoWithFallback({ runtime: params.runtime, error }));
|
||||||
|
this.gatewayInfo = resolved.info;
|
||||||
|
this.gatewayInfoUsedFallback = resolved.usedFallback;
|
||||||
}
|
}
|
||||||
return super.registerClient(client);
|
return super.registerClient(client);
|
||||||
}
|
}
|
||||||
@ -187,6 +281,7 @@ export function createDiscordGatewayPlugin(params: {
|
|||||||
return createGatewayPlugin({
|
return createGatewayPlugin({
|
||||||
options,
|
options,
|
||||||
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
||||||
|
runtime: params.runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,12 +296,14 @@ export function createDiscordGatewayPlugin(params: {
|
|||||||
fetchImpl: (input, init) => undiciFetch(input, init),
|
fetchImpl: (input, init) => undiciFetch(input, init),
|
||||||
fetchInit: { dispatcher: fetchAgent },
|
fetchInit: { dispatcher: fetchAgent },
|
||||||
wsAgent,
|
wsAgent,
|
||||||
|
runtime: params.runtime,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
|
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
|
||||||
return createGatewayPlugin({
|
return createGatewayPlugin({
|
||||||
options,
|
options,
|
||||||
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
||||||
|
runtime: params.runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../../../../src/acp/persistent-bindings.js", () => ({
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
ensureConfiguredAcpBindingSessionMock(...args),
|
return {
|
||||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
...actual,
|
||||||
resolveConfiguredAcpBindingRecordMock(...args),
|
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||||
}));
|
ensureConfiguredBindingRouteReadyMock(...args),
|
||||||
|
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||||
|
resolveConfiguredBindingRouteMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js";
|
import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js";
|
||||||
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||||
@ -52,6 +56,77 @@ function createConfiguredDiscordBinding() {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createConfiguredDiscordRoute() {
|
||||||
|
const configuredBinding = createConfiguredDiscordBinding();
|
||||||
|
return {
|
||||||
|
bindingResolution: {
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: CHANNEL_ID,
|
||||||
|
},
|
||||||
|
compiledBinding: {
|
||||||
|
channel: "discord",
|
||||||
|
accountPattern: "default",
|
||||||
|
binding: {
|
||||||
|
type: "acp",
|
||||||
|
agentId: "codex",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
peer: {
|
||||||
|
kind: "channel",
|
||||||
|
id: CHANNEL_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindingConversationId: CHANNEL_ID,
|
||||||
|
target: {
|
||||||
|
conversationId: CHANNEL_ID,
|
||||||
|
},
|
||||||
|
agentId: "codex",
|
||||||
|
provider: {
|
||||||
|
compileConfiguredBinding: () => ({ conversationId: CHANNEL_ID }),
|
||||||
|
matchInboundConversation: () => ({ conversationId: CHANNEL_ID }),
|
||||||
|
},
|
||||||
|
targetFactory: {
|
||||||
|
driverId: "acp",
|
||||||
|
materialize: () => ({
|
||||||
|
record: configuredBinding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
agentId: configuredBinding.spec.agentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
conversationId: CHANNEL_ID,
|
||||||
|
},
|
||||||
|
record: configuredBinding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
agentId: configuredBinding.spec.agentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configuredBinding,
|
||||||
|
boundSessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
route: {
|
||||||
|
agentId: "codex",
|
||||||
|
accountId: "default",
|
||||||
|
channel: "discord",
|
||||||
|
sessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
mainSessionKey: "agent:codex:main",
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
lastRoutePolicy: "bound",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
||||||
const message = createDiscordMessage({
|
const message = createDiscordMessage({
|
||||||
id: "m-1",
|
id: "m-1",
|
||||||
@ -94,13 +169,10 @@ function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
|||||||
describe("preflightDiscordMessage configured ACP bindings", () => {
|
describe("preflightDiscordMessage configured ACP bindings", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
resolveConfiguredBindingRouteMock.mockReset();
|
||||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding());
|
resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredDiscordRoute());
|
||||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||||
ok: true,
|
|
||||||
sessionKey: "agent:codex:acp:binding:discord:default:abc123",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not initialize configured ACP bindings for rejected messages", async () => {
|
it("does not initialize configured ACP bindings for rejected messages", async () => {
|
||||||
@ -121,8 +193,8 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes configured ACP bindings only after preflight accepts the message", async () => {
|
it("initializes configured ACP bindings only after preflight accepts the message", async () => {
|
||||||
@ -144,8 +216,176 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
|
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts plain messages in configured ACP-bound channels without a mention", async () => {
|
||||||
|
const message = createDiscordMessage({
|
||||||
|
id: "m-no-mention",
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
content: "hello",
|
||||||
|
mentionedUsers: [],
|
||||||
|
author: {
|
||||||
|
id: "user-1",
|
||||||
|
bot: false,
|
||||||
|
username: "alice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await preflightDiscordMessage(
|
||||||
|
createBasePreflightParams({
|
||||||
|
data: createGuildEvent({
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
guildId: GUILD_ID,
|
||||||
|
author: message.author,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
guildEntries: {
|
||||||
|
[GUILD_ID]: {
|
||||||
|
id: GUILD_ID,
|
||||||
|
channels: {
|
||||||
|
[CHANNEL_ID]: {
|
||||||
|
allow: true,
|
||||||
|
enabled: true,
|
||||||
|
requireMention: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => {
|
||||||
|
const message = createDiscordMessage({
|
||||||
|
id: "m-rest",
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
content: "",
|
||||||
|
author: {
|
||||||
|
id: "user-1",
|
||||||
|
bot: false,
|
||||||
|
username: "alice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restGet = vi.fn(async () => ({
|
||||||
|
id: "m-rest",
|
||||||
|
content: "hello from rest",
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
mention_everyone: false,
|
||||||
|
author: {
|
||||||
|
id: "user-1",
|
||||||
|
username: "alice",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const client = {
|
||||||
|
...createGuildTextClient(CHANNEL_ID),
|
||||||
|
rest: {
|
||||||
|
get: restGet,
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
|
||||||
|
|
||||||
|
const result = await preflightDiscordMessage(
|
||||||
|
createBasePreflightParams({
|
||||||
|
client,
|
||||||
|
data: createGuildEvent({
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
guildId: GUILD_ID,
|
||||||
|
author: message.author,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
guildEntries: {
|
||||||
|
[GUILD_ID]: {
|
||||||
|
id: GUILD_ID,
|
||||||
|
channels: {
|
||||||
|
[CHANNEL_ID]: {
|
||||||
|
allow: true,
|
||||||
|
enabled: true,
|
||||||
|
requireMention: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restGet).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result?.messageText).toBe("hello from rest");
|
||||||
|
expect(result?.data.message.content).toBe("hello from rest");
|
||||||
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates sticker-only guild message payloads from REST before ensuring configured ACP bindings", async () => {
|
||||||
|
const message = createDiscordMessage({
|
||||||
|
id: "m-rest-sticker",
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
content: "",
|
||||||
|
author: {
|
||||||
|
id: "user-1",
|
||||||
|
bot: false,
|
||||||
|
username: "alice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restGet = vi.fn(async () => ({
|
||||||
|
id: "m-rest-sticker",
|
||||||
|
content: "",
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
mention_everyone: false,
|
||||||
|
sticker_items: [
|
||||||
|
{
|
||||||
|
id: "sticker-1",
|
||||||
|
name: "wave",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
author: {
|
||||||
|
id: "user-1",
|
||||||
|
username: "alice",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const client = {
|
||||||
|
...createGuildTextClient(CHANNEL_ID),
|
||||||
|
rest: {
|
||||||
|
get: restGet,
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
|
||||||
|
|
||||||
|
const result = await preflightDiscordMessage(
|
||||||
|
createBasePreflightParams({
|
||||||
|
client,
|
||||||
|
data: createGuildEvent({
|
||||||
|
channelId: CHANNEL_ID,
|
||||||
|
guildId: GUILD_ID,
|
||||||
|
author: message.author,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
guildEntries: {
|
||||||
|
[GUILD_ID]: {
|
||||||
|
id: GUILD_ID,
|
||||||
|
channels: {
|
||||||
|
[CHANNEL_ID]: {
|
||||||
|
allow: true,
|
||||||
|
enabled: true,
|
||||||
|
requireMention: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(restGet).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result?.messageText).toBe("<media:sticker> (1 sticker)");
|
||||||
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
|
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({
|
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||||
}));
|
}));
|
||||||
import {
|
import {
|
||||||
@ -229,16 +229,16 @@ describe("resolvePreflightMentionRequirement", () => {
|
|||||||
expect(
|
expect(
|
||||||
resolvePreflightMentionRequirement({
|
resolvePreflightMentionRequirement({
|
||||||
shouldRequireMention: true,
|
shouldRequireMention: true,
|
||||||
isBoundThreadSession: false,
|
bypassMentionRequirement: false,
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables mention requirement for bound thread sessions", () => {
|
it("disables mention requirement when the route explicitly bypasses mentions", () => {
|
||||||
expect(
|
expect(
|
||||||
resolvePreflightMentionRequirement({
|
resolvePreflightMentionRequirement({
|
||||||
shouldRequireMention: true,
|
shouldRequireMention: true,
|
||||||
isBoundThreadSession: true,
|
bypassMentionRequirement: true,
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@ -247,7 +247,7 @@ describe("resolvePreflightMentionRequirement", () => {
|
|||||||
expect(
|
expect(
|
||||||
resolvePreflightMentionRequirement({
|
resolvePreflightMentionRequirement({
|
||||||
shouldRequireMention: false,
|
shouldRequireMention: false,
|
||||||
isBoundThreadSession: false,
|
bypassMentionRequirement: false,
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@ -378,6 +378,69 @@ describe("preflightDiscordMessage", () => {
|
|||||||
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
|
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops hydrated bound-thread webhook echoes after fetching an empty payload", async () => {
|
||||||
|
const threadBinding = createThreadBinding({
|
||||||
|
targetKind: "session",
|
||||||
|
targetSessionKey: "agent:main:acp:discord-thread-1",
|
||||||
|
});
|
||||||
|
const threadId = "thread-webhook-hydrated-1";
|
||||||
|
const parentId = "channel-parent-webhook-hydrated-1";
|
||||||
|
const message = createDiscordMessage({
|
||||||
|
id: "m-webhook-hydrated-1",
|
||||||
|
channelId: threadId,
|
||||||
|
content: "",
|
||||||
|
webhookId: undefined,
|
||||||
|
author: {
|
||||||
|
id: "relay-bot-1",
|
||||||
|
bot: true,
|
||||||
|
username: "Relay",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restGet = vi.fn(async () => ({
|
||||||
|
id: message.id,
|
||||||
|
content: "webhook relay",
|
||||||
|
webhook_id: "wh-1",
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
mention_everyone: false,
|
||||||
|
author: {
|
||||||
|
id: "relay-bot-1",
|
||||||
|
username: "Relay",
|
||||||
|
bot: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const client = {
|
||||||
|
...createThreadClient({ threadId, parentId }),
|
||||||
|
rest: {
|
||||||
|
get: restGet,
|
||||||
|
},
|
||||||
|
} as unknown as DiscordClient;
|
||||||
|
|
||||||
|
const result = await preflightDiscordMessage({
|
||||||
|
...createPreflightArgs({
|
||||||
|
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||||
|
discordConfig: {
|
||||||
|
allowBots: true,
|
||||||
|
} as DiscordConfig,
|
||||||
|
data: createGuildEvent({
|
||||||
|
channelId: threadId,
|
||||||
|
guildId: "guild-1",
|
||||||
|
author: message.author,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
client,
|
||||||
|
}),
|
||||||
|
threadBindings: {
|
||||||
|
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
|
||||||
|
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(restGet).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
|
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
|
||||||
const threadBinding = createThreadBinding();
|
const threadBinding = createThreadBinding();
|
||||||
const threadId = "thread-bot-focus";
|
const threadId = "thread-bot-focus";
|
||||||
@ -655,8 +718,8 @@ describe("preflightDiscordMessage", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await preflightDiscordMessage(
|
const result = await preflightDiscordMessage({
|
||||||
createPreflightArgs({
|
...createPreflightArgs({
|
||||||
cfg: {
|
cfg: {
|
||||||
...DEFAULT_PREFLIGHT_CFG,
|
...DEFAULT_PREFLIGHT_CFG,
|
||||||
messages: {
|
messages: {
|
||||||
@ -674,7 +737,17 @@ describe("preflightDiscordMessage", () => {
|
|||||||
}),
|
}),
|
||||||
client,
|
client,
|
||||||
}),
|
}),
|
||||||
);
|
guildEntries: {
|
||||||
|
"guild-1": {
|
||||||
|
channels: {
|
||||||
|
[channelId]: {
|
||||||
|
allow: true,
|
||||||
|
requireMention: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||||
expect(transcribeFirstAudioMock).toHaveBeenCalledWith(
|
expect(transcribeFirstAudioMock).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ChannelType, MessageType, type User } from "@buape/carbon";
|
import { ChannelType, MessageType, type Message, type User } from "@buape/carbon";
|
||||||
|
import { Routes, type APIMessage } from "discord-api-types/v10";
|
||||||
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
|
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
|
import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
|
import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
@ -6,8 +7,8 @@ import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runt
|
|||||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import {
|
import {
|
||||||
ensureConfiguredAcpRouteReady,
|
ensureConfiguredBindingRouteReady,
|
||||||
resolveConfiguredAcpRoute,
|
resolveConfiguredBindingRoute,
|
||||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import {
|
import {
|
||||||
getSessionBindingService,
|
getSessionBindingService,
|
||||||
@ -95,12 +96,12 @@ function isBoundThreadBotSystemMessage(params: {
|
|||||||
|
|
||||||
export function resolvePreflightMentionRequirement(params: {
|
export function resolvePreflightMentionRequirement(params: {
|
||||||
shouldRequireMention: boolean;
|
shouldRequireMention: boolean;
|
||||||
isBoundThreadSession: boolean;
|
bypassMentionRequirement: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!params.shouldRequireMention) {
|
if (!params.shouldRequireMention) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !params.isBoundThreadSession;
|
return !params.bypassMentionRequirement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
||||||
@ -131,6 +132,95 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
|||||||
return webhookId === boundWebhookId;
|
return webhookId === boundWebhookId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message {
|
||||||
|
const baseReferenced = (
|
||||||
|
base as unknown as {
|
||||||
|
referencedMessage?: {
|
||||||
|
mentionedUsers?: unknown[];
|
||||||
|
mentionedRoles?: unknown[];
|
||||||
|
mentionedEveryone?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).referencedMessage;
|
||||||
|
const fetchedMentions = Array.isArray(fetched.mentions)
|
||||||
|
? fetched.mentions.map((mention) => ({
|
||||||
|
...mention,
|
||||||
|
globalName: mention.global_name ?? undefined,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
const referencedMessage = fetched.referenced_message
|
||||||
|
? ({
|
||||||
|
...((base as { referencedMessage?: object }).referencedMessage ?? {}),
|
||||||
|
...fetched.referenced_message,
|
||||||
|
mentionedUsers: Array.isArray(fetched.referenced_message.mentions)
|
||||||
|
? fetched.referenced_message.mentions.map((mention) => ({
|
||||||
|
...mention,
|
||||||
|
globalName: mention.global_name ?? undefined,
|
||||||
|
}))
|
||||||
|
: (baseReferenced?.mentionedUsers ?? []),
|
||||||
|
mentionedRoles:
|
||||||
|
fetched.referenced_message.mention_roles ?? baseReferenced?.mentionedRoles ?? [],
|
||||||
|
mentionedEveryone:
|
||||||
|
fetched.referenced_message.mention_everyone ?? baseReferenced?.mentionedEveryone ?? false,
|
||||||
|
} satisfies Record<string, unknown>)
|
||||||
|
: (base as { referencedMessage?: Message }).referencedMessage;
|
||||||
|
const rawData = {
|
||||||
|
...((base as { rawData?: Record<string, unknown> }).rawData ?? {}),
|
||||||
|
message_snapshots:
|
||||||
|
fetched.message_snapshots ??
|
||||||
|
(base as { rawData?: { message_snapshots?: unknown } }).rawData?.message_snapshots,
|
||||||
|
sticker_items:
|
||||||
|
(fetched as { sticker_items?: unknown }).sticker_items ??
|
||||||
|
(base as { rawData?: { sticker_items?: unknown } }).rawData?.sticker_items,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...fetched,
|
||||||
|
content: fetched.content ?? base.content,
|
||||||
|
attachments: fetched.attachments ?? base.attachments,
|
||||||
|
embeds: fetched.embeds ?? base.embeds,
|
||||||
|
stickers:
|
||||||
|
(fetched as { stickers?: unknown }).stickers ??
|
||||||
|
(fetched as { sticker_items?: unknown }).sticker_items ??
|
||||||
|
base.stickers,
|
||||||
|
mentionedUsers: fetchedMentions ?? base.mentionedUsers,
|
||||||
|
mentionedRoles: fetched.mention_roles ?? base.mentionedRoles,
|
||||||
|
mentionedEveryone: fetched.mention_everyone ?? base.mentionedEveryone,
|
||||||
|
referencedMessage,
|
||||||
|
rawData,
|
||||||
|
} as unknown as Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateDiscordMessageIfEmpty(params: {
|
||||||
|
client: DiscordMessagePreflightParams["client"];
|
||||||
|
message: Message;
|
||||||
|
messageChannelId: string;
|
||||||
|
}): Promise<Message> {
|
||||||
|
const currentText = resolveDiscordMessageText(params.message, {
|
||||||
|
includeForwarded: true,
|
||||||
|
});
|
||||||
|
if (currentText) {
|
||||||
|
return params.message;
|
||||||
|
}
|
||||||
|
const rest = params.client.rest as { get?: (route: string) => Promise<unknown> } | undefined;
|
||||||
|
if (typeof rest?.get !== "function") {
|
||||||
|
return params.message;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fetched = (await rest.get(
|
||||||
|
Routes.channelMessage(params.messageChannelId, params.message.id),
|
||||||
|
)) as APIMessage | null | undefined;
|
||||||
|
if (!fetched) {
|
||||||
|
return params.message;
|
||||||
|
}
|
||||||
|
logVerbose(`discord: hydrated empty inbound payload via REST for ${params.message.id}`);
|
||||||
|
return mergeFetchedDiscordMessage(params.message, fetched);
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`);
|
||||||
|
return params.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function preflightDiscordMessage(
|
export async function preflightDiscordMessage(
|
||||||
params: DiscordMessagePreflightParams,
|
params: DiscordMessagePreflightParams,
|
||||||
): Promise<DiscordMessagePreflightContext | null> {
|
): Promise<DiscordMessagePreflightContext | null> {
|
||||||
@ -138,7 +228,7 @@ export async function preflightDiscordMessage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||||
const message = params.data.message;
|
let message = params.data.message;
|
||||||
const author = params.data.author;
|
const author = params.data.author;
|
||||||
if (!author) {
|
if (!author) {
|
||||||
return null;
|
return null;
|
||||||
@ -160,6 +250,15 @@ export async function preflightDiscordMessage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message = await hydrateDiscordMessageIfEmpty({
|
||||||
|
client: params.client,
|
||||||
|
message,
|
||||||
|
messageChannelId,
|
||||||
|
});
|
||||||
|
if (isPreflightAborted(params.abortSignal)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const pluralkitConfig = params.discordConfig?.pluralkit;
|
const pluralkitConfig = params.discordConfig?.pluralkit;
|
||||||
const webhookId = resolveDiscordWebhookId(message);
|
const webhookId = resolveDiscordWebhookId(message);
|
||||||
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
|
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
|
||||||
@ -197,6 +296,7 @@ export async function preflightDiscordMessage(
|
|||||||
}
|
}
|
||||||
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
||||||
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
||||||
|
const data = message === params.data.message ? params.data : { ...params.data, message };
|
||||||
logDebug(
|
logDebug(
|
||||||
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
|
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
|
||||||
);
|
);
|
||||||
@ -359,16 +459,18 @@ export async function preflightDiscordMessage(
|
|||||||
}) ?? undefined;
|
}) ?? undefined;
|
||||||
const configuredRoute =
|
const configuredRoute =
|
||||||
threadBinding == null
|
threadBinding == null
|
||||||
? resolveConfiguredAcpRoute({
|
? resolveConfiguredBindingRoute({
|
||||||
cfg: freshCfg,
|
cfg: freshCfg,
|
||||||
route,
|
route,
|
||||||
channel: "discord",
|
conversation: {
|
||||||
accountId: params.accountId,
|
channel: "discord",
|
||||||
conversationId: messageChannelId,
|
accountId: params.accountId,
|
||||||
parentConversationId: earlyThreadParentId,
|
conversationId: messageChannelId,
|
||||||
|
parentConversationId: earlyThreadParentId,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const configuredBinding = configuredRoute?.configuredBinding ?? null;
|
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
||||||
if (!threadBinding && configuredBinding) {
|
if (!threadBinding && configuredBinding) {
|
||||||
threadBinding = configuredBinding.record;
|
threadBinding = configuredBinding.record;
|
||||||
}
|
}
|
||||||
@ -394,6 +496,7 @@ export async function preflightDiscordMessage(
|
|||||||
});
|
});
|
||||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||||
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||||
|
const bypassMentionRequirement = isBoundThreadSession || Boolean(configuredBinding);
|
||||||
if (
|
if (
|
||||||
isBoundThreadBotSystemMessage({
|
isBoundThreadBotSystemMessage({
|
||||||
isBoundThreadSession,
|
isBoundThreadSession,
|
||||||
@ -579,7 +682,7 @@ export async function preflightDiscordMessage(
|
|||||||
});
|
});
|
||||||
const shouldRequireMention = resolvePreflightMentionRequirement({
|
const shouldRequireMention = resolvePreflightMentionRequirement({
|
||||||
shouldRequireMention: shouldRequireMentionByConfig,
|
shouldRequireMention: shouldRequireMentionByConfig,
|
||||||
isBoundThreadSession,
|
bypassMentionRequirement,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preflight audio transcription for mention detection in guilds.
|
// Preflight audio transcription for mention detection in guilds.
|
||||||
@ -764,13 +867,13 @@ export async function preflightDiscordMessage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (configuredBinding) {
|
if (configuredBinding) {
|
||||||
const ensured = await ensureConfiguredAcpRouteReady({
|
const ensured = await ensureConfiguredBindingRouteReady({
|
||||||
cfg: freshCfg,
|
cfg: freshCfg,
|
||||||
configuredBinding,
|
bindingResolution: configuredBinding,
|
||||||
});
|
});
|
||||||
if (!ensured.ok) {
|
if (!ensured.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
`discord: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -794,7 +897,7 @@ export async function preflightDiscordMessage(
|
|||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
ackReactionScope: params.ackReactionScope,
|
ackReactionScope: params.ackReactionScope,
|
||||||
groupPolicy: params.groupPolicy,
|
groupPolicy: params.groupPolicy,
|
||||||
data: params.data,
|
data,
|
||||||
client: params.client,
|
client: params.client,
|
||||||
message,
|
message,
|
||||||
messageChannelId,
|
messageChannelId,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
|
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
|
||||||
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
|
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
|
||||||
|
import type { ChatType } from "../../../../src/channels/chat-type.js";
|
||||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||||
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
||||||
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
||||||
@ -11,17 +12,17 @@ import {
|
|||||||
} from "./native-command.test-helpers.js";
|
} from "./native-command.test-helpers.js";
|
||||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||||
|
|
||||||
type ResolveConfiguredAcpBindingRecordFn =
|
type ResolveConfiguredBindingRouteFn =
|
||||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredAcpRoute;
|
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||||
type EnsureConfiguredAcpBindingSessionFn =
|
type EnsureConfiguredBindingRouteReadyFn =
|
||||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredAcpRouteReady;
|
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||||
|
|
||||||
const persistentBindingMocks = vi.hoisted(() => ({
|
const persistentBindingMocks = vi.hoisted(() => ({
|
||||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>((params) => ({
|
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
|
||||||
configuredBinding: null,
|
bindingResolution: null,
|
||||||
route: params.route,
|
route: params.route,
|
||||||
})),
|
})),
|
||||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
@ -30,8 +31,8 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
resolveConfiguredAcpRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||||
ensureConfiguredAcpRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -65,12 +66,7 @@ function createConfig(): OpenClawConfig {
|
|||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusCommand(cfg: OpenClawConfig) {
|
function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
|
||||||
const commandSpec: NativeCommandSpec = {
|
|
||||||
name: "status",
|
|
||||||
description: "Status",
|
|
||||||
acceptsArgs: false,
|
|
||||||
};
|
|
||||||
return createDiscordNativeCommand({
|
return createDiscordNativeCommand({
|
||||||
command: commandSpec,
|
command: commandSpec,
|
||||||
cfg,
|
cfg,
|
||||||
@ -147,39 +143,145 @@ async function expectPairCommandReply(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
function createStatusCommand(cfg: OpenClawConfig) {
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
return createNativeCommand(cfg, {
|
||||||
configuredBinding: {
|
name: "status",
|
||||||
spec: {
|
description: "Status",
|
||||||
channel: "discord",
|
acceptsArgs: false,
|
||||||
accountId: params.accountId,
|
});
|
||||||
conversationId: channelId,
|
}
|
||||||
parentConversationId: params.parentConversationId,
|
|
||||||
agentId: "codex",
|
function resolveConversationFromParams(params: Parameters<ResolveConfiguredBindingRouteFn>[0]) {
|
||||||
mode: "persistent",
|
if ("conversation" in params) {
|
||||||
},
|
return params.conversation;
|
||||||
record: {
|
}
|
||||||
bindingId: `config:acp:discord:${params.accountId}:${channelId}`,
|
return {
|
||||||
targetSessionKey: boundSessionKey,
|
channel: params.channel,
|
||||||
targetKind: "session",
|
accountId: params.accountId,
|
||||||
conversation: {
|
conversationId: params.conversationId,
|
||||||
channel: "discord",
|
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
|
||||||
accountId: params.accountId,
|
};
|
||||||
conversationId: channelId,
|
}
|
||||||
},
|
|
||||||
status: "active",
|
function createConfiguredBindingResolution(params: {
|
||||||
boundAt: 0,
|
conversation: ReturnType<typeof resolveConversationFromParams>;
|
||||||
},
|
boundSessionKey: string;
|
||||||
},
|
}) {
|
||||||
boundSessionKey,
|
const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-")
|
||||||
boundAgentId: "codex",
|
? "direct"
|
||||||
route: {
|
: "channel";
|
||||||
...params.route,
|
const configuredBinding = {
|
||||||
|
spec: {
|
||||||
|
channel: "discord" as const,
|
||||||
|
accountId: params.conversation.accountId,
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
...(params.conversation.parentConversationId
|
||||||
|
? { parentConversationId: params.conversation.parentConversationId }
|
||||||
|
: {}),
|
||||||
agentId: "codex",
|
agentId: "codex",
|
||||||
sessionKey: boundSessionKey,
|
mode: "persistent" as const,
|
||||||
matchedBy: "binding.channel",
|
|
||||||
},
|
},
|
||||||
}));
|
record: {
|
||||||
|
bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`,
|
||||||
|
targetSessionKey: params.boundSessionKey,
|
||||||
|
targetKind: "session" as const,
|
||||||
|
conversation: params.conversation,
|
||||||
|
status: "active" as const,
|
||||||
|
boundAt: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
conversation: params.conversation,
|
||||||
|
compiledBinding: {
|
||||||
|
channel: "discord" as const,
|
||||||
|
binding: {
|
||||||
|
type: "acp" as const,
|
||||||
|
agentId: "codex",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: params.conversation.accountId,
|
||||||
|
peer: {
|
||||||
|
kind: peerKind,
|
||||||
|
id: params.conversation.conversationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acp: {
|
||||||
|
mode: "persistent" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindingConversationId: params.conversation.conversationId,
|
||||||
|
target: {
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
...(params.conversation.parentConversationId
|
||||||
|
? { parentConversationId: params.conversation.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
agentId: "codex",
|
||||||
|
provider: {
|
||||||
|
compileConfiguredBinding: () => ({
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
...(params.conversation.parentConversationId
|
||||||
|
? { parentConversationId: params.conversation.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
matchInboundConversation: () => ({
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
...(params.conversation.parentConversationId
|
||||||
|
? { parentConversationId: params.conversation.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
targetFactory: {
|
||||||
|
driverId: "acp" as const,
|
||||||
|
materialize: () => ({
|
||||||
|
record: configuredBinding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful" as const,
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: params.boundSessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
...(params.conversation.parentConversationId
|
||||||
|
? { parentConversationId: params.conversation.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
record: configuredBinding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful" as const,
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: params.boundSessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
||||||
|
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => {
|
||||||
|
const conversation = resolveConversationFromParams(params);
|
||||||
|
const bindingResolution = createConfiguredBindingResolution({
|
||||||
|
conversation: {
|
||||||
|
...conversation,
|
||||||
|
conversationId: channelId,
|
||||||
|
},
|
||||||
|
boundSessionKey,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bindingResolution,
|
||||||
|
boundSessionKey,
|
||||||
|
boundAgentId: "codex",
|
||||||
|
route: {
|
||||||
|
...params.route,
|
||||||
|
agentId: "codex",
|
||||||
|
sessionKey: boundSessionKey,
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
});
|
});
|
||||||
@ -234,7 +336,7 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
clearPluginCommands();
|
clearPluginCommands();
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
||||||
configuredBinding: null,
|
bindingResolution: null,
|
||||||
route: params.route,
|
route: params.route,
|
||||||
}));
|
}));
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
||||||
@ -519,4 +621,64 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
boundSessionKey,
|
boundSessionKey,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
|
||||||
|
const guildId = "1459246755253325866";
|
||||||
|
const channelId = "1479098716916023408";
|
||||||
|
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
||||||
|
const cfg = {
|
||||||
|
commands: {
|
||||||
|
useAccessGroups: false,
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
type: "acp",
|
||||||
|
agentId: "codex",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
peer: { kind: "channel", id: channelId },
|
||||||
|
},
|
||||||
|
acp: {
|
||||||
|
mode: "persistent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const interaction = createInteraction({
|
||||||
|
channelType: ChannelType.GuildText,
|
||||||
|
channelId,
|
||||||
|
guildId,
|
||||||
|
guildName: "Ops",
|
||||||
|
});
|
||||||
|
const command = createNativeCommand(cfg, {
|
||||||
|
name: "new",
|
||||||
|
description: "Start a new session.",
|
||||||
|
acceptsArgs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfiguredBinding(channelId, boundSessionKey);
|
||||||
|
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: "acpx exited with code 1",
|
||||||
|
});
|
||||||
|
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||||
|
const dispatchSpy = createDispatchSpy();
|
||||||
|
|
||||||
|
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||||
|
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||||
|
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||||
|
};
|
||||||
|
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||||
|
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||||
|
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||||
|
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||||
|
expect(interaction.reply).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: "Configured ACP binding is unavailable right now. Please try again.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,8 +24,8 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
|
|||||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import {
|
import {
|
||||||
ensureConfiguredAcpRouteReady,
|
ensureConfiguredBindingRouteReady,
|
||||||
resolveConfiguredAcpRoute,
|
resolveConfiguredBindingRoute,
|
||||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||||
@ -194,6 +194,11 @@ function buildDiscordCommandOptions(params: {
|
|||||||
}) satisfies CommandOptions;
|
}) satisfies CommandOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
|
||||||
|
const normalized = commandName.trim().toLowerCase();
|
||||||
|
return normalized === "acp" || normalized === "new" || normalized === "reset";
|
||||||
|
}
|
||||||
|
|
||||||
function readDiscordCommandArgs(
|
function readDiscordCommandArgs(
|
||||||
interaction: CommandInteraction,
|
interaction: CommandInteraction,
|
||||||
definitions?: CommandArgDefinition[],
|
definitions?: CommandArgDefinition[],
|
||||||
@ -1617,24 +1622,27 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
||||||
const configuredRoute =
|
const configuredRoute =
|
||||||
threadBinding == null
|
threadBinding == null
|
||||||
? resolveConfiguredAcpRoute({
|
? resolveConfiguredBindingRoute({
|
||||||
cfg,
|
cfg,
|
||||||
route,
|
route,
|
||||||
channel: "discord",
|
conversation: {
|
||||||
accountId,
|
channel: "discord",
|
||||||
conversationId: channelId,
|
accountId,
|
||||||
parentConversationId: threadParentId,
|
conversationId: channelId,
|
||||||
|
parentConversationId: threadParentId,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const configuredBinding = configuredRoute?.configuredBinding ?? null;
|
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
||||||
if (configuredBinding) {
|
const commandName = command.nativeName ?? command.key;
|
||||||
const ensured = await ensureConfiguredAcpRouteReady({
|
if (configuredBinding && !shouldBypassConfiguredAcpEnsure(commandName)) {
|
||||||
|
const ensured = await ensureConfiguredBindingRouteReady({
|
||||||
cfg,
|
cfg,
|
||||||
configuredBinding,
|
bindingResolution: configuredBinding,
|
||||||
});
|
});
|
||||||
if (!ensured.ok) {
|
if (!ensured.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||||
);
|
);
|
||||||
await respond("Configured ACP binding is unavailable right now. Please try again.");
|
await respond("Configured ACP binding is unavailable right now. Please try again.");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -228,6 +228,65 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
expect(connectedCall![0].lastConnectedAt).toBeTypeOf("number");
|
expect(connectedCall![0].lastConnectedAt).toBeTypeOf("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces a fresh reconnect when startup never reaches READY, then recovers", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||||
|
const { emitter, gateway } = createGatewayHarness();
|
||||||
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
|
gateway.connect.mockImplementation((_resume?: boolean) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
gateway.isConnected = true;
|
||||||
|
}, 1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lifecycleParams, runtimeError } = createLifecycleHarness({ gateway });
|
||||||
|
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||||
|
await vi.advanceTimersByTimeAsync(15_000 + 1_000);
|
||||||
|
await expect(lifecyclePromise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(runtimeError).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("gateway was not ready after 15000ms"),
|
||||||
|
);
|
||||||
|
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gateway.connect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gateway.connect).toHaveBeenCalledWith(false);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails fast when startup never reaches READY after a forced reconnect", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||||
|
const { emitter, gateway } = createGatewayHarness();
|
||||||
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
|
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||||
|
createLifecycleHarness({ gateway });
|
||||||
|
|
||||||
|
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||||
|
lifecyclePromise.catch(() => {});
|
||||||
|
await vi.advanceTimersByTimeAsync(15_000 * 2 + 1_000);
|
||||||
|
await expect(lifecyclePromise).rejects.toThrow(
|
||||||
|
"discord gateway did not reach READY within 15000ms after a forced reconnect",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gateway.connect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gateway.connect).toHaveBeenCalledWith(false);
|
||||||
|
expectLifecycleCleanup({
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
threadStop,
|
||||||
|
waitCalls: 0,
|
||||||
|
releaseEarlyGatewayErrorGuard,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
|
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
|
||||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||||
const {
|
const {
|
||||||
@ -276,6 +335,51 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces fatal startup gateway errors while waiting for READY", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||||
|
const pendingGatewayErrors: unknown[] = [];
|
||||||
|
const { emitter, gateway } = createGatewayHarness();
|
||||||
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
|
const {
|
||||||
|
lifecycleParams,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
threadStop,
|
||||||
|
runtimeError,
|
||||||
|
releaseEarlyGatewayErrorGuard,
|
||||||
|
} = createLifecycleHarness({
|
||||||
|
gateway,
|
||||||
|
pendingGatewayErrors,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
pendingGatewayErrors.push(new Error("Fatal Gateway error: 4001"));
|
||||||
|
}, 1_000);
|
||||||
|
|
||||||
|
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||||
|
lifecyclePromise.catch(() => {});
|
||||||
|
await vi.advanceTimersByTimeAsync(1_500);
|
||||||
|
await expect(lifecyclePromise).rejects.toThrow("Fatal Gateway error: 4001");
|
||||||
|
|
||||||
|
expect(runtimeError).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("discord gateway error: Error: Fatal Gateway error: 4001"),
|
||||||
|
);
|
||||||
|
expect(gateway.disconnect).not.toHaveBeenCalled();
|
||||||
|
expect(gateway.connect).not.toHaveBeenCalled();
|
||||||
|
expectLifecycleCleanup({
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
threadStop,
|
||||||
|
waitCalls: 0,
|
||||||
|
releaseEarlyGatewayErrorGuard,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("retries stalled HELLO with resume before forcing fresh identify", async () => {
|
it("retries stalled HELLO with resume before forcing fresh identify", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
@ -288,8 +392,11 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
},
|
},
|
||||||
sequence: 123,
|
sequence: 123,
|
||||||
});
|
});
|
||||||
|
gateway.isConnected = true;
|
||||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
||||||
|
emitter.emit("debug", "WebSocket connection closed with code 1006");
|
||||||
|
gateway.isConnected = false;
|
||||||
await emitGatewayOpenAndWait(emitter);
|
await emitGatewayOpenAndWait(emitter);
|
||||||
await emitGatewayOpenAndWait(emitter);
|
await emitGatewayOpenAndWait(emitter);
|
||||||
await emitGatewayOpenAndWait(emitter);
|
await emitGatewayOpenAndWait(emitter);
|
||||||
@ -324,8 +431,13 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
},
|
},
|
||||||
sequence: 456,
|
sequence: 456,
|
||||||
});
|
});
|
||||||
|
gateway.isConnected = true;
|
||||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
||||||
|
emitter.emit("debug", "WebSocket connection closed with code 1006");
|
||||||
|
gateway.isConnected = false;
|
||||||
|
await emitGatewayOpenAndWait(emitter);
|
||||||
|
|
||||||
await emitGatewayOpenAndWait(emitter);
|
await emitGatewayOpenAndWait(emitter);
|
||||||
|
|
||||||
// Successful reconnect (READY/RESUMED sets isConnected=true), then
|
// Successful reconnect (READY/RESUMED sets isConnected=true), then
|
||||||
@ -342,10 +454,11 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
const { lifecycleParams } = createLifecycleHarness({ gateway });
|
const { lifecycleParams } = createLifecycleHarness({ gateway });
|
||||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(gateway.connect).toHaveBeenCalledTimes(3);
|
expect(gateway.connect).toHaveBeenCalledTimes(4);
|
||||||
expect(gateway.connect).toHaveBeenNthCalledWith(1, true);
|
expect(gateway.connect).toHaveBeenNthCalledWith(1, true);
|
||||||
expect(gateway.connect).toHaveBeenNthCalledWith(2, true);
|
expect(gateway.connect).toHaveBeenNthCalledWith(2, true);
|
||||||
expect(gateway.connect).toHaveBeenNthCalledWith(3, true);
|
expect(gateway.connect).toHaveBeenNthCalledWith(3, true);
|
||||||
|
expect(gateway.connect).toHaveBeenNthCalledWith(4, true);
|
||||||
expect(gateway.connect).not.toHaveBeenCalledWith(false);
|
expect(gateway.connect).not.toHaveBeenCalledWith(false);
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@ -357,6 +470,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
try {
|
try {
|
||||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||||
const { emitter, gateway } = createGatewayHarness();
|
const { emitter, gateway } = createGatewayHarness();
|
||||||
|
gateway.isConnected = true;
|
||||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
||||||
(waitParams: WaitForDiscordGatewayStopParams) =>
|
(waitParams: WaitForDiscordGatewayStopParams) =>
|
||||||
@ -382,6 +496,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||||||
try {
|
try {
|
||||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||||
const { emitter, gateway } = createGatewayHarness();
|
const { emitter, gateway } = createGatewayHarness();
|
||||||
|
gateway.isConnected = true;
|
||||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||||
let resolveWait: (() => void) | undefined;
|
let resolveWait: (() => void) | undefined;
|
||||||
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
||||||
|
|||||||
@ -15,6 +15,37 @@ type ExecApprovalsHandler = {
|
|||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
|
||||||
|
const DISCORD_GATEWAY_READY_POLL_MS = 250;
|
||||||
|
|
||||||
|
type GatewayReadyWaitResult = "ready" | "timeout" | "stopped";
|
||||||
|
|
||||||
|
async function waitForDiscordGatewayReady(params: {
|
||||||
|
gateway?: Pick<GatewayPlugin, "isConnected">;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
timeoutMs: number;
|
||||||
|
beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop";
|
||||||
|
}): Promise<GatewayReadyWaitResult> {
|
||||||
|
const deadlineAt = Date.now() + params.timeoutMs;
|
||||||
|
while (!params.abortSignal?.aborted) {
|
||||||
|
const pollDecision = await params.beforePoll?.();
|
||||||
|
if (pollDecision === "stop") {
|
||||||
|
return "stopped";
|
||||||
|
}
|
||||||
|
if (params.gateway?.isConnected) {
|
||||||
|
return "ready";
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadlineAt) {
|
||||||
|
return "timeout";
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_POLL_MS);
|
||||||
|
timeout.unref?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return "stopped";
|
||||||
|
}
|
||||||
|
|
||||||
export async function runDiscordGatewayLifecycle(params: {
|
export async function runDiscordGatewayLifecycle(params: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
client: Client;
|
client: Client;
|
||||||
@ -242,20 +273,6 @@ export async function runDiscordGatewayLifecycle(params: {
|
|||||||
};
|
};
|
||||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||||
|
|
||||||
// If the gateway is already connected when the lifecycle starts (the
|
|
||||||
// "WebSocket connection opened" debug event was emitted before we
|
|
||||||
// registered the listener above), push the initial connected status now.
|
|
||||||
// Guard against lifecycleStopping: if the abortSignal was already aborted,
|
|
||||||
// onAbort() ran synchronously above and pushed connected: false — don't
|
|
||||||
// contradict it with a spurious connected: true.
|
|
||||||
if (gateway?.isConnected && !lifecycleStopping) {
|
|
||||||
const at = Date.now();
|
|
||||||
pushStatus({
|
|
||||||
...createConnectedChannelStatusPatch(at),
|
|
||||||
lastDisconnect: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let sawDisallowedIntents = false;
|
let sawDisallowedIntents = false;
|
||||||
const logGatewayError = (err: unknown) => {
|
const logGatewayError = (err: unknown) => {
|
||||||
if (params.isDisallowedIntentsError(err)) {
|
if (params.isDisallowedIntentsError(err)) {
|
||||||
@ -277,28 +294,107 @@ export async function runDiscordGatewayLifecycle(params: {
|
|||||||
params.isDisallowedIntentsError(err)
|
params.isDisallowedIntentsError(err)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const drainPendingGatewayErrors = (): "continue" | "stop" => {
|
||||||
|
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
|
||||||
|
if (pendingGatewayErrors.length === 0) {
|
||||||
|
return "continue";
|
||||||
|
}
|
||||||
|
const queuedErrors = [...pendingGatewayErrors];
|
||||||
|
pendingGatewayErrors.length = 0;
|
||||||
|
for (const err of queuedErrors) {
|
||||||
|
logGatewayError(err);
|
||||||
|
if (!shouldStopOnGatewayError(err)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (params.isDisallowedIntentsError(err)) {
|
||||||
|
return "stop";
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return "continue";
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (params.execApprovalsHandler) {
|
if (params.execApprovalsHandler) {
|
||||||
await params.execApprovalsHandler.start();
|
await params.execApprovalsHandler.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain gateway errors emitted before lifecycle listeners were attached.
|
// Drain gateway errors emitted before lifecycle listeners were attached.
|
||||||
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
|
if (drainPendingGatewayErrors() === "stop") {
|
||||||
if (pendingGatewayErrors.length > 0) {
|
return;
|
||||||
const queuedErrors = [...pendingGatewayErrors];
|
}
|
||||||
pendingGatewayErrors.length = 0;
|
|
||||||
for (const err of queuedErrors) {
|
// Carbon starts the gateway during client construction, before OpenClaw can
|
||||||
logGatewayError(err);
|
// attach lifecycle listeners. Require a READY/RESUMED-connected gateway
|
||||||
if (!shouldStopOnGatewayError(err)) {
|
// before continuing so the monitor does not look healthy while silently
|
||||||
continue;
|
// missing inbound events.
|
||||||
}
|
if (gateway && !gateway.isConnected && !lifecycleStopping) {
|
||||||
if (params.isDisallowedIntentsError(err)) {
|
const initialReady = await waitForDiscordGatewayReady({
|
||||||
|
gateway,
|
||||||
|
abortSignal: params.abortSignal,
|
||||||
|
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
|
||||||
|
beforePoll: drainPendingGatewayErrors,
|
||||||
|
});
|
||||||
|
if (initialReady === "stopped" || lifecycleStopping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialReady === "timeout" && !lifecycleStopping) {
|
||||||
|
params.runtime.error?.(
|
||||||
|
danger(
|
||||||
|
`discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; forcing a fresh reconnect`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const startupRetryAt = Date.now();
|
||||||
|
pushStatus({
|
||||||
|
connected: false,
|
||||||
|
lastEventAt: startupRetryAt,
|
||||||
|
lastDisconnect: {
|
||||||
|
at: startupRetryAt,
|
||||||
|
error: "startup-not-ready",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
gateway?.disconnect();
|
||||||
|
gateway?.connect(false);
|
||||||
|
const reconnected = await waitForDiscordGatewayReady({
|
||||||
|
gateway,
|
||||||
|
abortSignal: params.abortSignal,
|
||||||
|
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
|
||||||
|
beforePoll: drainPendingGatewayErrors,
|
||||||
|
});
|
||||||
|
if (reconnected === "stopped" || lifecycleStopping) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw err;
|
if (reconnected === "timeout" && !lifecycleStopping) {
|
||||||
|
const error = new Error(
|
||||||
|
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after a forced reconnect`,
|
||||||
|
);
|
||||||
|
const startupFailureAt = Date.now();
|
||||||
|
pushStatus({
|
||||||
|
connected: false,
|
||||||
|
lastEventAt: startupFailureAt,
|
||||||
|
lastDisconnect: {
|
||||||
|
at: startupFailureAt,
|
||||||
|
error: "startup-reconnect-timeout",
|
||||||
|
},
|
||||||
|
lastError: error.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the gateway is already connected when the lifecycle starts (or becomes
|
||||||
|
// connected during the startup readiness guard), push the initial connected
|
||||||
|
// status now. Guard against lifecycleStopping: if the abortSignal was
|
||||||
|
// already aborted, onAbort() ran synchronously above and pushed connected:
|
||||||
|
// false, so don't contradict it with a spurious connected: true.
|
||||||
|
if (gateway?.isConnected && !lifecycleStopping) {
|
||||||
|
const at = Date.now();
|
||||||
|
pushStatus({
|
||||||
|
...createConnectedChannelStatusPatch(at),
|
||||||
|
lastDisconnect: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await waitForDiscordGatewayStop({
|
await waitForDiscordGatewayStop({
|
||||||
gateway: gateway
|
gateway: gateway
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -142,11 +142,30 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(registerGatewayClient(plugin)).rejects.toThrow(
|
await expect(registerGatewayClient(plugin)).rejects.toThrow(
|
||||||
"Failed to get gateway information from Discord: fetch failed",
|
"Failed to get gateway information from Discord",
|
||||||
);
|
);
|
||||||
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
|
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectGatewayRegisterFallback(response: Response) {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
globalFetchMock.mockResolvedValue(response);
|
||||||
|
const plugin = createDiscordGatewayPlugin({
|
||||||
|
discordConfig: {},
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerGatewayClient(plugin);
|
||||||
|
|
||||||
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
||||||
|
"wss://gateway.discord.gg/",
|
||||||
|
);
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("discord: gateway metadata lookup failed transiently"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function registerGatewayClientWithMetadata(params: {
|
async function registerGatewayClientWithMetadata(params: {
|
||||||
plugin: unknown;
|
plugin: unknown;
|
||||||
fetchMock: typeof globalFetchMock;
|
fetchMock: typeof globalFetchMock;
|
||||||
@ -161,6 +180,7 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal("fetch", globalFetchMock);
|
vi.stubGlobal("fetch", globalFetchMock);
|
||||||
|
vi.useRealTimers();
|
||||||
baseRegisterClientSpy.mockClear();
|
baseRegisterClientSpy.mockClear();
|
||||||
globalFetchMock.mockClear();
|
globalFetchMock.mockClear();
|
||||||
restProxyAgentSpy.mockClear();
|
restProxyAgentSpy.mockClear();
|
||||||
@ -190,7 +210,7 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps plain-text Discord 503 responses to fetch failed", async () => {
|
it("maps plain-text Discord 503 responses to fetch failed", async () => {
|
||||||
await expectGatewayRegisterFetchFailure({
|
await expectGatewayRegisterFallback({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 503,
|
status: 503,
|
||||||
text: async () =>
|
text: async () =>
|
||||||
@ -198,6 +218,14 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
} as Response);
|
} as Response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps fatal Discord metadata failures fatal", async () => {
|
||||||
|
await expectGatewayRegisterFetchFailure({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: async () => "401: Unauthorized",
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
|
|
||||||
@ -255,7 +283,7 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps body read failures to fetch failed", async () => {
|
it("maps body read failures to fetch failed", async () => {
|
||||||
await expectGatewayRegisterFetchFailure({
|
await expectGatewayRegisterFallback({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
text: async () => {
|
text: async () => {
|
||||||
@ -263,4 +291,68 @@ describe("createDiscordGatewayPlugin", () => {
|
|||||||
},
|
},
|
||||||
} as unknown as Response);
|
} as unknown as Response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the default gateway url when metadata lookup times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const runtime = createRuntime();
|
||||||
|
globalFetchMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
const plugin = createDiscordGatewayPlugin({
|
||||||
|
discordConfig: {},
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerPromise = registerGatewayClient(plugin);
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
await registerPromise;
|
||||||
|
|
||||||
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
||||||
|
"wss://gateway.discord.gg/",
|
||||||
|
);
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("discord: gateway metadata lookup failed transiently"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes fallback gateway metadata on the next register attempt", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
globalFetchMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
text: async () =>
|
||||||
|
"upstream connect error or disconnect/reset before headers. reset reason: overflow",
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({
|
||||||
|
url: "wss://gateway.discord.gg/?v=10",
|
||||||
|
shards: 8,
|
||||||
|
session_start_limit: {
|
||||||
|
total: 1000,
|
||||||
|
remaining: 999,
|
||||||
|
reset_after: 120_000,
|
||||||
|
max_concurrency: 16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
const plugin = createDiscordGatewayPlugin({
|
||||||
|
discordConfig: {},
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerGatewayClient(plugin);
|
||||||
|
await registerGatewayClient(plugin);
|
||||||
|
|
||||||
|
expect(globalFetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(
|
||||||
|
(plugin as unknown as { gatewayInfo?: { url?: string; shards?: number } }).gatewayInfo,
|
||||||
|
).toMatchObject({
|
||||||
|
url: "wss://gateway.discord.gg/?v=10",
|
||||||
|
shards: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { ChannelType } from "discord-api-types/v10";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
clearRuntimeConfigSnapshot,
|
clearRuntimeConfigSnapshot,
|
||||||
@ -12,12 +13,12 @@ import { getSessionBindingService } from "../../../../src/infra/outbound/session
|
|||||||
const hoisted = vi.hoisted(() => {
|
const hoisted = vi.hoisted(() => {
|
||||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||||
const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
|
const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
|
||||||
const restGet = vi.fn(async () => ({
|
const restGet = vi.fn(async (..._args: unknown[]) => ({
|
||||||
id: "thread-1",
|
id: "thread-1",
|
||||||
type: 11,
|
type: 11,
|
||||||
parent_id: "parent-1",
|
parent_id: "parent-1",
|
||||||
}));
|
}));
|
||||||
const restPost = vi.fn(async () => ({
|
const restPost = vi.fn(async (..._args: unknown[]) => ({
|
||||||
id: "wh-created",
|
id: "wh-created",
|
||||||
token: "tok-created",
|
token: "tok-created",
|
||||||
}));
|
}));
|
||||||
@ -45,47 +46,151 @@ vi.mock("../send.js", () => ({
|
|||||||
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
|
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../client.js", () => ({
|
|
||||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../send.messages.js", () => ({
|
vi.mock("../send.messages.js", () => ({
|
||||||
createThreadDiscord: hoisted.createThreadDiscord,
|
createThreadDiscord: hoisted.createThreadDiscord,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../../src/acp/runtime/session-meta.js", async (importOriginal) => {
|
const { __testing, createThreadBindingManager } = await import("./thread-bindings.manager.js");
|
||||||
const actual =
|
|
||||||
await importOriginal<typeof import("../../../../src/acp/runtime/session-meta.js")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readAcpSessionEntry: hoisted.readAcpSessionEntry,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
__testing,
|
|
||||||
autoBindSpawnedDiscordSubagent,
|
autoBindSpawnedDiscordSubagent,
|
||||||
createThreadBindingManager,
|
|
||||||
reconcileAcpThreadBindingsOnStartup,
|
reconcileAcpThreadBindingsOnStartup,
|
||||||
resolveThreadBindingInactivityExpiresAt,
|
|
||||||
resolveThreadBindingIntroText,
|
|
||||||
resolveThreadBindingMaxAgeExpiresAt,
|
|
||||||
setThreadBindingIdleTimeoutBySessionKey,
|
setThreadBindingIdleTimeoutBySessionKey,
|
||||||
setThreadBindingMaxAgeBySessionKey,
|
setThreadBindingMaxAgeBySessionKey,
|
||||||
unbindThreadBindingsBySessionKey,
|
unbindThreadBindingsBySessionKey,
|
||||||
} = await import("./thread-bindings.js");
|
} = await import("./thread-bindings.lifecycle.js");
|
||||||
|
const { resolveThreadBindingInactivityExpiresAt, resolveThreadBindingMaxAgeExpiresAt } =
|
||||||
|
await import("./thread-bindings.state.js");
|
||||||
|
const { resolveThreadBindingIntroText } = await import("./thread-bindings.messages.js");
|
||||||
|
const discordClientModule = await import("../client.js");
|
||||||
|
const discordThreadBindingApi = await import("./thread-bindings.discord-api.js");
|
||||||
|
const acpRuntime = await import("openclaw/plugin-sdk/acp-runtime");
|
||||||
|
|
||||||
describe("thread binding lifecycle", () => {
|
describe("thread binding lifecycle", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__testing.resetThreadBindingsForTests();
|
__testing.resetThreadBindingsForTests();
|
||||||
clearRuntimeConfigSnapshot();
|
clearRuntimeConfigSnapshot();
|
||||||
hoisted.sendMessageDiscord.mockClear();
|
vi.restoreAllMocks();
|
||||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
hoisted.sendMessageDiscord.mockReset().mockResolvedValue({});
|
||||||
hoisted.restGet.mockClear();
|
hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({});
|
||||||
hoisted.restPost.mockClear();
|
hoisted.restGet.mockReset().mockResolvedValue({
|
||||||
hoisted.createDiscordRestClient.mockClear();
|
id: "thread-1",
|
||||||
hoisted.createThreadDiscord.mockClear();
|
type: 11,
|
||||||
|
parent_id: "parent-1",
|
||||||
|
});
|
||||||
|
hoisted.restPost.mockReset().mockResolvedValue({
|
||||||
|
id: "wh-created",
|
||||||
|
token: "tok-created",
|
||||||
|
});
|
||||||
|
hoisted.createDiscordRestClient.mockReset().mockImplementation((..._args: unknown[]) => ({
|
||||||
|
rest: {
|
||||||
|
get: hoisted.restGet,
|
||||||
|
post: hoisted.restPost,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
hoisted.createThreadDiscord.mockReset().mockResolvedValue({ id: "thread-created" });
|
||||||
hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null);
|
hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null);
|
||||||
|
vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation(
|
||||||
|
(...args) =>
|
||||||
|
hoisted.createDiscordRestClient(...args) as unknown as ReturnType<
|
||||||
|
typeof discordClientModule.createDiscordRestClient
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
vi.spyOn(discordThreadBindingApi, "createWebhookForChannel").mockImplementation(
|
||||||
|
async (params) => {
|
||||||
|
const rest = hoisted.createDiscordRestClient(
|
||||||
|
{
|
||||||
|
accountId: params.accountId,
|
||||||
|
token: params.token,
|
||||||
|
},
|
||||||
|
params.cfg,
|
||||||
|
).rest;
|
||||||
|
const created = (await rest.post("mock:channel-webhook")) as {
|
||||||
|
id?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
webhookId: typeof created?.id === "string" ? created.id.trim() || undefined : undefined,
|
||||||
|
webhookToken:
|
||||||
|
typeof created?.token === "string" ? created.token.trim() || undefined : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.spyOn(discordThreadBindingApi, "resolveChannelIdForBinding").mockImplementation(
|
||||||
|
async (params) => {
|
||||||
|
const explicit = params.channelId?.trim();
|
||||||
|
if (explicit) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
const rest = hoisted.createDiscordRestClient(
|
||||||
|
{
|
||||||
|
accountId: params.accountId,
|
||||||
|
token: params.token,
|
||||||
|
},
|
||||||
|
params.cfg,
|
||||||
|
).rest;
|
||||||
|
const channel = (await rest.get("mock:channel-resolve")) as {
|
||||||
|
id?: string;
|
||||||
|
type?: number;
|
||||||
|
parent_id?: string;
|
||||||
|
parentId?: string;
|
||||||
|
};
|
||||||
|
const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
|
||||||
|
const parentId =
|
||||||
|
typeof channel?.parent_id === "string"
|
||||||
|
? channel.parent_id.trim()
|
||||||
|
: typeof channel?.parentId === "string"
|
||||||
|
? channel.parentId.trim()
|
||||||
|
: "";
|
||||||
|
const isThreadType =
|
||||||
|
channel?.type === ChannelType.PublicThread ||
|
||||||
|
channel?.type === ChannelType.PrivateThread ||
|
||||||
|
channel?.type === ChannelType.AnnouncementThread;
|
||||||
|
if (parentId && isThreadType) {
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
return channelId || null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.spyOn(discordThreadBindingApi, "createThreadForBinding").mockImplementation(
|
||||||
|
async (params) => {
|
||||||
|
const created = await hoisted.createThreadDiscord(
|
||||||
|
params.channelId,
|
||||||
|
{
|
||||||
|
name: params.threadName,
|
||||||
|
autoArchiveMinutes: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: params.accountId,
|
||||||
|
token: params.token,
|
||||||
|
cfg: params.cfg,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return typeof created?.id === "string" ? created.id.trim() || null : null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.spyOn(discordThreadBindingApi, "maybeSendBindingMessage").mockImplementation(
|
||||||
|
async (params) => {
|
||||||
|
if (
|
||||||
|
params.preferWebhook !== false &&
|
||||||
|
params.record.webhookId &&
|
||||||
|
params.record.webhookToken
|
||||||
|
) {
|
||||||
|
await hoisted.sendWebhookMessageDiscord(params.text, {
|
||||||
|
cfg: params.cfg,
|
||||||
|
webhookId: params.record.webhookId,
|
||||||
|
webhookToken: params.record.webhookToken,
|
||||||
|
accountId: params.record.accountId,
|
||||||
|
threadId: params.record.threadId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await hoisted.sendMessageDiscord(`channel:${params.record.threadId}`, params.text, {
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.record.accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.spyOn(acpRuntime, "readAcpSessionEntry").mockImplementation(hoisted.readAcpSessionEntry);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -93,7 +198,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
createThreadBindingManager({
|
createThreadBindingManager({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
persist: false,
|
persist: false,
|
||||||
enableSweeper: true,
|
enableSweeper: false,
|
||||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||||
maxAgeMs: 0,
|
maxAgeMs: 0,
|
||||||
});
|
});
|
||||||
@ -139,7 +244,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
const manager = createThreadBindingManager({
|
const manager = createThreadBindingManager({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
persist: false,
|
persist: false,
|
||||||
enableSweeper: true,
|
enableSweeper: false,
|
||||||
idleTimeoutMs: 60_000,
|
idleTimeoutMs: 60_000,
|
||||||
maxAgeMs: 0,
|
maxAgeMs: 0,
|
||||||
});
|
});
|
||||||
@ -159,6 +264,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await __testing.runThreadBindingSweepForAccount("default");
|
||||||
|
|
||||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||||
@ -177,7 +283,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
const manager = createThreadBindingManager({
|
const manager = createThreadBindingManager({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
persist: false,
|
persist: false,
|
||||||
enableSweeper: true,
|
enableSweeper: false,
|
||||||
idleTimeoutMs: 0,
|
idleTimeoutMs: 0,
|
||||||
maxAgeMs: 60_000,
|
maxAgeMs: 60_000,
|
||||||
});
|
});
|
||||||
@ -195,6 +301,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
hoisted.sendMessageDiscord.mockClear();
|
hoisted.sendMessageDiscord.mockClear();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await __testing.runThreadBindingSweepForAccount("default");
|
||||||
|
|
||||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||||
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
|
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
|
||||||
@ -214,6 +321,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
|
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await __testing.runThreadBindingSweepForAccount("default");
|
||||||
|
|
||||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||||
@ -234,6 +342,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await __testing.runThreadBindingSweepForAccount("default");
|
||||||
|
|
||||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||||
@ -334,7 +443,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
const manager = createThreadBindingManager({
|
const manager = createThreadBindingManager({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
persist: false,
|
persist: false,
|
||||||
enableSweeper: true,
|
enableSweeper: false,
|
||||||
idleTimeoutMs: 60_000,
|
idleTimeoutMs: 60_000,
|
||||||
maxAgeMs: 0,
|
maxAgeMs: 0,
|
||||||
});
|
});
|
||||||
@ -358,6 +467,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
expect(updated[0]?.idleTimeoutMs).toBe(0);
|
expect(updated[0]?.idleTimeoutMs).toBe(0);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(240_000);
|
await vi.advanceTimersByTimeAsync(240_000);
|
||||||
|
await __testing.runThreadBindingSweepForAccount("default");
|
||||||
|
|
||||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||||
} finally {
|
} finally {
|
||||||
@ -371,7 +481,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
const manager = createThreadBindingManager({
|
const manager = createThreadBindingManager({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
persist: false,
|
persist: false,
|
||||||
enableSweeper: true,
|
enableSweeper: false,
|
||||||
idleTimeoutMs: 60_000,
|
idleTimeoutMs: 60_000,
|
||||||
maxAgeMs: 0,
|
maxAgeMs: 0,
|
||||||
});
|
});
|
||||||
@ -417,6 +527,7 @@ describe("thread binding lifecycle", () => {
|
|||||||
hoisted.sendMessageDiscord.mockClear();
|
hoisted.sendMessageDiscord.mockClear();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await __testing.runThreadBindingSweepForAccount("default");
|
||||||
|
|
||||||
expect(manager.getByThreadId("thread-2")).toBeDefined();
|
expect(manager.getByThreadId("thread-2")).toBeDefined();
|
||||||
expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
|
expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
|
||||||
|
|||||||
@ -69,6 +69,8 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SWEEPERS_BY_ACCOUNT_ID = new Map<string, () => Promise<void>>();
|
||||||
|
|
||||||
function resolveEffectiveBindingExpiresAt(params: {
|
function resolveEffectiveBindingExpiresAt(params: {
|
||||||
record: ThreadBindingRecord;
|
record: ThreadBindingRecord;
|
||||||
defaultIdleTimeoutMs: number;
|
defaultIdleTimeoutMs: number;
|
||||||
@ -200,6 +202,111 @@ export function createThreadBindingManager(
|
|||||||
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
|
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
|
||||||
|
|
||||||
let sweepTimer: NodeJS.Timeout | null = null;
|
let sweepTimer: NodeJS.Timeout | null = null;
|
||||||
|
const runSweepOnce = async () => {
|
||||||
|
const bindings = manager.listBindings();
|
||||||
|
if (bindings.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rest: ReturnType<typeof createDiscordRestClient>["rest"] | null = null;
|
||||||
|
for (const snapshotBinding of bindings) {
|
||||||
|
// Re-read live state after any awaited work from earlier iterations.
|
||||||
|
// This avoids unbinding based on stale snapshot data when activity touches
|
||||||
|
// happen while the sweeper loop is in-flight.
|
||||||
|
const binding = manager.getByThreadId(snapshotBinding.threadId);
|
||||||
|
if (!binding) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
||||||
|
record: binding,
|
||||||
|
defaultIdleTimeoutMs: idleTimeoutMs,
|
||||||
|
});
|
||||||
|
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
||||||
|
record: binding,
|
||||||
|
defaultMaxAgeMs: maxAgeMs,
|
||||||
|
});
|
||||||
|
const expirationCandidates: Array<{
|
||||||
|
reason: "idle-expired" | "max-age-expired";
|
||||||
|
at: number;
|
||||||
|
}> = [];
|
||||||
|
if (inactivityExpiresAt != null && now >= inactivityExpiresAt) {
|
||||||
|
expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt });
|
||||||
|
}
|
||||||
|
if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) {
|
||||||
|
expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt });
|
||||||
|
}
|
||||||
|
if (expirationCandidates.length > 0) {
|
||||||
|
expirationCandidates.sort((a, b) => a.at - b.at);
|
||||||
|
const reason = expirationCandidates[0]?.reason ?? "idle-expired";
|
||||||
|
manager.unbindThread({
|
||||||
|
threadId: binding.threadId,
|
||||||
|
reason,
|
||||||
|
sendFarewell: true,
|
||||||
|
farewellText: resolveThreadBindingFarewellText({
|
||||||
|
reason,
|
||||||
|
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
|
||||||
|
record: binding,
|
||||||
|
defaultIdleTimeoutMs: idleTimeoutMs,
|
||||||
|
}),
|
||||||
|
maxAgeMs: resolveThreadBindingMaxAgeMs({
|
||||||
|
record: binding,
|
||||||
|
defaultMaxAgeMs: maxAgeMs,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isDirectConversationBindingId(binding.threadId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!rest) {
|
||||||
|
try {
|
||||||
|
const cfg = resolveCurrentCfg();
|
||||||
|
rest = createDiscordRestClient(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
token: resolveCurrentToken(),
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
).rest;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||||
|
if (!channel || typeof channel !== "object") {
|
||||||
|
logVerbose(
|
||||||
|
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isThreadArchived(channel)) {
|
||||||
|
manager.unbindThread({
|
||||||
|
threadId: binding.threadId,
|
||||||
|
reason: "thread-archived",
|
||||||
|
sendFarewell: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isDiscordThreadGoneError(err)) {
|
||||||
|
logVerbose(
|
||||||
|
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||||
|
);
|
||||||
|
manager.unbindThread({
|
||||||
|
threadId: binding.threadId,
|
||||||
|
reason: "thread-delete",
|
||||||
|
sendFarewell: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logVerbose(
|
||||||
|
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SWEEPERS_BY_ACCOUNT_ID.set(accountId, runSweepOnce);
|
||||||
|
|
||||||
const manager: ThreadBindingManager = {
|
const manager: ThreadBindingManager = {
|
||||||
accountId,
|
accountId,
|
||||||
@ -444,6 +551,7 @@ export function createThreadBindingManager(
|
|||||||
clearInterval(sweepTimer);
|
clearInterval(sweepTimer);
|
||||||
sweepTimer = null;
|
sweepTimer = null;
|
||||||
}
|
}
|
||||||
|
SWEEPERS_BY_ACCOUNT_ID.delete(accountId);
|
||||||
unregisterManager(accountId, manager);
|
unregisterManager(accountId, manager);
|
||||||
unregisterSessionBindingAdapter({
|
unregisterSessionBindingAdapter({
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@ -455,110 +563,13 @@ export function createThreadBindingManager(
|
|||||||
|
|
||||||
if (params.enableSweeper !== false) {
|
if (params.enableSweeper !== false) {
|
||||||
sweepTimer = setInterval(() => {
|
sweepTimer = setInterval(() => {
|
||||||
void (async () => {
|
void runSweepOnce();
|
||||||
const bindings = manager.listBindings();
|
|
||||||
if (bindings.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rest;
|
|
||||||
try {
|
|
||||||
const cfg = resolveCurrentCfg();
|
|
||||||
rest = createDiscordRestClient(
|
|
||||||
{
|
|
||||||
accountId,
|
|
||||||
token: resolveCurrentToken(),
|
|
||||||
},
|
|
||||||
cfg,
|
|
||||||
).rest;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const snapshotBinding of bindings) {
|
|
||||||
// Re-read live state after any awaited work from earlier iterations.
|
|
||||||
// This avoids unbinding based on stale snapshot data when activity touches
|
|
||||||
// happen while the sweeper loop is in-flight.
|
|
||||||
const binding = manager.getByThreadId(snapshotBinding.threadId);
|
|
||||||
if (!binding) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
|
||||||
record: binding,
|
|
||||||
defaultIdleTimeoutMs: idleTimeoutMs,
|
|
||||||
});
|
|
||||||
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
|
||||||
record: binding,
|
|
||||||
defaultMaxAgeMs: maxAgeMs,
|
|
||||||
});
|
|
||||||
const expirationCandidates: Array<{
|
|
||||||
reason: "idle-expired" | "max-age-expired";
|
|
||||||
at: number;
|
|
||||||
}> = [];
|
|
||||||
if (inactivityExpiresAt != null && now >= inactivityExpiresAt) {
|
|
||||||
expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt });
|
|
||||||
}
|
|
||||||
if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) {
|
|
||||||
expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt });
|
|
||||||
}
|
|
||||||
if (expirationCandidates.length > 0) {
|
|
||||||
expirationCandidates.sort((a, b) => a.at - b.at);
|
|
||||||
const reason = expirationCandidates[0]?.reason ?? "idle-expired";
|
|
||||||
manager.unbindThread({
|
|
||||||
threadId: binding.threadId,
|
|
||||||
reason,
|
|
||||||
sendFarewell: true,
|
|
||||||
farewellText: resolveThreadBindingFarewellText({
|
|
||||||
reason,
|
|
||||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
|
|
||||||
record: binding,
|
|
||||||
defaultIdleTimeoutMs: idleTimeoutMs,
|
|
||||||
}),
|
|
||||||
maxAgeMs: resolveThreadBindingMaxAgeMs({
|
|
||||||
record: binding,
|
|
||||||
defaultMaxAgeMs: maxAgeMs,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isDirectConversationBindingId(binding.threadId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
|
||||||
if (!channel || typeof channel !== "object") {
|
|
||||||
logVerbose(
|
|
||||||
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isThreadArchived(channel)) {
|
|
||||||
manager.unbindThread({
|
|
||||||
threadId: binding.threadId,
|
|
||||||
reason: "thread-archived",
|
|
||||||
sendFarewell: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isDiscordThreadGoneError(err)) {
|
|
||||||
logVerbose(
|
|
||||||
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
|
||||||
);
|
|
||||||
manager.unbindThread({
|
|
||||||
threadId: binding.threadId,
|
|
||||||
reason: "thread-delete",
|
|
||||||
sendFarewell: false,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
logVerbose(
|
|
||||||
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, THREAD_BINDINGS_SWEEP_INTERVAL_MS);
|
}, THREAD_BINDINGS_SWEEP_INTERVAL_MS);
|
||||||
sweepTimer.unref?.();
|
// Keep the production process free to exit, but avoid breaking fake-timer
|
||||||
|
// sweeper tests where unref'd intervals may never fire.
|
||||||
|
if (!(process.env.VITEST || process.env.NODE_ENV === "test")) {
|
||||||
|
sweepTimer.unref?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerSessionBindingAdapter({
|
registerSessionBindingAdapter({
|
||||||
@ -690,4 +701,10 @@ export const __testing = {
|
|||||||
resolveThreadBindingsPath,
|
resolveThreadBindingsPath,
|
||||||
resolveThreadBindingThreadName,
|
resolveThreadBindingThreadName,
|
||||||
resetThreadBindingsForTests,
|
resetThreadBindingsForTests,
|
||||||
|
runThreadBindingSweepForAccount: async (accountId?: string) => {
|
||||||
|
const sweep = SWEEPERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId));
|
||||||
|
if (sweep) {
|
||||||
|
await sweep();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,10 +6,14 @@ const hoisted = vi.hoisted(() => {
|
|||||||
return { updateSessionStore, resolveStorePath };
|
return { updateSessionStore, resolveStorePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../../../src/config/sessions.js", () => ({
|
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||||
updateSessionStore: hoisted.updateSessionStore,
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
resolveStorePath: hoisted.resolveStorePath,
|
return {
|
||||||
}));
|
...actual,
|
||||||
|
updateSessionStore: hoisted.updateSessionStore,
|
||||||
|
resolveStorePath: hoisted.resolveStorePath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
|
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
|
||||||
|
|
||||||
|
|||||||
@ -21,8 +21,8 @@ const {
|
|||||||
mockResolveAgentRoute,
|
mockResolveAgentRoute,
|
||||||
mockReadSessionUpdatedAt,
|
mockReadSessionUpdatedAt,
|
||||||
mockResolveStorePath,
|
mockResolveStorePath,
|
||||||
mockResolveConfiguredAcpRoute,
|
mockResolveConfiguredBindingRoute,
|
||||||
mockEnsureConfiguredAcpRouteReady,
|
mockEnsureConfiguredBindingRouteReady,
|
||||||
mockResolveBoundConversation,
|
mockResolveBoundConversation,
|
||||||
mockTouchBinding,
|
mockTouchBinding,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
@ -50,11 +50,12 @@ const {
|
|||||||
})),
|
})),
|
||||||
mockReadSessionUpdatedAt: vi.fn(),
|
mockReadSessionUpdatedAt: vi.fn(),
|
||||||
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||||
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
|
mockResolveConfiguredBindingRoute: vi.fn(({ route }) => ({
|
||||||
|
bindingResolution: null,
|
||||||
configuredBinding: null,
|
configuredBinding: null,
|
||||||
route,
|
route,
|
||||||
})),
|
})),
|
||||||
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
mockEnsureConfiguredBindingRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
||||||
mockResolveBoundConversation: vi.fn(() => null),
|
mockResolveBoundConversation: vi.fn(() => null),
|
||||||
mockTouchBinding: vi.fn(),
|
mockTouchBinding: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@ -78,12 +79,12 @@ vi.mock("./client.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||||
const original =
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
|
||||||
return {
|
return {
|
||||||
...original,
|
...actual,
|
||||||
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
|
resolveConfiguredBindingRoute: (params: unknown) => mockResolveConfiguredBindingRoute(params),
|
||||||
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
|
ensureConfiguredBindingRouteReady: (params: unknown) =>
|
||||||
|
mockEnsureConfiguredBindingRouteReady(params),
|
||||||
getSessionBindingService: () => ({
|
getSessionBindingService: () => ({
|
||||||
resolveByConversation: mockResolveBoundConversation,
|
resolveByConversation: mockResolveBoundConversation,
|
||||||
touch: mockTouchBinding,
|
touch: mockTouchBinding,
|
||||||
@ -91,6 +92,13 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||||
|
getSessionBindingService: () => ({
|
||||||
|
resolveByConversation: mockResolveBoundConversation,
|
||||||
|
touch: mockTouchBinding,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
function createRuntimeEnv(): RuntimeEnv {
|
function createRuntimeEnv(): RuntimeEnv {
|
||||||
return {
|
return {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@ -138,14 +146,15 @@ describe("buildFeishuAgentBody", () => {
|
|||||||
describe("handleFeishuMessage ACP routing", () => {
|
describe("handleFeishuMessage ACP routing", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
||||||
({ route }) =>
|
({ route }) =>
|
||||||
({
|
({
|
||||||
|
bindingResolution: null,
|
||||||
configuredBinding: null,
|
configuredBinding: null,
|
||||||
route,
|
route,
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||||
mockTouchBinding.mockReset();
|
mockTouchBinding.mockReset();
|
||||||
mockResolveAgentRoute.mockReset().mockReturnValue({
|
mockResolveAgentRoute.mockReset().mockReturnValue({
|
||||||
@ -218,7 +227,37 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ensures configured ACP routes for Feishu DMs", async () => {
|
it("ensures configured ACP routes for Feishu DMs", async () => {
|
||||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
mockResolveConfiguredBindingRoute.mockReturnValue({
|
||||||
|
bindingResolution: {
|
||||||
|
configuredBinding: {
|
||||||
|
spec: {
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "ou_sender_1",
|
||||||
|
agentId: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||||
|
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||||
|
targetKind: "session",
|
||||||
|
conversation: {
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "ou_sender_1",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
boundAt: 0,
|
||||||
|
metadata: { source: "config" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
},
|
||||||
configuredBinding: {
|
configuredBinding: {
|
||||||
spec: {
|
spec: {
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
@ -268,12 +307,42 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
|
expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
||||||
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
|
expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
||||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
mockResolveConfiguredBindingRoute.mockReturnValue({
|
||||||
|
bindingResolution: {
|
||||||
|
configuredBinding: {
|
||||||
|
spec: {
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "ou_sender_1",
|
||||||
|
agentId: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||||
|
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||||
|
targetKind: "session",
|
||||||
|
conversation: {
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "ou_sender_1",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
boundAt: 0,
|
||||||
|
metadata: { source: "config" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
},
|
||||||
configuredBinding: {
|
configuredBinding: {
|
||||||
spec: {
|
spec: {
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
@ -305,7 +374,7 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||||||
matchedBy: "binding.channel",
|
matchedBy: "binding.channel",
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
|
mockEnsureConfiguredBindingRouteReady.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "runtime unavailable",
|
error: "runtime unavailable",
|
||||||
} as any);
|
} as any);
|
||||||
@ -433,14 +502,15 @@ describe("handleFeishuMessage command authorization", () => {
|
|||||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||||
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||||
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
||||||
({ route }) =>
|
({ route }) =>
|
||||||
({
|
({
|
||||||
|
bindingResolution: null,
|
||||||
configuredBinding: null,
|
configuredBinding: null,
|
||||||
route,
|
route,
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||||
mockTouchBinding.mockReset();
|
mockTouchBinding.mockReset();
|
||||||
mockResolveAgentRoute.mockReturnValue({
|
mockResolveAgentRoute.mockReturnValue({
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ensureConfiguredAcpRouteReady,
|
ensureConfiguredBindingRouteReady,
|
||||||
resolveConfiguredAcpRoute,
|
resolveConfiguredBindingRoute,
|
||||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
||||||
@ -1251,15 +1251,17 @@ export async function handleFeishuMessage(params: {
|
|||||||
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
|
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
|
||||||
let configuredBinding = null;
|
let configuredBinding = null;
|
||||||
if (feishuAcpConversationSupported) {
|
if (feishuAcpConversationSupported) {
|
||||||
const configuredRoute = resolveConfiguredAcpRoute({
|
const configuredRoute = resolveConfiguredBindingRoute({
|
||||||
cfg: effectiveCfg,
|
cfg: effectiveCfg,
|
||||||
route,
|
route,
|
||||||
channel: "feishu",
|
conversation: {
|
||||||
accountId: account.accountId,
|
channel: "feishu",
|
||||||
conversationId: currentConversationId,
|
accountId: account.accountId,
|
||||||
parentConversationId,
|
conversationId: currentConversationId,
|
||||||
|
parentConversationId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
configuredBinding = configuredRoute.configuredBinding;
|
configuredBinding = configuredRoute.bindingResolution;
|
||||||
route = configuredRoute.route;
|
route = configuredRoute.route;
|
||||||
|
|
||||||
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
||||||
@ -1292,9 +1294,9 @@ export async function handleFeishuMessage(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (configuredBinding) {
|
if (configuredBinding) {
|
||||||
const ensured = await ensureConfiguredAcpRouteReady({
|
const ensured = await ensureConfiguredBindingRouteReady({
|
||||||
cfg: effectiveCfg,
|
cfg: effectiveCfg,
|
||||||
configuredBinding,
|
bindingResolution: configuredBinding,
|
||||||
});
|
});
|
||||||
if (!ensured.ok) {
|
if (!ensured.ok) {
|
||||||
const replyTargetMessageId =
|
const replyTargetMessageId =
|
||||||
|
|||||||
@ -822,11 +822,15 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
acpBindings: {
|
bindings: {
|
||||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
compileConfiguredBinding: ({ conversationId }) =>
|
||||||
normalizeFeishuAcpConversationId(conversationId),
|
normalizeFeishuAcpConversationId(conversationId),
|
||||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||||
matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
matchFeishuAcpConversation({
|
||||||
|
bindingConversationId: compiledBinding.conversationId,
|
||||||
|
conversationId,
|
||||||
|
parentConversationId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
setup: feishuSetupAdapter,
|
setup: feishuSetupAdapter,
|
||||||
setupWizard: feishuSetupWizard,
|
setupWizard: feishuSetupWizard,
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const clientCtorMock = vi.hoisted(() => vi.fn());
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||||
const mockBaseHttpInstance = vi.hoisted(() => ({
|
|
||||||
request: vi.fn().mockResolvedValue({}),
|
vi.mock("./client.js", () => ({
|
||||||
get: vi.fn().mockResolvedValue({}),
|
createFeishuClient: createFeishuClientMock,
|
||||||
post: vi.fn().mockResolvedValue({}),
|
|
||||||
put: vi.fn().mockResolvedValue({}),
|
|
||||||
patch: vi.fn().mockResolvedValue({}),
|
|
||||||
delete: vi.fn().mockResolvedValue({}),
|
|
||||||
head: vi.fn().mockResolvedValue({}),
|
|
||||||
options: vi.fn().mockResolvedValue({}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js";
|
async function importProbeModule(scope: string) {
|
||||||
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
|
void scope;
|
||||||
|
vi.resetModules();
|
||||||
|
return await import("./probe.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
let FEISHU_PROBE_REQUEST_TIMEOUT_MS: typeof import("./probe.js").FEISHU_PROBE_REQUEST_TIMEOUT_MS;
|
||||||
|
let probeFeishu: typeof import("./probe.js").probeFeishu;
|
||||||
|
let clearProbeCache: typeof import("./probe.js").clearProbeCache;
|
||||||
|
|
||||||
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
|
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
|
||||||
const DEFAULT_SUCCESS_RESPONSE = {
|
const DEFAULT_SUCCESS_RESPONSE = {
|
||||||
@ -35,15 +36,9 @@ function makeRequestFn(response: Record<string, unknown>) {
|
|||||||
return vi.fn().mockResolvedValue(response);
|
return vi.fn().mockResolvedValue(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
function installClientCtor(requestFn: unknown) {
|
|
||||||
clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) {
|
|
||||||
this.request = requestFn;
|
|
||||||
} as never);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupClient(response: Record<string, unknown>) {
|
function setupClient(response: Record<string, unknown>) {
|
||||||
const requestFn = makeRequestFn(response);
|
const requestFn = makeRequestFn(response);
|
||||||
installClientCtor(requestFn);
|
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||||
return requestFn;
|
return requestFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +48,12 @@ function setupSuccessClient() {
|
|||||||
|
|
||||||
async function expectDefaultSuccessResult(
|
async function expectDefaultSuccessResult(
|
||||||
creds = DEFAULT_CREDS,
|
creds = DEFAULT_CREDS,
|
||||||
expected: Awaited<ReturnType<typeof probeFeishu>> = DEFAULT_SUCCESS_RESULT,
|
expected: {
|
||||||
|
ok: true;
|
||||||
|
appId: string;
|
||||||
|
botName: string;
|
||||||
|
botOpenId: string;
|
||||||
|
} = DEFAULT_SUCCESS_RESULT,
|
||||||
) {
|
) {
|
||||||
const result = await probeFeishu(creds);
|
const result = await probeFeishu(creds);
|
||||||
expect(result).toEqual(expected);
|
expect(result).toEqual(expected);
|
||||||
@ -73,7 +73,7 @@ async function expectErrorResultCached(params: {
|
|||||||
expectedError: string;
|
expectedError: string;
|
||||||
ttlMs: number;
|
ttlMs: number;
|
||||||
}) {
|
}) {
|
||||||
installClientCtor(params.requestFn);
|
createFeishuClientMock.mockReturnValue({ request: params.requestFn });
|
||||||
|
|
||||||
const first = await probeFeishu(DEFAULT_CREDS);
|
const first = await probeFeishu(DEFAULT_CREDS);
|
||||||
const second = await probeFeishu(DEFAULT_CREDS);
|
const second = await probeFeishu(DEFAULT_CREDS);
|
||||||
@ -106,27 +106,16 @@ async function readSequentialDefaultProbePair() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("probeFeishu", () => {
|
describe("probeFeishu", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
({ FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } = await importProbeModule(
|
||||||
|
`probe-${Date.now()}-${Math.random()}`,
|
||||||
|
));
|
||||||
clearProbeCache();
|
clearProbeCache();
|
||||||
clearClientCache();
|
vi.restoreAllMocks();
|
||||||
vi.clearAllMocks();
|
|
||||||
setFeishuClientRuntimeForTest({
|
|
||||||
sdk: {
|
|
||||||
AppType: { SelfBuild: "self" } as never,
|
|
||||||
Domain: {
|
|
||||||
Feishu: "https://open.feishu.cn",
|
|
||||||
Lark: "https://open.larksuite.com",
|
|
||||||
} as never,
|
|
||||||
Client: clientCtorMock as never,
|
|
||||||
defaultHttpInstance: mockBaseHttpInstance as never,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearProbeCache();
|
clearProbeCache();
|
||||||
clearClientCache();
|
|
||||||
setFeishuClientRuntimeForTest();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when credentials are missing", async () => {
|
it("returns error when credentials are missing", async () => {
|
||||||
@ -168,7 +157,7 @@ describe("probeFeishu", () => {
|
|||||||
it("returns timeout error when request exceeds timeout", async () => {
|
it("returns timeout error when request exceeds timeout", async () => {
|
||||||
await withFakeTimers(async () => {
|
await withFakeTimers(async () => {
|
||||||
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
|
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
|
||||||
installClientCtor(requestFn);
|
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||||
|
|
||||||
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
|
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
@ -179,6 +168,7 @@ describe("probeFeishu", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns aborted when abort signal is already aborted", async () => {
|
it("returns aborted when abort signal is already aborted", async () => {
|
||||||
|
createFeishuClientMock.mockClear();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
|
||||||
@ -188,7 +178,7 @@ describe("probeFeishu", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
|
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
|
||||||
expect(clientCtorMock).not.toHaveBeenCalled();
|
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("returns cached result on subsequent calls within TTL", async () => {
|
it("returns cached result on subsequent calls within TTL", async () => {
|
||||||
const requestFn = setupSuccessClient();
|
const requestFn = setupSuccessClient();
|
||||||
|
|||||||
@ -49,6 +49,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
|||||||
registerImageGenerationProvider() {},
|
registerImageGenerationProvider() {},
|
||||||
registerWebSearchProvider() {},
|
registerWebSearchProvider() {},
|
||||||
registerInteractiveHandler() {},
|
registerInteractiveHandler() {},
|
||||||
|
onConversationBindingResolved() {},
|
||||||
registerHook() {},
|
registerHook() {},
|
||||||
registerHttpRoute() {},
|
registerHttpRoute() {},
|
||||||
registerCommand() {},
|
registerCommand() {},
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../../../src/acp/persistent-bindings.js", () => ({
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
ensureConfiguredAcpBindingSessionMock(...args),
|
return {
|
||||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
...actual,
|
||||||
resolveConfiguredAcpBindingRecordMock(...args),
|
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||||
}));
|
ensureConfiguredBindingRouteReadyMock(...args),
|
||||||
|
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||||
|
resolveConfiguredBindingRouteMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||||
|
|
||||||
@ -43,15 +47,92 @@ function createConfiguredTelegramBinding() {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createConfiguredTelegramRoute() {
|
||||||
|
const configuredBinding = createConfiguredTelegramBinding();
|
||||||
|
return {
|
||||||
|
bindingResolution: {
|
||||||
|
conversation: {
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: "work",
|
||||||
|
conversationId: "-1001234567890:topic:42",
|
||||||
|
parentConversationId: "-1001234567890",
|
||||||
|
},
|
||||||
|
compiledBinding: {
|
||||||
|
channel: "telegram",
|
||||||
|
accountPattern: "work",
|
||||||
|
binding: {
|
||||||
|
type: "acp",
|
||||||
|
agentId: "codex",
|
||||||
|
match: {
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: "work",
|
||||||
|
peer: {
|
||||||
|
kind: "group",
|
||||||
|
id: "-1001234567890:topic:42",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindingConversationId: "-1001234567890:topic:42",
|
||||||
|
target: {
|
||||||
|
conversationId: "-1001234567890:topic:42",
|
||||||
|
parentConversationId: "-1001234567890",
|
||||||
|
},
|
||||||
|
agentId: "codex",
|
||||||
|
provider: {
|
||||||
|
compileConfiguredBinding: () => ({
|
||||||
|
conversationId: "-1001234567890:topic:42",
|
||||||
|
parentConversationId: "-1001234567890",
|
||||||
|
}),
|
||||||
|
matchInboundConversation: () => ({
|
||||||
|
conversationId: "-1001234567890:topic:42",
|
||||||
|
parentConversationId: "-1001234567890",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
targetFactory: {
|
||||||
|
driverId: "acp",
|
||||||
|
materialize: () => ({
|
||||||
|
record: configuredBinding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
agentId: configuredBinding.spec.agentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
conversationId: "-1001234567890:topic:42",
|
||||||
|
parentConversationId: "-1001234567890",
|
||||||
|
},
|
||||||
|
record: configuredBinding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
agentId: configuredBinding.spec.agentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configuredBinding,
|
||||||
|
boundSessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
route: {
|
||||||
|
agentId: "codex",
|
||||||
|
accountId: "work",
|
||||||
|
channel: "telegram",
|
||||||
|
sessionKey: configuredBinding.record.targetSessionKey,
|
||||||
|
mainSessionKey: "agent:codex:main",
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
lastRoutePolicy: "bound",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildTelegramMessageContext ACP configured bindings", () => {
|
describe("buildTelegramMessageContext ACP configured bindings", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
resolveConfiguredBindingRouteMock.mockReset();
|
||||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding());
|
resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredTelegramRoute());
|
||||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||||
ok: true,
|
|
||||||
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
|
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
|
||||||
@ -68,7 +149,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||||||
expect(ctx?.route.accountId).toBe("work");
|
expect(ctx?.route.accountId).toBe("work");
|
||||||
expect(ctx?.route.matchedBy).toBe("binding.channel");
|
expect(ctx?.route.matchedBy).toBe("binding.channel");
|
||||||
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
|
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
|
||||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips ACP session initialization when topic access is denied", async () => {
|
it("skips ACP session initialization when topic access is denied", async () => {
|
||||||
@ -86,8 +167,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).toBeNull();
|
expect(ctx).toBeNull();
|
||||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defers ACP session initialization for unauthorized control commands", async () => {
|
it("defers ACP session initialization for unauthorized control commands", async () => {
|
||||||
@ -109,14 +190,13 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).toBeNull();
|
expect(ctx).toBeNull();
|
||||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops inbound processing when configured ACP binding initialization fails", async () => {
|
it("drops inbound processing when configured ACP binding initialization fails", async () => {
|
||||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
|
||||||
error: "gateway unavailable",
|
error: "gateway unavailable",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,7 +210,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(ctx).toBeNull();
|
expect(ctx).toBeNull();
|
||||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/channel-runtime";
|
} from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
|
import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
|
import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
|
||||||
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||||
@ -201,24 +201,24 @@ export const buildTelegramMessageContext = async ({
|
|||||||
if (!configuredBinding) {
|
if (!configuredBinding) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const ensured = await ensureConfiguredAcpRouteReady({
|
const ensured = await ensureConfiguredBindingRouteReady({
|
||||||
cfg: freshCfg,
|
cfg: freshCfg,
|
||||||
configuredBinding,
|
bindingResolution: configuredBinding,
|
||||||
});
|
});
|
||||||
if (ensured.ok) {
|
if (ensured.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
|
`telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
`telegram: configured ACP binding unavailable for ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||||
);
|
);
|
||||||
logInboundDrop({
|
logInboundDrop({
|
||||||
log: logVerbose,
|
log: logVerbose,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
reason: "configured ACP binding unavailable",
|
reason: "configured ACP binding unavailable",
|
||||||
target: configuredBinding.spec.conversationId,
|
target: configuredBinding.record.conversation.conversationId,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
|
import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||||
import {
|
import {
|
||||||
createDeferred,
|
createDeferred,
|
||||||
createNativeCommandTestParams,
|
createNativeCommandTestParams,
|
||||||
@ -14,10 +15,10 @@ import {
|
|||||||
|
|
||||||
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
||||||
|
|
||||||
type ResolveConfiguredAcpBindingRecordFn =
|
type ResolveConfiguredBindingRouteFn =
|
||||||
typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||||
type EnsureConfiguredAcpBindingSessionFn =
|
type EnsureConfiguredBindingRouteReadyFn =
|
||||||
typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||||
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||||
type DispatchReplyWithBufferedBlockDispatcherParams =
|
type DispatchReplyWithBufferedBlockDispatcherParams =
|
||||||
@ -34,10 +35,12 @@ const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const persistentBindingMocks = vi.hoisted(() => ({
|
const persistentBindingMocks = vi.hoisted(() => ({
|
||||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
|
resolveConfiguredBindingRoute: vi.fn<ResolveConfiguredBindingRouteFn>(({ route }) => ({
|
||||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
bindingResolution: null,
|
||||||
|
route,
|
||||||
|
})),
|
||||||
|
ensureConfiguredBindingRouteReady: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
const sessionMocks = vi.hoisted(() => ({
|
const sessionMocks = vi.hoisted(() => ({
|
||||||
@ -59,12 +62,58 @@ const sessionBindingMocks = vi.hoisted(() => ({
|
|||||||
touch: vi.fn(),
|
touch: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../../src/acp/persistent-bindings.js")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
|
||||||
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
|
||||||
|
getSessionBindingService: () => ({
|
||||||
|
bind: vi.fn(),
|
||||||
|
getCapabilities: vi.fn(),
|
||||||
|
listBySession: vi.fn(),
|
||||||
|
resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
|
||||||
|
touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
|
||||||
|
unbind: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
|
||||||
|
recordInboundSessionMetaSafe: vi.fn(
|
||||||
|
async (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
agentId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
ctx: unknown;
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}) => {
|
||||||
|
const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, {
|
||||||
|
agentId: params.agentId,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sessionMocks.recordSessionMetaFromInbound({
|
||||||
|
storePath,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
ctx: params.ctx,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
params.onError?.(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||||
|
listSkillCommandsForAgents: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../../../src/config/sessions.js", () => ({
|
vi.mock("../../../src/config/sessions.js", () => ({
|
||||||
@ -74,15 +123,6 @@ vi.mock("../../../src/config/sessions.js", () => ({
|
|||||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStore: vi.fn(async () => []),
|
readChannelAllowFromStore: vi.fn(async () => []),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({
|
|
||||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
|
||||||
}));
|
|
||||||
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
}));
|
|
||||||
vi.mock("../../../src/channels/reply-prefix.js", () => ({
|
|
||||||
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
|
|
||||||
}));
|
|
||||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||||
getSessionBindingService: () => ({
|
getSessionBindingService: () => ({
|
||||||
bind: vi.fn(),
|
bind: vi.fn(),
|
||||||
@ -93,10 +133,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
|||||||
unbind: vi.fn(),
|
unbind: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
|
|
||||||
return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) };
|
|
||||||
});
|
|
||||||
vi.mock("../../../src/plugins/commands.js", () => ({
|
vi.mock("../../../src/plugins/commands.js", () => ({
|
||||||
getPluginCommandSpecs: vi.fn(() => []),
|
getPluginCommandSpecs: vi.fn(() => []),
|
||||||
matchPluginCommand: vi.fn(() => null),
|
matchPluginCommand: vi.fn(() => null),
|
||||||
@ -233,13 +269,93 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) {
|
|||||||
status: "active",
|
status: "active",
|
||||||
boundAt: 0,
|
boundAt: 0,
|
||||||
},
|
},
|
||||||
} satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding;
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfiguredBindingRoute(
|
||||||
|
route: ResolvedAgentRoute,
|
||||||
|
binding: ReturnType<typeof createConfiguredAcpTopicBinding> | null,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
bindingResolution: binding
|
||||||
|
? {
|
||||||
|
conversation: binding.record.conversation,
|
||||||
|
compiledBinding: {
|
||||||
|
channel: "telegram" as const,
|
||||||
|
binding: {
|
||||||
|
type: "acp" as const,
|
||||||
|
agentId: binding.spec.agentId,
|
||||||
|
match: {
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: binding.spec.accountId,
|
||||||
|
peer: {
|
||||||
|
kind: "group" as const,
|
||||||
|
id: binding.spec.conversationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acp: {
|
||||||
|
mode: binding.spec.mode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindingConversationId: binding.spec.conversationId,
|
||||||
|
target: {
|
||||||
|
conversationId: binding.spec.conversationId,
|
||||||
|
...(binding.spec.parentConversationId
|
||||||
|
? { parentConversationId: binding.spec.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
agentId: binding.spec.agentId,
|
||||||
|
provider: {
|
||||||
|
compileConfiguredBinding: () => ({
|
||||||
|
conversationId: binding.spec.conversationId,
|
||||||
|
...(binding.spec.parentConversationId
|
||||||
|
? { parentConversationId: binding.spec.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
matchInboundConversation: () => ({
|
||||||
|
conversationId: binding.spec.conversationId,
|
||||||
|
...(binding.spec.parentConversationId
|
||||||
|
? { parentConversationId: binding.spec.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
targetFactory: {
|
||||||
|
driverId: "acp" as const,
|
||||||
|
materialize: () => ({
|
||||||
|
record: binding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful" as const,
|
||||||
|
driverId: "acp" as const,
|
||||||
|
sessionKey: binding.record.targetSessionKey,
|
||||||
|
agentId: binding.spec.agentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
conversationId: binding.spec.conversationId,
|
||||||
|
...(binding.spec.parentConversationId
|
||||||
|
? { parentConversationId: binding.spec.parentConversationId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
record: binding.record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful" as const,
|
||||||
|
driverId: "acp" as const,
|
||||||
|
sessionKey: binding.record.targetSessionKey,
|
||||||
|
agentId: binding.spec.agentId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}),
|
||||||
|
route,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
|
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
|
||||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
|
expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled();
|
||||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled();
|
||||||
expect(sendMessage).toHaveBeenCalledWith(
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
-1001234567890,
|
-1001234567890,
|
||||||
"You are not authorized to use this command.",
|
"You are not authorized to use this command.",
|
||||||
@ -249,13 +365,12 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.f
|
|||||||
|
|
||||||
describe("registerTelegramNativeCommands — session metadata", () => {
|
describe("registerTelegramNativeCommands — session metadata", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
|
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
|
createConfiguredBindingRoute(route, null),
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
);
|
||||||
ok: true,
|
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
|
||||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||||
});
|
|
||||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
||||||
@ -403,13 +518,18 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||||||
|
|
||||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
createConfiguredBindingRoute(
|
||||||
|
{
|
||||||
|
...route,
|
||||||
|
sessionKey: boundSessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
},
|
||||||
|
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||||
ok: true,
|
|
||||||
sessionKey: boundSessionKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handler } = registerAndResolveStatusHandler({
|
const { handler } = registerAndResolveStatusHandler({
|
||||||
cfg: {},
|
cfg: {},
|
||||||
@ -418,8 +538,8 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||||||
});
|
});
|
||||||
await handler(createTelegramTopicCommandContext());
|
await handler(createTelegramTopicCommandContext());
|
||||||
|
|
||||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
||||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
||||||
const dispatchCall = (
|
const dispatchCall = (
|
||||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||||
@ -488,12 +608,19 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||||||
|
|
||||||
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
|
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
|
||||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
createConfiguredBindingRoute(
|
||||||
|
{
|
||||||
|
...route,
|
||||||
|
sessionKey: boundSessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
},
|
||||||
|
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
sessionKey: boundSessionKey,
|
|
||||||
error: "gateway unavailable",
|
error: "gateway unavailable",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -514,13 +641,18 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||||||
|
|
||||||
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
|
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
|
||||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
createConfiguredBindingRoute(
|
||||||
|
{
|
||||||
|
...route,
|
||||||
|
sessionKey: boundSessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
},
|
||||||
|
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||||
ok: true,
|
|
||||||
sessionKey: boundSessionKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||||
commandName: "new",
|
commandName: "new",
|
||||||
@ -535,7 +667,9 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
|
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||||
|
createConfiguredBindingRoute(route, null),
|
||||||
|
);
|
||||||
|
|
||||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||||
commandName: "new",
|
commandName: "new",
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import type {
|
|||||||
TelegramGroupConfig,
|
TelegramGroupConfig,
|
||||||
TelegramTopicConfig,
|
TelegramTopicConfig,
|
||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { ensureConfiguredAcpRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
|
import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import {
|
import {
|
||||||
executePluginCommand,
|
executePluginCommand,
|
||||||
@ -490,13 +490,13 @@ export const registerTelegramNativeCommands = ({
|
|||||||
topicAgentId,
|
topicAgentId,
|
||||||
});
|
});
|
||||||
if (configuredBinding) {
|
if (configuredBinding) {
|
||||||
const ensured = await ensureConfiguredAcpRouteReady({
|
const ensured = await ensureConfiguredBindingRouteReady({
|
||||||
cfg,
|
cfg,
|
||||||
configuredBinding,
|
bindingResolution: configuredBinding,
|
||||||
});
|
});
|
||||||
if (!ensured.ok) {
|
if (!ensured.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||||
);
|
);
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
operation: "sendMessage",
|
operation: "sendMessage",
|
||||||
|
|||||||
@ -6,31 +6,32 @@ import type { TelegramContext } from "./types.js";
|
|||||||
const saveMediaBuffer = vi.fn();
|
const saveMediaBuffer = vi.fn();
|
||||||
const fetchRemoteMedia = vi.fn();
|
const fetchRemoteMedia = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../../../src/media/store.js", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../../../src/media/store.js")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||||
|
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../../../src/media/fetch.js", () => ({
|
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
||||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
vi.mock("../../../../src/globals.js", () => ({
|
logVerbose: () => {},
|
||||||
danger: (s: string) => s,
|
warn: (s: string) => s,
|
||||||
warn: (s: string) => s,
|
danger: (s: string) => s,
|
||||||
logVerbose: () => {},
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
vi.mock("../sticker-cache.js", () => ({
|
vi.mock("../sticker-cache.js", () => ({
|
||||||
cacheSticker: () => {},
|
cacheSticker: () => {},
|
||||||
getCachedSticker: () => null,
|
getCachedSticker: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
let resolveMedia: typeof import("./delivery.js").resolveMedia;
|
||||||
const { resolveMedia } = await import("./delivery.js");
|
|
||||||
const MAX_MEDIA_BYTES = 10_000_000;
|
const MAX_MEDIA_BYTES = 10_000_000;
|
||||||
const BOT_TOKEN = "tok123";
|
const BOT_TOKEN = "tok123";
|
||||||
|
|
||||||
@ -164,10 +165,12 @@ async function flushRetryTimers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveMedia getFile retry", () => {
|
describe("resolveMedia getFile retry", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
({ resolveMedia } = await import("./delivery.js"));
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fetchRemoteMedia.mockClear();
|
fetchRemoteMedia.mockReset();
|
||||||
saveMediaBuffer.mockClear();
|
saveMediaBuffer.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@ -13,6 +13,36 @@ import * as monitorModule from "./monitor.js";
|
|||||||
import * as probeModule from "./probe.js";
|
import * as probeModule from "./probe.js";
|
||||||
import { setTelegramRuntime } from "./runtime.js";
|
import { setTelegramRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const probeTelegramMock = vi.hoisted(() => vi.fn());
|
||||||
|
const collectTelegramUnmentionedGroupIdsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const auditTelegramGroupMembershipMock = vi.hoisted(() => vi.fn());
|
||||||
|
const monitorTelegramProviderMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("./probe.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./probe.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
probeTelegram: probeTelegramMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./audit.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./audit.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
collectTelegramUnmentionedGroupIds: collectTelegramUnmentionedGroupIdsMock,
|
||||||
|
auditTelegramGroupMembership: auditTelegramGroupMembershipMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./monitor.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./monitor.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
monitorTelegramProvider: monitorTelegramProviderMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function createCfg(): OpenClawConfig {
|
function createCfg(): OpenClawConfig {
|
||||||
return {
|
return {
|
||||||
channels: {
|
channels: {
|
||||||
@ -156,7 +186,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("blocks startup for duplicate token accounts before polling starts", async () => {
|
it("blocks startup for duplicate token accounts before polling starts", async () => {
|
||||||
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true });
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
||||||
|
probeOk: true,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
telegramPlugin.gateway!.startAccount!(
|
telegramPlugin.gateway!.startAccount!(
|
||||||
@ -168,15 +200,23 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow("Duplicate Telegram bot token");
|
).rejects.toThrow("Duplicate Telegram bot token");
|
||||||
|
|
||||||
|
expect(probeTelegramMock).not.toHaveBeenCalled();
|
||||||
|
expect(monitorTelegramProviderMock).not.toHaveBeenCalled();
|
||||||
expect(probeTelegram).not.toHaveBeenCalled();
|
expect(probeTelegram).not.toHaveBeenCalled();
|
||||||
expect(monitorTelegramProvider).not.toHaveBeenCalled();
|
expect(monitorTelegramProvider).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes webhookPort through to monitor startup options", async () => {
|
it("passes webhookPort through to monitor startup options", async () => {
|
||||||
const { monitorTelegramProvider } = installGatewayRuntime({
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
||||||
probeOk: true,
|
probeOk: true,
|
||||||
botUsername: "opsbot",
|
botUsername: "opsbot",
|
||||||
});
|
});
|
||||||
|
probeTelegramMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
bot: { username: "opsbot" },
|
||||||
|
elapsedMs: 1,
|
||||||
|
});
|
||||||
|
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
cfg.channels!.telegram!.accounts!.ops = {
|
cfg.channels!.telegram!.accounts!.ops = {
|
||||||
@ -194,18 +234,39 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 2500, {
|
||||||
|
accountId: "ops",
|
||||||
|
proxyUrl: undefined,
|
||||||
|
network: undefined,
|
||||||
|
});
|
||||||
|
expect(monitorTelegramProviderMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
useWebhook: true,
|
useWebhook: true,
|
||||||
webhookPort: 9876,
|
webhookPort: 9876,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(probeTelegram).toHaveBeenCalled();
|
||||||
|
expect(monitorTelegramProvider).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes account proxy and network settings into Telegram probes", async () => {
|
it("passes account proxy and network settings into Telegram probes", async () => {
|
||||||
const { probeTelegram } = installGatewayRuntime({
|
const runtimeProbeTelegram = vi.fn(async () => {
|
||||||
probeOk: true,
|
throw new Error("runtime probe should not be used");
|
||||||
botUsername: "opsbot",
|
});
|
||||||
|
setTelegramRuntime({
|
||||||
|
channel: {
|
||||||
|
telegram: {
|
||||||
|
probeTelegram: runtimeProbeTelegram,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
shouldLogVerbose: () => false,
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime);
|
||||||
|
probeTelegramMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
bot: { username: "opsbot" },
|
||||||
|
elapsedMs: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
@ -218,7 +279,7 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, {
|
expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 5000, {
|
||||||
accountId: "ops",
|
accountId: "ops",
|
||||||
proxyUrl: "http://127.0.0.1:8888",
|
proxyUrl: "http://127.0.0.1:8888",
|
||||||
network: {
|
network: {
|
||||||
@ -226,19 +287,40 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
dnsResultOrder: "ipv4first",
|
dnsResultOrder: "ipv4first",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(runtimeProbeTelegram).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes account proxy and network settings into Telegram membership audits", async () => {
|
it("passes account proxy and network settings into Telegram membership audits", async () => {
|
||||||
const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({
|
const runtimeCollectUnmentionedGroupIds = vi.fn(() => {
|
||||||
probeOk: true,
|
throw new Error("runtime audit helper should not be used");
|
||||||
botUsername: "opsbot",
|
|
||||||
});
|
});
|
||||||
|
const runtimeAuditGroupMembership = vi.fn(async () => {
|
||||||
collectUnmentionedGroupIds.mockReturnValue({
|
throw new Error("runtime audit helper should not be used");
|
||||||
|
});
|
||||||
|
setTelegramRuntime({
|
||||||
|
channel: {
|
||||||
|
telegram: {
|
||||||
|
collectUnmentionedGroupIds: runtimeCollectUnmentionedGroupIds,
|
||||||
|
auditGroupMembership: runtimeAuditGroupMembership,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
shouldLogVerbose: () => false,
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime);
|
||||||
|
collectTelegramUnmentionedGroupIdsMock.mockReturnValue({
|
||||||
groupIds: ["-100123"],
|
groupIds: ["-100123"],
|
||||||
unresolvedGroups: 0,
|
unresolvedGroups: 0,
|
||||||
hasWildcardUnmentionedGroups: false,
|
hasWildcardUnmentionedGroups: false,
|
||||||
});
|
});
|
||||||
|
auditTelegramGroupMembershipMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
checkedGroups: 1,
|
||||||
|
unresolvedGroups: 0,
|
||||||
|
hasWildcardUnmentionedGroups: false,
|
||||||
|
groups: [],
|
||||||
|
elapsedMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
configureOpsProxyNetwork(cfg);
|
configureOpsProxyNetwork(cfg);
|
||||||
@ -257,7 +339,10 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(auditGroupMembership).toHaveBeenCalledWith({
|
expect(collectTelegramUnmentionedGroupIdsMock).toHaveBeenCalledWith({
|
||||||
|
"-100123": { requireMention: false },
|
||||||
|
});
|
||||||
|
expect(auditTelegramGroupMembershipMock).toHaveBeenCalledWith({
|
||||||
token: "token-ops",
|
token: "token-ops",
|
||||||
botId: 123,
|
botId: 123,
|
||||||
groupIds: ["-100123"],
|
groupIds: ["-100123"],
|
||||||
@ -268,6 +353,8 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
},
|
},
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
});
|
});
|
||||||
|
expect(runtimeCollectUnmentionedGroupIds).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeAuditGroupMembership).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
|
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
|
||||||
@ -391,7 +478,11 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not crash startup when a resolved account token is undefined", async () => {
|
it("does not crash startup when a resolved account token is undefined", async () => {
|
||||||
const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false });
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
||||||
|
probeOk: false,
|
||||||
|
});
|
||||||
|
probeTelegramMock.mockResolvedValue({ ok: false, elapsedMs: 1 });
|
||||||
|
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
const ctx = createStartAccountCtx({
|
const ctx = createStartAccountCtx({
|
||||||
@ -405,11 +496,18 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
} as ResolvedTelegramAccount;
|
} as ResolvedTelegramAccount;
|
||||||
|
|
||||||
await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined();
|
await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined();
|
||||||
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
expect(probeTelegramMock).toHaveBeenCalledWith("", 2500, {
|
||||||
|
accountId: "ops",
|
||||||
|
proxyUrl: undefined,
|
||||||
|
network: undefined,
|
||||||
|
});
|
||||||
|
expect(monitorTelegramProviderMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
token: "",
|
token: "",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(probeTelegram).toHaveBeenCalled();
|
||||||
|
expect(monitorTelegramProvider).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -333,11 +333,15 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
acpBindings: {
|
bindings: {
|
||||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
compileConfiguredBinding: ({ conversationId }) =>
|
||||||
normalizeTelegramAcpConversationId(conversationId),
|
normalizeTelegramAcpConversationId(conversationId),
|
||||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||||
matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
matchTelegramAcpConversation({
|
||||||
|
bindingConversationId: compiledBinding.conversationId,
|
||||||
|
conversationId,
|
||||||
|
parentConversationId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveTelegramDmPolicy,
|
resolveDmPolicy: resolveTelegramDmPolicy,
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { resolveConfiguredAcpRoute } from "openclaw/plugin-sdk/conversation-runtime";
|
import {
|
||||||
|
resolveConfiguredBindingRoute,
|
||||||
|
type ConfiguredBindingRouteResult,
|
||||||
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
|
import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import {
|
import {
|
||||||
@ -31,7 +34,7 @@ export function resolveTelegramConversationRoute(params: {
|
|||||||
topicAgentId?: string | null;
|
topicAgentId?: string | null;
|
||||||
}): {
|
}): {
|
||||||
route: ReturnType<typeof resolveAgentRoute>;
|
route: ReturnType<typeof resolveAgentRoute>;
|
||||||
configuredBinding: ReturnType<typeof resolveConfiguredAcpRoute>["configuredBinding"];
|
configuredBinding: ConfiguredBindingRouteResult["bindingResolution"];
|
||||||
configuredBindingSessionKey: string;
|
configuredBindingSessionKey: string;
|
||||||
} {
|
} {
|
||||||
const peerId = params.isGroup
|
const peerId = params.isGroup
|
||||||
@ -94,15 +97,17 @@ export function resolveTelegramConversationRoute(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredRoute = resolveConfiguredAcpRoute({
|
const configuredRoute = resolveConfiguredBindingRoute({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
route,
|
route,
|
||||||
channel: "telegram",
|
conversation: {
|
||||||
accountId: params.accountId,
|
channel: "telegram",
|
||||||
conversationId: peerId,
|
accountId: params.accountId,
|
||||||
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
|
conversationId: peerId,
|
||||||
|
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
let configuredBinding = configuredRoute.configuredBinding;
|
let configuredBinding = configuredRoute.bindingResolution;
|
||||||
let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||||
route = configuredRoute.route;
|
route = configuredRoute.route;
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,14 @@ vi.mock("grammy", () => ({
|
|||||||
botCtorSpy(token, options);
|
botCtorSpy(token, options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
message = "HttpError",
|
||||||
|
public error?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
InputFile: class {},
|
InputFile: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -94,5 +102,6 @@ export function installTelegramSendTestHooks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function importTelegramSendModule() {
|
export async function importTelegramSendModule() {
|
||||||
|
vi.resetModules();
|
||||||
return await import("./send.js");
|
return await import("./send.js");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,29 +7,24 @@ const loadCronStore = vi.fn();
|
|||||||
const resolveCronStorePath = vi.fn();
|
const resolveCronStorePath = vi.fn();
|
||||||
const saveCronStore = vi.fn();
|
const saveCronStore = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
readConfigFileSnapshotForWrite,
|
readConfigFileSnapshotForWrite,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../../src/cron/store.js", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("../../../src/cron/store.js")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
loadCronStore,
|
loadCronStore,
|
||||||
resolveCronStorePath,
|
resolveCronStorePath,
|
||||||
saveCronStore,
|
saveCronStore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js");
|
|
||||||
|
|
||||||
describe("maybePersistResolvedTelegramTarget", () => {
|
describe("maybePersistResolvedTelegramTarget", () => {
|
||||||
beforeEach(() => {
|
let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"));
|
||||||
readConfigFileSnapshotForWrite.mockReset();
|
readConfigFileSnapshotForWrite.mockReset();
|
||||||
writeConfigFile.mockReset();
|
writeConfigFile.mockReset();
|
||||||
loadCronStore.mockReset();
|
loadCronStore.mockReset();
|
||||||
|
|||||||
@ -603,137 +603,164 @@ export class AcpSessionManager {
|
|||||||
}
|
}
|
||||||
await this.evictIdleRuntimeHandles({ cfg: input.cfg });
|
await this.evictIdleRuntimeHandles({ cfg: input.cfg });
|
||||||
await this.withSessionActor(sessionKey, async () => {
|
await this.withSessionActor(sessionKey, async () => {
|
||||||
const resolution = this.resolveSession({
|
|
||||||
cfg: input.cfg,
|
|
||||||
sessionKey,
|
|
||||||
});
|
|
||||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
|
||||||
|
|
||||||
const {
|
|
||||||
runtime,
|
|
||||||
handle: ensuredHandle,
|
|
||||||
meta: ensuredMeta,
|
|
||||||
} = await this.ensureRuntimeHandle({
|
|
||||||
cfg: input.cfg,
|
|
||||||
sessionKey,
|
|
||||||
meta: resolvedMeta,
|
|
||||||
});
|
|
||||||
let handle = ensuredHandle;
|
|
||||||
const meta = ensuredMeta;
|
|
||||||
await this.applyRuntimeControls({
|
|
||||||
sessionKey,
|
|
||||||
runtime,
|
|
||||||
handle,
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
const turnStartedAt = Date.now();
|
const turnStartedAt = Date.now();
|
||||||
const actorKey = normalizeActorKey(sessionKey);
|
const actorKey = normalizeActorKey(sessionKey);
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
await this.setSessionState({
|
const resolution = this.resolveSession({
|
||||||
cfg: input.cfg,
|
|
||||||
sessionKey,
|
|
||||||
state: "running",
|
|
||||||
clearLastError: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const internalAbortController = new AbortController();
|
|
||||||
const onCallerAbort = () => {
|
|
||||||
internalAbortController.abort();
|
|
||||||
};
|
|
||||||
if (input.signal?.aborted) {
|
|
||||||
internalAbortController.abort();
|
|
||||||
} else if (input.signal) {
|
|
||||||
input.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTurn: ActiveTurnState = {
|
|
||||||
runtime,
|
|
||||||
handle,
|
|
||||||
abortController: internalAbortController,
|
|
||||||
};
|
|
||||||
this.activeTurnBySession.set(actorKey, activeTurn);
|
|
||||||
|
|
||||||
let streamError: AcpRuntimeError | null = null;
|
|
||||||
try {
|
|
||||||
const combinedSignal =
|
|
||||||
input.signal && typeof AbortSignal.any === "function"
|
|
||||||
? AbortSignal.any([input.signal, internalAbortController.signal])
|
|
||||||
: internalAbortController.signal;
|
|
||||||
for await (const event of runtime.runTurn({
|
|
||||||
handle,
|
|
||||||
text: input.text,
|
|
||||||
attachments: input.attachments,
|
|
||||||
mode: input.mode,
|
|
||||||
requestId: input.requestId,
|
|
||||||
signal: combinedSignal,
|
|
||||||
})) {
|
|
||||||
if (event.type === "error") {
|
|
||||||
streamError = new AcpRuntimeError(
|
|
||||||
normalizeAcpErrorCode(event.code),
|
|
||||||
event.message?.trim() || "ACP turn failed before completion.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (input.onEvent) {
|
|
||||||
await input.onEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (streamError) {
|
|
||||||
throw streamError;
|
|
||||||
}
|
|
||||||
this.recordTurnCompletion({
|
|
||||||
startedAt: turnStartedAt,
|
|
||||||
});
|
|
||||||
await this.setSessionState({
|
|
||||||
cfg: input.cfg,
|
cfg: input.cfg,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
state: "idle",
|
|
||||||
clearLastError: true,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||||
const acpError = toAcpRuntimeError({
|
let runtime: AcpRuntime | undefined;
|
||||||
error,
|
let handle: AcpRuntimeHandle | undefined;
|
||||||
fallbackCode: "ACP_TURN_FAILED",
|
let meta: SessionAcpMeta | undefined;
|
||||||
fallbackMessage: "ACP turn failed before completion.",
|
let activeTurn: ActiveTurnState | undefined;
|
||||||
});
|
let internalAbortController: AbortController | undefined;
|
||||||
this.recordTurnCompletion({
|
let onCallerAbort: (() => void) | undefined;
|
||||||
startedAt: turnStartedAt,
|
let activeTurnStarted = false;
|
||||||
errorCode: acpError.code,
|
let sawTurnOutput = false;
|
||||||
});
|
let retryFreshHandle = false;
|
||||||
await this.setSessionState({
|
try {
|
||||||
cfg: input.cfg,
|
const ensured = await this.ensureRuntimeHandle({
|
||||||
sessionKey,
|
|
||||||
state: "error",
|
|
||||||
lastError: acpError.message,
|
|
||||||
});
|
|
||||||
throw acpError;
|
|
||||||
} finally {
|
|
||||||
if (input.signal) {
|
|
||||||
input.signal.removeEventListener("abort", onCallerAbort);
|
|
||||||
}
|
|
||||||
if (this.activeTurnBySession.get(actorKey) === activeTurn) {
|
|
||||||
this.activeTurnBySession.delete(actorKey);
|
|
||||||
}
|
|
||||||
if (meta.mode !== "oneshot") {
|
|
||||||
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
|
|
||||||
cfg: input.cfg,
|
cfg: input.cfg,
|
||||||
|
sessionKey,
|
||||||
|
meta: resolvedMeta,
|
||||||
|
});
|
||||||
|
runtime = ensured.runtime;
|
||||||
|
handle = ensured.handle;
|
||||||
|
meta = ensured.meta;
|
||||||
|
await this.applyRuntimeControls({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
runtime,
|
runtime,
|
||||||
handle,
|
handle,
|
||||||
meta,
|
meta,
|
||||||
failOnStatusError: false,
|
});
|
||||||
}));
|
|
||||||
}
|
await this.setSessionState({
|
||||||
if (meta.mode === "oneshot") {
|
cfg: input.cfg,
|
||||||
try {
|
sessionKey,
|
||||||
await runtime.close({
|
state: "running",
|
||||||
handle,
|
clearLastError: true,
|
||||||
reason: "oneshot-complete",
|
});
|
||||||
});
|
|
||||||
} catch (error) {
|
internalAbortController = new AbortController();
|
||||||
logVerbose(`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`);
|
onCallerAbort = () => {
|
||||||
} finally {
|
internalAbortController?.abort();
|
||||||
this.clearCachedRuntimeState(sessionKey);
|
};
|
||||||
|
if (input.signal?.aborted) {
|
||||||
|
internalAbortController.abort();
|
||||||
|
} else if (input.signal) {
|
||||||
|
input.signal.addEventListener("abort", onCallerAbort, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeTurn = {
|
||||||
|
runtime,
|
||||||
|
handle,
|
||||||
|
abortController: internalAbortController,
|
||||||
|
};
|
||||||
|
this.activeTurnBySession.set(actorKey, activeTurn);
|
||||||
|
activeTurnStarted = true;
|
||||||
|
|
||||||
|
let streamError: AcpRuntimeError | null = null;
|
||||||
|
const combinedSignal =
|
||||||
|
input.signal && typeof AbortSignal.any === "function"
|
||||||
|
? AbortSignal.any([input.signal, internalAbortController.signal])
|
||||||
|
: internalAbortController.signal;
|
||||||
|
for await (const event of runtime.runTurn({
|
||||||
|
handle,
|
||||||
|
text: input.text,
|
||||||
|
attachments: input.attachments,
|
||||||
|
mode: input.mode,
|
||||||
|
requestId: input.requestId,
|
||||||
|
signal: combinedSignal,
|
||||||
|
})) {
|
||||||
|
if (event.type === "error") {
|
||||||
|
streamError = new AcpRuntimeError(
|
||||||
|
normalizeAcpErrorCode(event.code),
|
||||||
|
event.message?.trim() || "ACP turn failed before completion.",
|
||||||
|
);
|
||||||
|
} else if (event.type === "text_delta" || event.type === "tool_call") {
|
||||||
|
sawTurnOutput = true;
|
||||||
|
}
|
||||||
|
if (input.onEvent) {
|
||||||
|
await input.onEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (streamError) {
|
||||||
|
throw streamError;
|
||||||
|
}
|
||||||
|
this.recordTurnCompletion({
|
||||||
|
startedAt: turnStartedAt,
|
||||||
|
});
|
||||||
|
await this.setSessionState({
|
||||||
|
cfg: input.cfg,
|
||||||
|
sessionKey,
|
||||||
|
state: "idle",
|
||||||
|
clearLastError: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const acpError = toAcpRuntimeError({
|
||||||
|
error,
|
||||||
|
fallbackCode: activeTurnStarted ? "ACP_TURN_FAILED" : "ACP_SESSION_INIT_FAILED",
|
||||||
|
fallbackMessage: activeTurnStarted
|
||||||
|
? "ACP turn failed before completion."
|
||||||
|
: "Could not initialize ACP session runtime.",
|
||||||
|
});
|
||||||
|
retryFreshHandle = this.shouldRetryTurnWithFreshHandle({
|
||||||
|
attempt,
|
||||||
|
sessionKey,
|
||||||
|
error: acpError,
|
||||||
|
sawTurnOutput,
|
||||||
|
});
|
||||||
|
if (retryFreshHandle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.recordTurnCompletion({
|
||||||
|
startedAt: turnStartedAt,
|
||||||
|
errorCode: acpError.code,
|
||||||
|
});
|
||||||
|
await this.setSessionState({
|
||||||
|
cfg: input.cfg,
|
||||||
|
sessionKey,
|
||||||
|
state: "error",
|
||||||
|
lastError: acpError.message,
|
||||||
|
});
|
||||||
|
throw acpError;
|
||||||
|
} finally {
|
||||||
|
if (input.signal && onCallerAbort) {
|
||||||
|
input.signal.removeEventListener("abort", onCallerAbort);
|
||||||
|
}
|
||||||
|
if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) {
|
||||||
|
this.activeTurnBySession.delete(actorKey);
|
||||||
|
}
|
||||||
|
if (!retryFreshHandle && runtime && handle && meta && meta.mode !== "oneshot") {
|
||||||
|
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
|
||||||
|
cfg: input.cfg,
|
||||||
|
sessionKey,
|
||||||
|
runtime,
|
||||||
|
handle,
|
||||||
|
meta,
|
||||||
|
failOnStatusError: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!retryFreshHandle && runtime && handle && meta && meta.mode === "oneshot") {
|
||||||
|
try {
|
||||||
|
await runtime.close({
|
||||||
|
handle,
|
||||||
|
reason: "oneshot-complete",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logVerbose(
|
||||||
|
`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.clearCachedRuntimeState(sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retryFreshHandle) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -864,7 +891,9 @@ export class AcpSessionManager {
|
|||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
input.allowBackendUnavailable &&
|
input.allowBackendUnavailable &&
|
||||||
(acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE")
|
(acpError.code === "ACP_BACKEND_MISSING" ||
|
||||||
|
acpError.code === "ACP_BACKEND_UNAVAILABLE" ||
|
||||||
|
this.isRecoverableAcpxExitError(acpError.message))
|
||||||
) {
|
) {
|
||||||
// Treat unavailable backends as terminal for this cached handle so it
|
// Treat unavailable backends as terminal for this cached handle so it
|
||||||
// cannot continue counting against maxConcurrentSessions.
|
// cannot continue counting against maxConcurrentSessions.
|
||||||
@ -916,7 +945,17 @@ export class AcpSessionManager {
|
|||||||
const agentMatches = cached.agent === agent;
|
const agentMatches = cached.agent === agent;
|
||||||
const modeMatches = cached.mode === mode;
|
const modeMatches = cached.mode === mode;
|
||||||
const cwdMatches = (cached.cwd ?? "") === (cwd ?? "");
|
const cwdMatches = (cached.cwd ?? "") === (cwd ?? "");
|
||||||
if (backendMatches && agentMatches && modeMatches && cwdMatches) {
|
if (
|
||||||
|
backendMatches &&
|
||||||
|
agentMatches &&
|
||||||
|
modeMatches &&
|
||||||
|
cwdMatches &&
|
||||||
|
(await this.isCachedRuntimeHandleReusable({
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
runtime: cached.runtime,
|
||||||
|
handle: cached.handle,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
runtime: cached.runtime,
|
runtime: cached.runtime,
|
||||||
handle: cached.handle,
|
handle: cached.handle,
|
||||||
@ -1020,6 +1059,49 @@ export class AcpSessionManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isCachedRuntimeHandleReusable(params: {
|
||||||
|
sessionKey: string;
|
||||||
|
runtime: AcpRuntime;
|
||||||
|
handle: AcpRuntimeHandle;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (!params.runtime.getStatus) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await params.runtime.getStatus({
|
||||||
|
handle: params.handle,
|
||||||
|
});
|
||||||
|
if (this.isRuntimeStatusUnavailable(status)) {
|
||||||
|
this.clearCachedRuntimeState(params.sessionKey);
|
||||||
|
logVerbose(
|
||||||
|
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after unhealthy status probe: ${status.summary ?? "status unavailable"}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.clearCachedRuntimeState(params.sessionKey);
|
||||||
|
logVerbose(
|
||||||
|
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after status probe failed: ${String(error)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRuntimeStatusUnavailable(status: AcpRuntimeStatus | undefined): boolean {
|
||||||
|
if (!status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const detailsStatus =
|
||||||
|
typeof status.details?.status === "string" ? status.details.status.trim().toLowerCase() : "";
|
||||||
|
if (detailsStatus === "dead" || detailsStatus === "no-session") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const summaryMatch = status.summary?.match(/\bstatus=([^\s]+)/i);
|
||||||
|
const summaryStatus = summaryMatch?.[1]?.trim().toLowerCase() ?? "";
|
||||||
|
return summaryStatus === "dead" || summaryStatus === "no-session";
|
||||||
|
}
|
||||||
|
|
||||||
private async persistRuntimeOptions(params: {
|
private async persistRuntimeOptions(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -1103,6 +1185,29 @@ export class AcpSessionManager {
|
|||||||
this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1);
|
this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldRetryTurnWithFreshHandle(params: {
|
||||||
|
attempt: number;
|
||||||
|
sessionKey: string;
|
||||||
|
error: AcpRuntimeError;
|
||||||
|
sawTurnOutput: boolean;
|
||||||
|
}): boolean {
|
||||||
|
if (params.attempt > 0 || params.sawTurnOutput) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.isRecoverableAcpxExitError(params.error.message)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.clearCachedRuntimeState(params.sessionKey);
|
||||||
|
logVerbose(
|
||||||
|
`acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecoverableAcpxExitError(message: string): boolean {
|
||||||
|
return /^acpx exited with code \d+/i.test(message.trim());
|
||||||
|
}
|
||||||
|
|
||||||
private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise<void> {
|
private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise<void> {
|
||||||
const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg);
|
const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg);
|
||||||
if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) {
|
if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) {
|
||||||
|
|||||||
@ -354,6 +354,52 @@ describe("AcpSessionManager", () => {
|
|||||||
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-ensures cached runtime handles when the backend reports the session is dead", async () => {
|
||||||
|
const runtimeState = createRuntime();
|
||||||
|
runtimeState.getStatus
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
summary: "status=alive",
|
||||||
|
details: { status: "alive" },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
summary: "status=dead",
|
||||||
|
details: { status: "dead" },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
summary: "status=alive",
|
||||||
|
details: { status: "alive" },
|
||||||
|
});
|
||||||
|
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||||
|
id: "acpx",
|
||||||
|
runtime: runtimeState.runtime,
|
||||||
|
});
|
||||||
|
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
storeSessionKey: "agent:codex:acp:session-1",
|
||||||
|
acp: readySessionMeta(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new AcpSessionManager();
|
||||||
|
await manager.runTurn({
|
||||||
|
cfg: baseCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
text: "first",
|
||||||
|
mode: "prompt",
|
||||||
|
requestId: "r1",
|
||||||
|
});
|
||||||
|
await manager.runTurn({
|
||||||
|
cfg: baseCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
text: "second",
|
||||||
|
mode: "prompt",
|
||||||
|
requestId: "r2",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runtimeState.getStatus).toHaveBeenCalledTimes(3);
|
||||||
|
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("rehydrates runtime handles after a manager restart", async () => {
|
it("rehydrates runtime handles after a manager restart", async () => {
|
||||||
const runtimeState = createRuntime();
|
const runtimeState = createRuntime();
|
||||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||||
@ -531,6 +577,61 @@ describe("AcpSessionManager", () => {
|
|||||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops cached runtime handles when close sees a stale acpx process-exit error", async () => {
|
||||||
|
const runtimeState = createRuntime();
|
||||||
|
runtimeState.close.mockRejectedValueOnce(new Error("acpx exited with code 1"));
|
||||||
|
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||||
|
id: "acpx",
|
||||||
|
runtime: runtimeState.runtime,
|
||||||
|
});
|
||||||
|
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||||
|
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
|
||||||
|
return {
|
||||||
|
sessionKey,
|
||||||
|
storeSessionKey: sessionKey,
|
||||||
|
acp: {
|
||||||
|
...readySessionMeta(),
|
||||||
|
runtimeSessionName: `runtime:${sessionKey}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const limitedCfg = {
|
||||||
|
acp: {
|
||||||
|
...baseCfg.acp,
|
||||||
|
maxConcurrentSessions: 1,
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const manager = new AcpSessionManager();
|
||||||
|
await manager.runTurn({
|
||||||
|
cfg: limitedCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-a",
|
||||||
|
text: "first",
|
||||||
|
mode: "prompt",
|
||||||
|
requestId: "r1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeResult = await manager.closeSession({
|
||||||
|
cfg: limitedCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-a",
|
||||||
|
reason: "manual-close",
|
||||||
|
allowBackendUnavailable: true,
|
||||||
|
});
|
||||||
|
expect(closeResult.runtimeClosed).toBe(false);
|
||||||
|
expect(closeResult.runtimeNotice).toBe("acpx exited with code 1");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.runTurn({
|
||||||
|
cfg: limitedCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-b",
|
||||||
|
text: "second",
|
||||||
|
mode: "prompt",
|
||||||
|
requestId: "r2",
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("evicts idle cached runtimes before enforcing max concurrent limits", async () => {
|
it("evicts idle cached runtimes before enforcing max concurrent limits", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
@ -807,6 +908,82 @@ describe("AcpSessionManager", () => {
|
|||||||
expect(states.at(-1)).toBe("error");
|
expect(states.at(-1)).toBe("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks the session as errored when runtime ensure fails before turn start", async () => {
|
||||||
|
const runtimeState = createRuntime();
|
||||||
|
runtimeState.ensureSession.mockRejectedValue(new Error("acpx exited with code 1"));
|
||||||
|
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||||
|
id: "acpx",
|
||||||
|
runtime: runtimeState.runtime,
|
||||||
|
});
|
||||||
|
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
storeSessionKey: "agent:codex:acp:session-1",
|
||||||
|
acp: {
|
||||||
|
...readySessionMeta(),
|
||||||
|
state: "running",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new AcpSessionManager();
|
||||||
|
await expect(
|
||||||
|
manager.runTurn({
|
||||||
|
cfg: baseCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
text: "do work",
|
||||||
|
mode: "prompt",
|
||||||
|
requestId: "run-1",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "ACP_SESSION_INIT_FAILED",
|
||||||
|
message: "acpx exited with code 1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const states = extractStatesFromUpserts();
|
||||||
|
expect(states).not.toContain("running");
|
||||||
|
expect(states.at(-1)).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries once with a fresh runtime handle after an early acpx exit", async () => {
|
||||||
|
const runtimeState = createRuntime();
|
||||||
|
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||||
|
id: "acpx",
|
||||||
|
runtime: runtimeState.runtime,
|
||||||
|
});
|
||||||
|
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
storeSessionKey: "agent:codex:acp:session-1",
|
||||||
|
acp: readySessionMeta(),
|
||||||
|
});
|
||||||
|
runtimeState.runTurn
|
||||||
|
.mockImplementationOnce(async function* () {
|
||||||
|
yield {
|
||||||
|
type: "error" as const,
|
||||||
|
message: "acpx exited with code 1",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async function* () {
|
||||||
|
yield { type: "done" as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new AcpSessionManager();
|
||||||
|
await expect(
|
||||||
|
manager.runTurn({
|
||||||
|
cfg: baseCfg,
|
||||||
|
sessionKey: "agent:codex:acp:session-1",
|
||||||
|
text: "do work",
|
||||||
|
mode: "prompt",
|
||||||
|
requestId: "run-1",
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
||||||
|
const states = extractStatesFromUpserts();
|
||||||
|
expect(states).toContain("running");
|
||||||
|
expect(states).toContain("idle");
|
||||||
|
expect(states).not.toContain("error");
|
||||||
|
});
|
||||||
|
|
||||||
it("persists runtime mode changes through setSessionRuntimeMode", async () => {
|
it("persists runtime mode changes through setSessionRuntimeMode", async () => {
|
||||||
const runtimeState = createRuntime();
|
const runtimeState = createRuntime();
|
||||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||||
|
|||||||
100
src/acp/persistent-bindings.lifecycle.test.ts
Normal file
100
src/acp/persistent-bindings.lifecycle.test.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { importFreshModule } from "../../test/helpers/import-fresh.js";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
const managerMocks = vi.hoisted(() => ({
|
||||||
|
closeSession: vi.fn(),
|
||||||
|
initializeSession: vi.fn(),
|
||||||
|
updateSessionRuntimeOptions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sessionMetaMocks = vi.hoisted(() => ({
|
||||||
|
readAcpSessionEntry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolveMocks = vi.hoisted(() => ({
|
||||||
|
resolveConfiguredAcpBindingSpecBySessionKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./control-plane/manager.js", () => ({
|
||||||
|
getAcpSessionManager: () => ({
|
||||||
|
closeSession: managerMocks.closeSession,
|
||||||
|
initializeSession: managerMocks.initializeSession,
|
||||||
|
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./runtime/session-meta.js", () => ({
|
||||||
|
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./persistent-bindings.resolve.js", () => ({
|
||||||
|
resolveConfiguredAcpBindingSpecBySessionKey:
|
||||||
|
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey,
|
||||||
|
}));
|
||||||
|
type BindingTargetsModule = typeof import("../channels/plugins/binding-targets.js");
|
||||||
|
let bindingTargets: BindingTargetsModule;
|
||||||
|
let bindingTargetsImportScope = 0;
|
||||||
|
|
||||||
|
const baseCfg = {
|
||||||
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "codex" }, { id: "claude" }],
|
||||||
|
},
|
||||||
|
} satisfies OpenClawConfig;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
bindingTargetsImportScope += 1;
|
||||||
|
bindingTargets = await importFreshModule<BindingTargetsModule>(
|
||||||
|
import.meta.url,
|
||||||
|
`../channels/plugins/binding-targets.js?scope=${bindingTargetsImportScope}`,
|
||||||
|
);
|
||||||
|
managerMocks.closeSession.mockReset().mockResolvedValue({
|
||||||
|
runtimeClosed: true,
|
||||||
|
metaCleared: false,
|
||||||
|
});
|
||||||
|
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
|
||||||
|
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
|
||||||
|
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
||||||
|
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockReset().mockReturnValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resetConfiguredBindingTargetInPlace", () => {
|
||||||
|
it("does not resolve configured bindings when ACP metadata already exists", async () => {
|
||||||
|
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
|
||||||
|
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
||||||
|
acp: {
|
||||||
|
agent: "claude",
|
||||||
|
mode: "persistent",
|
||||||
|
backend: "acpx",
|
||||||
|
runtimeOptions: { cwd: "/home/bob/clawd" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockImplementation(() => {
|
||||||
|
throw new Error("configured binding resolution should be skipped");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await bindingTargets.resetConfiguredBindingTargetInPlace({
|
||||||
|
cfg: baseCfg,
|
||||||
|
sessionKey,
|
||||||
|
reason: "reset",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey).not.toHaveBeenCalled();
|
||||||
|
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionKey,
|
||||||
|
clearMeta: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionKey,
|
||||||
|
agent: "claude",
|
||||||
|
backendId: "acpx",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
buildConfiguredAcpSessionKey,
|
buildConfiguredAcpSessionKey,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
type ConfiguredAcpBindingSpec,
|
type ConfiguredAcpBindingSpec,
|
||||||
|
type ResolvedConfiguredAcpBinding,
|
||||||
} from "./persistent-bindings.types.js";
|
} from "./persistent-bindings.types.js";
|
||||||
import { readAcpSessionEntry } from "./runtime/session-meta.js";
|
import { readAcpSessionEntry } from "./runtime/session-meta.js";
|
||||||
|
|
||||||
@ -96,7 +97,7 @@ export async function ensureConfiguredAcpBindingSession(params: {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
`acp-configured-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -106,6 +107,26 @@ export async function ensureConfiguredAcpBindingSession(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureConfiguredAcpBindingReady(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
||||||
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
if (!params.configuredBinding) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const ensured = await ensureConfiguredAcpBindingSession({
|
||||||
|
cfg: params.cfg,
|
||||||
|
spec: params.configuredBinding.spec,
|
||||||
|
});
|
||||||
|
if (ensured.ok) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: ensured.error ?? "unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetAcpSessionInPlace(params: {
|
export async function resetAcpSessionInPlace(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -119,14 +140,17 @@ export async function resetAcpSessionInPlace(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
|
|
||||||
cfg: params.cfg,
|
|
||||||
sessionKey,
|
|
||||||
});
|
|
||||||
const meta = readAcpSessionEntry({
|
const meta = readAcpSessionEntry({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
})?.acp;
|
})?.acp;
|
||||||
|
const configuredBinding =
|
||||||
|
!meta || !normalizeText(meta.agent)
|
||||||
|
? resolveConfiguredAcpBindingSpecBySessionKey({
|
||||||
|
cfg: params.cfg,
|
||||||
|
sessionKey,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
if (configuredBinding) {
|
if (configuredBinding) {
|
||||||
const ensured = await ensureConfiguredAcpBindingSession({
|
const ensured = await ensureConfiguredAcpBindingSession({
|
||||||
@ -189,7 +213,7 @@ export async function resetAcpSessionInPlace(params: {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`);
|
logVerbose(`acp-configured-binding: failed reset for ${sessionKey}: ${message}`);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: message,
|
error: message,
|
||||||
|
|||||||
@ -1,275 +1,17 @@
|
|||||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
import {
|
||||||
import { listAcpBindings } from "../config/bindings.js";
|
resolveConfiguredBindingRecord,
|
||||||
|
resolveConfiguredBindingRecordBySessionKey,
|
||||||
|
resolveConfiguredBindingRecordForConversation,
|
||||||
|
} from "../channels/plugins/binding-registry.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { AgentAcpBinding } from "../config/types.js";
|
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
|
||||||
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACCOUNT_ID,
|
resolveConfiguredAcpBindingSpecFromRecord,
|
||||||
normalizeAccountId,
|
toResolvedConfiguredAcpBinding,
|
||||||
parseAgentSessionKey,
|
|
||||||
} from "../routing/session-key.js";
|
|
||||||
import {
|
|
||||||
buildConfiguredAcpSessionKey,
|
|
||||||
normalizeBindingConfig,
|
|
||||||
normalizeMode,
|
|
||||||
normalizeText,
|
|
||||||
toConfiguredAcpBindingRecord,
|
|
||||||
type ConfiguredAcpBindingChannel,
|
|
||||||
type ConfiguredAcpBindingSpec,
|
type ConfiguredAcpBindingSpec,
|
||||||
type ResolvedConfiguredAcpBinding,
|
type ResolvedConfiguredAcpBinding,
|
||||||
} from "./persistent-bindings.types.js";
|
} from "./persistent-bindings.types.js";
|
||||||
|
|
||||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
|
||||||
const normalized = (value ?? "").trim().toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const plugin = getChannelPlugin(normalized);
|
|
||||||
return plugin?.acpBindings ? plugin.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
|
||||||
const trimmed = (match ?? "").trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
|
|
||||||
}
|
|
||||||
if (trimmed === "*") {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return normalizeAccountId(trimmed) === actual ? 2 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
|
|
||||||
const id = binding.match.peer?.id?.trim();
|
|
||||||
return id ? id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfiguredBindingSessionKey(params: {
|
|
||||||
sessionKey: string;
|
|
||||||
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
|
|
||||||
const parsed = parseAgentSessionKey(params.sessionKey);
|
|
||||||
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
|
|
||||||
if (!rest) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tokens = rest.split(":");
|
|
||||||
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const channel = normalizeBindingChannel(tokens[2]);
|
|
||||||
if (!channel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
channel,
|
|
||||||
accountId: normalizeAccountId(tokens[3]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
|
|
||||||
acpAgentId?: string;
|
|
||||||
mode?: string;
|
|
||||||
cwd?: string;
|
|
||||||
backend?: string;
|
|
||||||
} {
|
|
||||||
const agent = params.cfg.agents?.list?.find(
|
|
||||||
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (!agent || agent.runtime?.type !== "acp") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
acpAgentId: normalizeText(agent.runtime.acp?.agent),
|
|
||||||
mode: normalizeText(agent.runtime.acp?.mode),
|
|
||||||
cwd: normalizeText(agent.runtime.acp?.cwd),
|
|
||||||
backend: normalizeText(agent.runtime.acp?.backend),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toConfiguredBindingSpec(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
channel: ConfiguredAcpBindingChannel;
|
|
||||||
accountId: string;
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
binding: AgentAcpBinding;
|
|
||||||
}): ConfiguredAcpBindingSpec {
|
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
|
||||||
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
|
|
||||||
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
|
|
||||||
cfg: params.cfg,
|
|
||||||
ownerAgentId: agentId,
|
|
||||||
});
|
|
||||||
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
|
|
||||||
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
|
|
||||||
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
|
|
||||||
return {
|
|
||||||
channel: params.channel,
|
|
||||||
accountId,
|
|
||||||
conversationId: params.conversationId,
|
|
||||||
parentConversationId: params.parentConversationId,
|
|
||||||
agentId,
|
|
||||||
acpAgentId,
|
|
||||||
mode,
|
|
||||||
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
|
|
||||||
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
|
|
||||||
label: bindingOverrides.label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConfiguredBindingRecord(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
bindings: AgentAcpBinding[];
|
|
||||||
channel: ConfiguredAcpBindingChannel;
|
|
||||||
accountId: string;
|
|
||||||
selectConversation: (binding: AgentAcpBinding) => {
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
matchPriority?: number;
|
|
||||||
} | null;
|
|
||||||
}): ResolvedConfiguredAcpBinding | null {
|
|
||||||
let wildcardMatch: {
|
|
||||||
binding: AgentAcpBinding;
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
matchPriority: number;
|
|
||||||
} | null = null;
|
|
||||||
let exactMatch: {
|
|
||||||
binding: AgentAcpBinding;
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
matchPriority: number;
|
|
||||||
} | null = null;
|
|
||||||
for (const binding of params.bindings) {
|
|
||||||
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const accountMatchPriority = resolveAccountMatchPriority(
|
|
||||||
binding.match.accountId,
|
|
||||||
params.accountId,
|
|
||||||
);
|
|
||||||
if (accountMatchPriority === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const conversation = params.selectConversation(binding);
|
|
||||||
if (!conversation) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const matchPriority = conversation.matchPriority ?? 0;
|
|
||||||
if (accountMatchPriority === 2) {
|
|
||||||
if (!exactMatch || matchPriority > exactMatch.matchPriority) {
|
|
||||||
exactMatch = {
|
|
||||||
binding,
|
|
||||||
conversationId: conversation.conversationId,
|
|
||||||
parentConversationId: conversation.parentConversationId,
|
|
||||||
matchPriority,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) {
|
|
||||||
wildcardMatch = {
|
|
||||||
binding,
|
|
||||||
conversationId: conversation.conversationId,
|
|
||||||
parentConversationId: conversation.parentConversationId,
|
|
||||||
matchPriority,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (exactMatch) {
|
|
||||||
const spec = toConfiguredBindingSpec({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel: params.channel,
|
|
||||||
accountId: params.accountId,
|
|
||||||
conversationId: exactMatch.conversationId,
|
|
||||||
parentConversationId: exactMatch.parentConversationId,
|
|
||||||
binding: exactMatch.binding,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
spec,
|
|
||||||
record: toConfiguredAcpBindingRecord(spec),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!wildcardMatch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const spec = toConfiguredBindingSpec({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel: params.channel,
|
|
||||||
accountId: params.accountId,
|
|
||||||
conversationId: wildcardMatch.conversationId,
|
|
||||||
parentConversationId: wildcardMatch.parentConversationId,
|
|
||||||
binding: wildcardMatch.binding,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
spec,
|
|
||||||
record: toConfiguredAcpBindingRecord(spec),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
sessionKey: string;
|
|
||||||
}): ConfiguredAcpBindingSpec | null {
|
|
||||||
const sessionKey = params.sessionKey.trim();
|
|
||||||
if (!sessionKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
|
|
||||||
if (!parsedSessionKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const plugin = getChannelPlugin(parsedSessionKey.channel);
|
|
||||||
const acpBindings = plugin?.acpBindings;
|
|
||||||
if (!acpBindings?.normalizeConfiguredBindingTarget) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
|
|
||||||
for (const binding of listAcpBindings(params.cfg)) {
|
|
||||||
const channel = normalizeBindingChannel(binding.match.channel);
|
|
||||||
if (!channel || channel !== parsedSessionKey.channel) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const accountMatchPriority = resolveAccountMatchPriority(
|
|
||||||
binding.match.accountId,
|
|
||||||
parsedSessionKey.accountId,
|
|
||||||
);
|
|
||||||
if (accountMatchPriority === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const targetConversationId = resolveBindingConversationId(binding);
|
|
||||||
if (!targetConversationId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const target = acpBindings.normalizeConfiguredBindingTarget({
|
|
||||||
binding,
|
|
||||||
conversationId: targetConversationId,
|
|
||||||
});
|
|
||||||
if (!target) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const spec = toConfiguredBindingSpec({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel,
|
|
||||||
accountId: parsedSessionKey.accountId,
|
|
||||||
conversationId: target.conversationId,
|
|
||||||
parentConversationId: target.parentConversationId,
|
|
||||||
binding,
|
|
||||||
});
|
|
||||||
if (buildConfiguredAcpSessionKey(spec) !== sessionKey) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (accountMatchPriority === 2) {
|
|
||||||
return spec;
|
|
||||||
}
|
|
||||||
if (!wildcardMatch) {
|
|
||||||
wildcardMatch = spec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return wildcardMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveConfiguredAcpBindingRecord(params: {
|
export function resolveConfiguredAcpBindingRecord(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
channel: string;
|
channel: string;
|
||||||
@ -277,36 +19,22 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
parentConversationId?: string;
|
parentConversationId?: string;
|
||||||
}): ResolvedConfiguredAcpBinding | null {
|
}): ResolvedConfiguredAcpBinding | null {
|
||||||
const channel = normalizeBindingChannel(params.channel);
|
const resolved = resolveConfiguredBindingRecord(params);
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
|
||||||
const conversationId = params.conversationId.trim();
|
}
|
||||||
const parentConversationId = params.parentConversationId?.trim() || undefined;
|
|
||||||
if (!channel || !conversationId) {
|
export function resolveConfiguredAcpBindingRecordForConversation(params: {
|
||||||
return null;
|
cfg: OpenClawConfig;
|
||||||
}
|
conversation: ConversationRef;
|
||||||
const plugin = getChannelPlugin(channel);
|
}): ResolvedConfiguredAcpBinding | null {
|
||||||
const acpBindings = plugin?.acpBindings;
|
const resolved = resolveConfiguredBindingRecordForConversation(params);
|
||||||
if (!acpBindings?.matchConfiguredBinding) {
|
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
const matchConfiguredBinding = acpBindings.matchConfiguredBinding;
|
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
return resolveConfiguredBindingRecord({
|
sessionKey: string;
|
||||||
cfg: params.cfg,
|
}): ConfiguredAcpBindingSpec | null {
|
||||||
bindings: listAcpBindings(params.cfg),
|
const resolved = resolveConfiguredBindingRecordBySessionKey(params);
|
||||||
channel,
|
return resolved ? resolveConfiguredAcpBindingSpecFromRecord(resolved.record) : null;
|
||||||
accountId,
|
|
||||||
selectConversation: (binding) => {
|
|
||||||
const bindingConversationId = resolveBindingConversationId(binding);
|
|
||||||
if (!bindingConversationId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return matchConfiguredBinding({
|
|
||||||
binding,
|
|
||||||
bindingConversationId,
|
|
||||||
conversationId,
|
|
||||||
parentConversationId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
|
||||||
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
|
|
||||||
import { deriveLastRoutePolicy } from "../routing/resolve-route.js";
|
|
||||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
|
||||||
import {
|
|
||||||
ensureConfiguredAcpBindingSession,
|
|
||||||
resolveConfiguredAcpBindingRecord,
|
|
||||||
type ConfiguredAcpBindingChannel,
|
|
||||||
type ResolvedConfiguredAcpBinding,
|
|
||||||
} from "./persistent-bindings.js";
|
|
||||||
|
|
||||||
export function resolveConfiguredAcpRoute(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
route: ResolvedAgentRoute;
|
|
||||||
channel: ConfiguredAcpBindingChannel;
|
|
||||||
accountId: string;
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
}): {
|
|
||||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
|
||||||
route: ResolvedAgentRoute;
|
|
||||||
boundSessionKey?: string;
|
|
||||||
boundAgentId?: string;
|
|
||||||
} {
|
|
||||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel: params.channel,
|
|
||||||
accountId: params.accountId,
|
|
||||||
conversationId: params.conversationId,
|
|
||||||
parentConversationId: params.parentConversationId,
|
|
||||||
});
|
|
||||||
if (!configuredBinding) {
|
|
||||||
return {
|
|
||||||
configuredBinding: null,
|
|
||||||
route: params.route,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? "";
|
|
||||||
if (!boundSessionKey) {
|
|
||||||
return {
|
|
||||||
configuredBinding,
|
|
||||||
route: params.route,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
|
|
||||||
return {
|
|
||||||
configuredBinding,
|
|
||||||
boundSessionKey,
|
|
||||||
boundAgentId,
|
|
||||||
route: {
|
|
||||||
...params.route,
|
|
||||||
sessionKey: boundSessionKey,
|
|
||||||
agentId: boundAgentId,
|
|
||||||
lastRoutePolicy: deriveLastRoutePolicy({
|
|
||||||
sessionKey: boundSessionKey,
|
|
||||||
mainSessionKey: params.route.mainSessionKey,
|
|
||||||
}),
|
|
||||||
matchedBy: "binding.channel",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureConfiguredAcpRouteReady(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
|
||||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
||||||
if (!params.configuredBinding) {
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
const ensured = await ensureConfiguredAcpBindingSession({
|
|
||||||
cfg: params.cfg,
|
|
||||||
spec: params.configuredBinding.spec,
|
|
||||||
});
|
|
||||||
if (ensured.ok) {
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: ensured.error ?? "unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { discordPlugin } from "../../extensions/discord/src/channel.js";
|
import { discordPlugin } from "../../extensions/discord/src/channel.js";
|
||||||
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
|
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
|
||||||
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
||||||
|
import { importFreshModule } from "../../test/helpers/import-fresh.js";
|
||||||
|
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
|
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
|
||||||
const managerMocks = vi.hoisted(() => ({
|
const managerMocks = vi.hoisted(() => ({
|
||||||
resolveSession: vi.fn(),
|
resolveSession: vi.fn(),
|
||||||
closeSession: vi.fn(),
|
closeSession: vi.fn(),
|
||||||
@ -27,17 +30,24 @@ vi.mock("./runtime/session-meta.js", () => ({
|
|||||||
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type PersistentBindingsModule = typeof import("./persistent-bindings.js");
|
type PersistentBindingsModule = Pick<
|
||||||
|
typeof import("./persistent-bindings.resolve.js"),
|
||||||
let buildConfiguredAcpSessionKey: PersistentBindingsModule["buildConfiguredAcpSessionKey"];
|
"resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey"
|
||||||
let ensureConfiguredAcpBindingSession: PersistentBindingsModule["ensureConfiguredAcpBindingSession"];
|
> &
|
||||||
let resetAcpSessionInPlace: PersistentBindingsModule["resetAcpSessionInPlace"];
|
Pick<
|
||||||
let resolveConfiguredAcpBindingRecord: PersistentBindingsModule["resolveConfiguredAcpBindingRecord"];
|
typeof import("./persistent-bindings.lifecycle.js"),
|
||||||
let resolveConfiguredAcpBindingSpecBySessionKey: PersistentBindingsModule["resolveConfiguredAcpBindingSpecBySessionKey"];
|
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
|
||||||
|
>;
|
||||||
|
let persistentBindings: PersistentBindingsModule;
|
||||||
|
let persistentBindingsImportScope = 0;
|
||||||
|
|
||||||
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||||
type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0];
|
type BindingRecordInput = Parameters<
|
||||||
type BindingSpec = Parameters<typeof ensureConfiguredAcpBindingSession>[0]["spec"];
|
PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]
|
||||||
|
>[0];
|
||||||
|
type BindingSpec = Parameters<
|
||||||
|
PersistentBindingsModule["ensureConfiguredAcpBindingSession"]
|
||||||
|
>[0]["spec"];
|
||||||
|
|
||||||
const baseCfg = {
|
const baseCfg = {
|
||||||
session: { mainKey: "main", scope: "per-sender" },
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
@ -117,7 +127,7 @@ function createFeishuBinding(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
||||||
return resolveConfiguredAcpBindingRecord({
|
return persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: defaultDiscordAccountId,
|
accountId: defaultDiscordAccountId,
|
||||||
@ -131,7 +141,7 @@ function resolveDiscordBindingSpecBySession(
|
|||||||
conversationId = defaultDiscordConversationId,
|
conversationId = defaultDiscordConversationId,
|
||||||
) {
|
) {
|
||||||
const resolved = resolveBindingRecord(cfg, { conversationId });
|
const resolved = resolveBindingRecord(cfg, { conversationId });
|
||||||
return resolveConfiguredAcpBindingSpecBySessionKey({
|
return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||||
cfg,
|
cfg,
|
||||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||||
});
|
});
|
||||||
@ -148,7 +158,11 @@ function createDiscordPersistentSpec(overrides: Partial<BindingSpec> = {}): Bind
|
|||||||
} as BindingSpec;
|
} as BindingSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
function mockReadySession(params: {
|
||||||
|
spec: BindingSpec;
|
||||||
|
cwd: string;
|
||||||
|
state?: "idle" | "running" | "error";
|
||||||
|
}) {
|
||||||
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
|
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
|
||||||
managerMocks.resolveSession.mockReturnValue({
|
managerMocks.resolveSession.mockReturnValue({
|
||||||
kind: "ready",
|
kind: "ready",
|
||||||
@ -159,14 +173,33 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
|||||||
runtimeSessionName: "existing",
|
runtimeSessionName: "existing",
|
||||||
mode: params.spec.mode,
|
mode: params.spec.mode,
|
||||||
runtimeOptions: { cwd: params.cwd },
|
runtimeOptions: { cwd: params.cwd },
|
||||||
state: "idle",
|
state: params.state ?? "idle",
|
||||||
lastActivityAt: Date.now(),
|
lastActivityAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return sessionKey;
|
return sessionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
persistentBindingsImportScope += 1;
|
||||||
|
const [resolveModule, lifecycleModule] = await Promise.all([
|
||||||
|
importFreshModule<typeof import("./persistent-bindings.resolve.js")>(
|
||||||
|
import.meta.url,
|
||||||
|
`./persistent-bindings.resolve.js?scope=${persistentBindingsImportScope}`,
|
||||||
|
),
|
||||||
|
importFreshModule<typeof import("./persistent-bindings.lifecycle.js")>(
|
||||||
|
import.meta.url,
|
||||||
|
`./persistent-bindings.lifecycle.js?scope=${persistentBindingsImportScope}`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
persistentBindings = {
|
||||||
|
resolveConfiguredAcpBindingRecord: resolveModule.resolveConfiguredAcpBindingRecord,
|
||||||
|
resolveConfiguredAcpBindingSpecBySessionKey:
|
||||||
|
resolveModule.resolveConfiguredAcpBindingSpecBySessionKey,
|
||||||
|
ensureConfiguredAcpBindingSession: lifecycleModule.ensureConfiguredAcpBindingSession,
|
||||||
|
resetAcpSessionInPlace: lifecycleModule.resetAcpSessionInPlace,
|
||||||
|
};
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
createTestRegistry([
|
createTestRegistry([
|
||||||
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
||||||
@ -184,17 +217,6 @@ beforeEach(() => {
|
|||||||
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
({
|
|
||||||
buildConfiguredAcpSessionKey,
|
|
||||||
ensureConfiguredAcpBindingSession,
|
|
||||||
resetAcpSessionInPlace,
|
|
||||||
resolveConfiguredAcpBindingRecord,
|
|
||||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
|
||||||
} = await import("./persistent-bindings.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveConfiguredAcpBindingRecord", () => {
|
describe("resolveConfiguredAcpBindingRecord", () => {
|
||||||
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
||||||
const cfg = createCfgWithBindings([
|
const cfg = createCfgWithBindings([
|
||||||
@ -263,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "work",
|
accountId: "work",
|
||||||
@ -318,13 +340,13 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canonical = resolveConfiguredAcpBindingRecord({
|
const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
conversationId: "-1001234567890:topic:42",
|
conversationId: "-1001234567890:topic:42",
|
||||||
});
|
});
|
||||||
const splitIds = resolveConfiguredAcpBindingRecord({
|
const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -347,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -364,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -384,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -405,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -427,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -449,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -468,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -514,6 +536,25 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||||||
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
|
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
|
||||||
expect(resolved?.spec.backend).toBe("acpx");
|
expect(resolved?.spec.backend).toBe("acpx");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives configured binding cwd from an explicit agent workspace", () => {
|
||||||
|
const cfg = createCfgWithBindings(
|
||||||
|
[
|
||||||
|
createDiscordBinding({
|
||||||
|
agentId: "codex",
|
||||||
|
conversationId: defaultDiscordConversationId,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const resolved = resolveBindingRecord(cfg);
|
||||||
|
|
||||||
|
expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||||
@ -534,7 +575,7 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for unknown session keys", () => {
|
it("returns null for unknown session keys", () => {
|
||||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||||
cfg: baseCfg,
|
cfg: baseCfg,
|
||||||
sessionKey: "agent:main:acp:binding:discord:default:notfound",
|
sessionKey: "agent:main:acp:binding:discord:default:notfound",
|
||||||
});
|
});
|
||||||
@ -568,13 +609,13 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
|||||||
acp: { backend: "acpx" },
|
acp: { backend: "acpx" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const resolved = resolveConfiguredAcpBindingRecord({
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
conversationId: "user_123",
|
conversationId: "user_123",
|
||||||
});
|
});
|
||||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||||
cfg,
|
cfg,
|
||||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||||
});
|
});
|
||||||
@ -614,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||||||
cwd: "/workspace/openclaw",
|
cwd: "/workspace/openclaw",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ensured = await ensureConfiguredAcpBindingSession({
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||||
cfg: baseCfg,
|
cfg: baseCfg,
|
||||||
spec,
|
spec,
|
||||||
});
|
});
|
||||||
@ -633,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||||||
cwd: "/workspace/other-repo",
|
cwd: "/workspace/other-repo",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ensured = await ensureConfiguredAcpBindingSession({
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||||
cfg: baseCfg,
|
cfg: baseCfg,
|
||||||
spec,
|
spec,
|
||||||
});
|
});
|
||||||
@ -649,6 +690,26 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||||||
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a matching ready session even when the stored ACP session is in error state", async () => {
|
||||||
|
const spec = createDiscordPersistentSpec({
|
||||||
|
cwd: "/home/bob/clawd",
|
||||||
|
});
|
||||||
|
const sessionKey = mockReadySession({
|
||||||
|
spec,
|
||||||
|
cwd: "/home/bob/clawd",
|
||||||
|
state: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||||
|
cfg: baseCfg,
|
||||||
|
spec,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ensured).toEqual({ ok: true, sessionKey });
|
||||||
|
expect(managerMocks.closeSession).not.toHaveBeenCalled();
|
||||||
|
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("initializes ACP session with runtime agent override when provided", async () => {
|
it("initializes ACP session with runtime agent override when provided", async () => {
|
||||||
const spec = createDiscordPersistentSpec({
|
const spec = createDiscordPersistentSpec({
|
||||||
agentId: "coding",
|
agentId: "coding",
|
||||||
@ -656,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||||||
});
|
});
|
||||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||||
|
|
||||||
const ensured = await ensureConfiguredAcpBindingSession({
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||||
cfg: baseCfg,
|
cfg: baseCfg,
|
||||||
spec,
|
spec,
|
||||||
});
|
});
|
||||||
@ -692,7 +753,7 @@ describe("resetAcpSessionInPlace", () => {
|
|||||||
});
|
});
|
||||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||||
|
|
||||||
const result = await resetAcpSessionInPlace({
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||||
cfg,
|
cfg,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
reason: "new",
|
reason: "new",
|
||||||
@ -721,7 +782,7 @@ describe("resetAcpSessionInPlace", () => {
|
|||||||
});
|
});
|
||||||
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
|
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
|
||||||
|
|
||||||
const result = await resetAcpSessionInPlace({
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||||
cfg: baseCfg,
|
cfg: baseCfg,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
reason: "reset",
|
reason: "reset",
|
||||||
@ -752,7 +813,7 @@ describe("resetAcpSessionInPlace", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resetAcpSessionInPlace({
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||||
cfg,
|
cfg,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
reason: "reset",
|
reason: "reset",
|
||||||
@ -766,4 +827,64 @@ describe("resetAcpSessionInPlace", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => {
|
||||||
|
const cfg = createCfgWithBindings(
|
||||||
|
[
|
||||||
|
createDiscordBinding({
|
||||||
|
agentId: "coding",
|
||||||
|
conversationId: "1478844424791396446",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{ id: "main" },
|
||||||
|
{
|
||||||
|
id: "coding",
|
||||||
|
runtime: {
|
||||||
|
type: "acp",
|
||||||
|
acp: {
|
||||||
|
agent: "codex",
|
||||||
|
backend: "acpx",
|
||||||
|
mode: "persistent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "claude" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const sessionKey = buildConfiguredAcpSessionKey({
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1478844424791396446",
|
||||||
|
agentId: "coding",
|
||||||
|
acpAgentId: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
backend: "acpx",
|
||||||
|
});
|
||||||
|
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
||||||
|
acp: {
|
||||||
|
mode: "persistent",
|
||||||
|
backend: "acpx",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||||
|
cfg,
|
||||||
|
sessionKey,
|
||||||
|
reason: "reset",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionKey,
|
||||||
|
agent: "codex",
|
||||||
|
backendId: "acpx",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
export {
|
|
||||||
buildConfiguredAcpSessionKey,
|
|
||||||
normalizeBindingConfig,
|
|
||||||
normalizeMode,
|
|
||||||
normalizeText,
|
|
||||||
toConfiguredAcpBindingRecord,
|
|
||||||
type AcpBindingConfigShape,
|
|
||||||
type ConfiguredAcpBindingChannel,
|
|
||||||
type ConfiguredAcpBindingSpec,
|
|
||||||
type ResolvedConfiguredAcpBinding,
|
|
||||||
} from "./persistent-bindings.types.js";
|
|
||||||
export {
|
|
||||||
ensureConfiguredAcpBindingSession,
|
|
||||||
resetAcpSessionInPlace,
|
|
||||||
} from "./persistent-bindings.lifecycle.js";
|
|
||||||
export {
|
|
||||||
resolveConfiguredAcpBindingRecord,
|
|
||||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
|
||||||
} from "./persistent-bindings.resolve.js";
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
|
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
|
||||||
|
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||||
|
|
||||||
@ -104,3 +105,72 @@ export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): Se
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseConfiguredAcpSessionKey(
|
||||||
|
sessionKey: string,
|
||||||
|
): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
|
||||||
|
const trimmed = sessionKey.trim();
|
||||||
|
if (!trimmed.startsWith("agent:")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rest = trimmed.slice(trimmed.indexOf(":") + 1);
|
||||||
|
const nextSeparator = rest.indexOf(":");
|
||||||
|
if (nextSeparator === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tokens = rest.slice(nextSeparator + 1).split(":");
|
||||||
|
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const channel = tokens[2]?.trim().toLowerCase();
|
||||||
|
if (!channel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel: channel as ConfiguredAcpBindingChannel,
|
||||||
|
accountId: normalizeAccountId(tokens[3] ?? "default"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredAcpBindingSpecFromRecord(
|
||||||
|
record: SessionBindingRecord,
|
||||||
|
): ConfiguredAcpBindingSpec | null {
|
||||||
|
if (record.targetKind !== "session") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const conversationId = record.conversation.conversationId.trim();
|
||||||
|
if (!conversationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const agentId =
|
||||||
|
normalizeText(record.metadata?.agentId) ??
|
||||||
|
resolveAgentIdFromSessionKey(record.targetSessionKey);
|
||||||
|
if (!agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel: record.conversation.channel as ConfiguredAcpBindingChannel,
|
||||||
|
accountId: normalizeAccountId(record.conversation.accountId),
|
||||||
|
conversationId,
|
||||||
|
parentConversationId: normalizeText(record.conversation.parentConversationId),
|
||||||
|
agentId,
|
||||||
|
acpAgentId: normalizeText(record.metadata?.acpAgentId),
|
||||||
|
mode: normalizeMode(record.metadata?.mode),
|
||||||
|
cwd: normalizeText(record.metadata?.cwd),
|
||||||
|
backend: normalizeText(record.metadata?.backend),
|
||||||
|
label: normalizeText(record.metadata?.label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toResolvedConfiguredAcpBinding(
|
||||||
|
record: SessionBindingRecord,
|
||||||
|
): ResolvedConfiguredAcpBinding | null {
|
||||||
|
const spec = resolveConfiguredAcpBindingSpecFromRecord(record);
|
||||||
|
if (!spec) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
record,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
activeSessionKey: sessionKey.toLowerCase(),
|
activeSessionKey: sessionKey.toLowerCase(),
|
||||||
|
allowDropAcpMetaSessionKeys: [sessionKey],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
|
import { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
||||||
@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
const configuredBinding = resolveConfiguredBindingRecord({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel,
|
channel,
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
|
import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||||
@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
|||||||
? boundAcpSessionKey.trim()
|
? boundAcpSessionKey.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
if (boundAcpKey) {
|
if (boundAcpKey) {
|
||||||
const resetResult = await resetAcpSessionInPlace({
|
const resetResult = await resetConfiguredBindingTargetInPlace({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
sessionKey: boundAcpKey,
|
sessionKey: boundAcpKey,
|
||||||
reason: commandAction,
|
reason: commandAction,
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
|
|
||||||
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||||
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
|
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
|
||||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
import {
|
||||||
|
createChannelTestPluginBase,
|
||||||
|
createTestRegistry,
|
||||||
|
} from "../../test-utils/channel-plugins.js";
|
||||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
@ -192,14 +193,16 @@ vi.mock("../../tts/tts.js", () => ({
|
|||||||
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
|
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
|
||||||
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
|
||||||
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
|
|
||||||
const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js");
|
|
||||||
|
|
||||||
const noAbortResult = { handled: false, aborted: false } as const;
|
const noAbortResult = { handled: false, aborted: false } as const;
|
||||||
const emptyConfig = {} as OpenClawConfig;
|
const emptyConfig = {} as OpenClawConfig;
|
||||||
type DispatchReplyArgs = Parameters<typeof dispatchReplyFromConfig>[0];
|
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
||||||
|
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
||||||
|
let acpManagerTesting: typeof import("../../acp/control-plane/manager.js").__testing;
|
||||||
|
let pluginBindingTesting: typeof import("../../plugins/conversation-binding.js").__testing;
|
||||||
|
let AcpRuntimeErrorClass: typeof import("../../acp/runtime/errors.js").AcpRuntimeError;
|
||||||
|
type DispatchReplyArgs = Parameters<
|
||||||
|
typeof import("./dispatch-from-config.js").dispatchReplyFromConfig
|
||||||
|
>[0];
|
||||||
|
|
||||||
function createDispatcher(): ReplyDispatcher {
|
function createDispatcher(): ReplyDispatcher {
|
||||||
return {
|
return {
|
||||||
@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("dispatchReplyFromConfig", () => {
|
describe("dispatchReplyFromConfig", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
|
||||||
|
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
|
||||||
|
({ __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"));
|
||||||
|
({ __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"));
|
||||||
|
({ AcpRuntimeError: AcpRuntimeErrorClass } = await import("../../acp/runtime/errors.js"));
|
||||||
|
const discordTestPlugin = {
|
||||||
|
...createChannelTestPluginBase({
|
||||||
|
id: "discord",
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct"],
|
||||||
|
nativeCommands: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
execApprovals: {
|
||||||
|
shouldSuppressLocalPrompt: ({ payload }: { payload: ReplyPayload }) =>
|
||||||
|
Boolean(
|
||||||
|
payload.channelData &&
|
||||||
|
typeof payload.channelData === "object" &&
|
||||||
|
!Array.isArray(payload.channelData) &&
|
||||||
|
payload.channelData.execApproval,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "discord",
|
||||||
|
source: "test",
|
||||||
|
plugin: discordTestPlugin,
|
||||||
|
},
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
|
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
|
||||||
throw new AcpRuntimeError(
|
throw new AcpRuntimeErrorClass(
|
||||||
"ACP_BACKEND_MISSING",
|
"ACP_BACKEND_MISSING",
|
||||||
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||||
|
import {
|
||||||
|
resolveConversationBindingRecord,
|
||||||
|
touchConversationBindingRecord,
|
||||||
|
} from "../../bindings/records.js";
|
||||||
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
|
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
@ -20,7 +24,6 @@ import {
|
|||||||
toPluginMessageReceivedEvent,
|
toPluginMessageReceivedEvent,
|
||||||
} from "../../hooks/message-hook-mappers.js";
|
} from "../../hooks/message-hook-mappers.js";
|
||||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
|
||||||
import {
|
import {
|
||||||
logMessageProcessed,
|
logMessageProcessed,
|
||||||
logMessageQueued,
|
logMessageQueued,
|
||||||
@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
|
|
||||||
const pluginOwnedBindingRecord =
|
const pluginOwnedBindingRecord =
|
||||||
inboundClaimContext.conversationId && inboundClaimContext.channelId
|
inboundClaimContext.conversationId && inboundClaimContext.channelId
|
||||||
? getSessionBindingService().resolveByConversation({
|
? resolveConversationBindingRecord({
|
||||||
channel: inboundClaimContext.channelId,
|
channel: inboundClaimContext.channelId,
|
||||||
accountId: inboundClaimContext.accountId ?? "default",
|
accountId: inboundClaimContext.accountId ?? "default",
|
||||||
conversationId: inboundClaimContext.conversationId,
|
conversationId: inboundClaimContext.conversationId,
|
||||||
@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (pluginOwnedBinding) {
|
if (pluginOwnedBinding) {
|
||||||
getSessionBindingService().touch(pluginOwnedBinding.bindingId);
|
touchConversationBindingRecord(pluginOwnedBinding.bindingId);
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
|
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
|
conversationBindingResolvedHandlers: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -300,7 +301,7 @@ describe("routeReply", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes thread id to Telegram sends", async () => {
|
it("passes thread id to Telegram sends", async () => {
|
||||||
mocks.sendMessageTelegram.mockClear();
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||||
await routeReply({
|
await routeReply({
|
||||||
payload: { text: "hi" },
|
payload: { text: "hi" },
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@ -308,10 +309,12 @@ describe("routeReply", () => {
|
|||||||
threadId: 42,
|
threadId: 42,
|
||||||
cfg: {} as never,
|
cfg: {} as never,
|
||||||
});
|
});
|
||||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||||
"telegram:123",
|
expect.objectContaining({
|
||||||
"hi",
|
channel: "telegram",
|
||||||
expect.objectContaining({ messageThreadId: 42 }),
|
to: "telegram:123",
|
||||||
|
threadId: 42,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -346,17 +349,19 @@ describe("routeReply", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes replyToId to Telegram sends", async () => {
|
it("passes replyToId to Telegram sends", async () => {
|
||||||
mocks.sendMessageTelegram.mockClear();
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||||
await routeReply({
|
await routeReply({
|
||||||
payload: { text: "hi", replyToId: "123" },
|
payload: { text: "hi", replyToId: "123" },
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
to: "telegram:123",
|
to: "telegram:123",
|
||||||
cfg: {} as never,
|
cfg: {} as never,
|
||||||
});
|
});
|
||||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||||
"telegram:123",
|
expect.objectContaining({
|
||||||
"hi",
|
channel: "telegram",
|
||||||
expect.objectContaining({ replyToMessageId: 123 }),
|
to: "telegram:123",
|
||||||
|
replyToId: "123",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
48
src/bindings/records.ts
Normal file
48
src/bindings/records.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
getSessionBindingService,
|
||||||
|
type ConversationRef,
|
||||||
|
type SessionBindingBindInput,
|
||||||
|
type SessionBindingCapabilities,
|
||||||
|
type SessionBindingRecord,
|
||||||
|
type SessionBindingUnbindInput,
|
||||||
|
} from "../infra/outbound/session-binding-service.js";
|
||||||
|
|
||||||
|
// Shared binding record helpers used by both configured bindings and
|
||||||
|
// runtime-created plugin conversation bindings.
|
||||||
|
export async function createConversationBindingRecord(
|
||||||
|
input: SessionBindingBindInput,
|
||||||
|
): Promise<SessionBindingRecord> {
|
||||||
|
return await getSessionBindingService().bind(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConversationBindingCapabilities(params: {
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
}): SessionBindingCapabilities {
|
||||||
|
return getSessionBindingService().getCapabilities(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessionBindingRecords(targetSessionKey: string): SessionBindingRecord[] {
|
||||||
|
return getSessionBindingService().listBySession(targetSessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConversationBindingRecord(
|
||||||
|
conversation: ConversationRef,
|
||||||
|
): SessionBindingRecord | null {
|
||||||
|
return getSessionBindingService().resolveByConversation(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchConversationBindingRecord(bindingId: string, at?: number): void {
|
||||||
|
const service = getSessionBindingService();
|
||||||
|
if (typeof at === "number") {
|
||||||
|
service.touch(bindingId, at);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.touch(bindingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbindConversationBindingRecord(
|
||||||
|
input: SessionBindingUnbindInput,
|
||||||
|
): Promise<SessionBindingRecord[]> {
|
||||||
|
return await getSessionBindingService().unbind(input);
|
||||||
|
}
|
||||||
252
src/channels/plugins/acp-bindings.test.ts
Normal file
252
src/channels/plugins/acp-bindings.test.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js";
|
||||||
|
|
||||||
|
const resolveAgentConfigMock = vi.hoisted(() => vi.fn());
|
||||||
|
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
|
||||||
|
const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getChannelPluginMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getActivePluginRegistryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
|
resolveAgentConfig: (...args: unknown[]) => resolveAgentConfigMock(...args),
|
||||||
|
resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args),
|
||||||
|
resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./index.js", () => ({
|
||||||
|
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/runtime.js", () => ({
|
||||||
|
getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args),
|
||||||
|
getActivePluginRegistryVersion: (...args: unknown[]) =>
|
||||||
|
getActivePluginRegistryVersionMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function importConfiguredBindings() {
|
||||||
|
const builtins = await import("./configured-binding-builtins.js");
|
||||||
|
builtins.ensureConfiguredBindingBuiltinsRegistered();
|
||||||
|
return await import("./configured-binding-registry.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfig(options?: { bindingAgentId?: string; accountId?: string }) {
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "main" }, { id: "codex" }],
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
type: "acp",
|
||||||
|
agentId: options?.bindingAgentId ?? "codex",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: options?.accountId ?? "default",
|
||||||
|
peer: {
|
||||||
|
kind: "channel",
|
||||||
|
id: "1479098716916023408",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acp: {
|
||||||
|
backend: "acpx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDiscordAcpPlugin(overrides?: {
|
||||||
|
compileConfiguredBinding?: ReturnType<typeof vi.fn>;
|
||||||
|
matchInboundConversation?: ReturnType<typeof vi.fn>;
|
||||||
|
}) {
|
||||||
|
const compileConfiguredBinding =
|
||||||
|
overrides?.compileConfiguredBinding ??
|
||||||
|
vi.fn(({ conversationId }: { conversationId: string }) => ({
|
||||||
|
conversationId,
|
||||||
|
}));
|
||||||
|
const matchInboundConversation =
|
||||||
|
overrides?.matchInboundConversation ??
|
||||||
|
vi.fn(
|
||||||
|
({
|
||||||
|
compiledBinding,
|
||||||
|
conversationId,
|
||||||
|
parentConversationId,
|
||||||
|
}: {
|
||||||
|
compiledBinding: { conversationId: string };
|
||||||
|
conversationId: string;
|
||||||
|
parentConversationId?: string;
|
||||||
|
}) => {
|
||||||
|
if (compiledBinding.conversationId === conversationId) {
|
||||||
|
return { conversationId, matchPriority: 2 };
|
||||||
|
}
|
||||||
|
if (parentConversationId && compiledBinding.conversationId === parentConversationId) {
|
||||||
|
return { conversationId: parentConversationId, matchPriority: 1 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: "discord",
|
||||||
|
bindings: {
|
||||||
|
compileConfiguredBinding,
|
||||||
|
matchInboundConversation,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("configured binding registry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
resolveAgentConfigMock.mockReset().mockReturnValue(undefined);
|
||||||
|
resolveDefaultAgentIdMock.mockReset().mockReturnValue("main");
|
||||||
|
resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace");
|
||||||
|
getChannelPluginMock.mockReset();
|
||||||
|
getActivePluginRegistryMock.mockReset().mockReturnValue({ channels: [] });
|
||||||
|
getActivePluginRegistryVersionMock.mockReset().mockReturnValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves configured ACP bindings from an already loaded channel plugin", async () => {
|
||||||
|
const plugin = createDiscordAcpPlugin();
|
||||||
|
getChannelPluginMock.mockReturnValue(plugin);
|
||||||
|
const bindingRegistry = await importConfiguredBindings();
|
||||||
|
|
||||||
|
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: createConfig() as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved?.record.conversation.channel).toBe("discord");
|
||||||
|
expect(resolved?.record.metadata?.backend).toBe("acpx");
|
||||||
|
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves configured ACP bindings from canonical conversation refs", async () => {
|
||||||
|
const plugin = createDiscordAcpPlugin();
|
||||||
|
getChannelPluginMock.mockReturnValue(plugin);
|
||||||
|
const bindingRegistry = await importConfiguredBindings();
|
||||||
|
|
||||||
|
const resolved = bindingRegistry.resolveConfiguredBinding({
|
||||||
|
cfg: createConfig() as never,
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved?.conversation).toEqual({
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
expect(resolved?.record.conversation.channel).toBe("discord");
|
||||||
|
expect(resolved?.statefulTarget).toEqual({
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: resolved?.record.targetSessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
label: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("primes compiled ACP bindings from the already loaded active registry once", async () => {
|
||||||
|
const plugin = createDiscordAcpPlugin();
|
||||||
|
const cfg = createConfig({ bindingAgentId: "codex" });
|
||||||
|
getChannelPluginMock.mockReturnValue(undefined);
|
||||||
|
getActivePluginRegistryMock.mockReturnValue({
|
||||||
|
channels: [{ plugin }],
|
||||||
|
});
|
||||||
|
const bindingRegistry = await importConfiguredBindings();
|
||||||
|
|
||||||
|
const primed = bindingRegistry.primeConfiguredBindingRegistry({
|
||||||
|
cfg: cfg as never,
|
||||||
|
});
|
||||||
|
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: cfg as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(primed).toEqual({ bindingCount: 1, channelCount: 1 });
|
||||||
|
expect(resolved?.statefulTarget.agentId).toBe("codex");
|
||||||
|
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const second = bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: cfg as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second?.statefulTarget.agentId).toBe("codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves wildcard binding session keys from the compiled registry", async () => {
|
||||||
|
const plugin = createDiscordAcpPlugin();
|
||||||
|
getChannelPluginMock.mockReturnValue(plugin);
|
||||||
|
const bindingRegistry = await importConfiguredBindings();
|
||||||
|
|
||||||
|
const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({
|
||||||
|
cfg: createConfig({ accountId: "*" }) as never,
|
||||||
|
sessionKey: buildConfiguredAcpSessionKey({
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "work",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
agentId: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
backend: "acpx",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved?.record.conversation.channel).toBe("discord");
|
||||||
|
expect(resolved?.record.conversation.accountId).toBe("work");
|
||||||
|
expect(resolved?.record.metadata?.backend).toBe("acpx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not perform late plugin discovery when a channel plugin is unavailable", async () => {
|
||||||
|
const bindingRegistry = await importConfiguredBindings();
|
||||||
|
|
||||||
|
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: createConfig() as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds the compiled registry when the active plugin registry version changes", async () => {
|
||||||
|
const plugin = createDiscordAcpPlugin();
|
||||||
|
getChannelPluginMock.mockReturnValue(plugin);
|
||||||
|
getActivePluginRegistryVersionMock.mockReturnValue(10);
|
||||||
|
const cfg = createConfig();
|
||||||
|
const bindingRegistry = await importConfiguredBindings();
|
||||||
|
|
||||||
|
bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: cfg as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: cfg as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
|
||||||
|
getActivePluginRegistryVersionMock.mockReturnValue(11);
|
||||||
|
bindingRegistry.resolveConfiguredBindingRecord({
|
||||||
|
cfg: cfg as never,
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "1479098716916023408",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
src/channels/plugins/acp-configured-binding-consumer.ts
Normal file
155
src/channels/plugins/acp-configured-binding-consumer.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
buildConfiguredAcpSessionKey,
|
||||||
|
normalizeBindingConfig,
|
||||||
|
normalizeMode,
|
||||||
|
normalizeText,
|
||||||
|
parseConfiguredAcpSessionKey,
|
||||||
|
toConfiguredAcpBindingRecord,
|
||||||
|
type ConfiguredAcpBindingSpec,
|
||||||
|
} from "../../acp/persistent-bindings.types.js";
|
||||||
|
import {
|
||||||
|
resolveAgentConfig,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
} from "../../agents/agent-scope.js";
|
||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type {
|
||||||
|
ConfiguredBindingRuleConfig,
|
||||||
|
ConfiguredBindingTargetFactory,
|
||||||
|
} from "./binding-types.js";
|
||||||
|
import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js";
|
||||||
|
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
|
||||||
|
|
||||||
|
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
|
||||||
|
acpAgentId?: string;
|
||||||
|
mode?: string;
|
||||||
|
cwd?: string;
|
||||||
|
backend?: string;
|
||||||
|
} {
|
||||||
|
const agent = params.cfg.agents?.list?.find(
|
||||||
|
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!agent || agent.runtime?.type !== "acp") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
acpAgentId: normalizeText(agent.runtime.acp?.agent),
|
||||||
|
mode: normalizeText(agent.runtime.acp?.mode),
|
||||||
|
cwd: normalizeText(agent.runtime.acp?.cwd),
|
||||||
|
backend: normalizeText(agent.runtime.acp?.backend),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredBindingWorkspaceCwd(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
agentId: string;
|
||||||
|
}): string | undefined {
|
||||||
|
const explicitAgentWorkspace = normalizeText(
|
||||||
|
resolveAgentConfig(params.cfg, params.agentId)?.workspace,
|
||||||
|
);
|
||||||
|
if (explicitAgentWorkspace) {
|
||||||
|
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||||
|
}
|
||||||
|
if (params.agentId === resolveDefaultAgentId(params.cfg)) {
|
||||||
|
const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace);
|
||||||
|
if (defaultWorkspace) {
|
||||||
|
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfiguredAcpSpec(params: {
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
conversation: ChannelConfiguredBindingConversationRef;
|
||||||
|
agentId: string;
|
||||||
|
acpAgentId?: string;
|
||||||
|
mode: "persistent" | "oneshot";
|
||||||
|
cwd?: string;
|
||||||
|
backend?: string;
|
||||||
|
label?: string;
|
||||||
|
}): ConfiguredAcpBindingSpec {
|
||||||
|
return {
|
||||||
|
channel: params.channel as ConfiguredAcpBindingSpec["channel"],
|
||||||
|
accountId: params.accountId,
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
parentConversationId: params.conversation.parentConversationId,
|
||||||
|
agentId: params.agentId,
|
||||||
|
acpAgentId: params.acpAgentId,
|
||||||
|
mode: params.mode,
|
||||||
|
cwd: params.cwd,
|
||||||
|
backend: params.backend,
|
||||||
|
label: params.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAcpTargetFactory(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
binding: ConfiguredBindingRuleConfig;
|
||||||
|
channel: string;
|
||||||
|
agentId: string;
|
||||||
|
}): ConfiguredBindingTargetFactory | null {
|
||||||
|
if (params.binding.type !== "acp") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
|
||||||
|
cfg: params.cfg,
|
||||||
|
ownerAgentId: params.agentId,
|
||||||
|
});
|
||||||
|
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
|
||||||
|
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
|
||||||
|
const cwd =
|
||||||
|
bindingOverrides.cwd ??
|
||||||
|
runtimeDefaults.cwd ??
|
||||||
|
resolveConfiguredBindingWorkspaceCwd({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentId: params.agentId,
|
||||||
|
});
|
||||||
|
const backend = bindingOverrides.backend ?? runtimeDefaults.backend;
|
||||||
|
const label = bindingOverrides.label;
|
||||||
|
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
driverId: "acp",
|
||||||
|
materialize: ({ accountId, conversation }) => {
|
||||||
|
const spec = buildConfiguredAcpSpec({
|
||||||
|
channel: params.channel,
|
||||||
|
accountId,
|
||||||
|
conversation,
|
||||||
|
agentId: params.agentId,
|
||||||
|
acpAgentId,
|
||||||
|
mode,
|
||||||
|
cwd,
|
||||||
|
backend,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
const record = toConfiguredAcpBindingRecord(spec);
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: buildConfiguredAcpSessionKey(spec),
|
||||||
|
agentId: params.agentId,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = {
|
||||||
|
id: "acp",
|
||||||
|
supports: (binding) => binding.type === "acp",
|
||||||
|
buildTargetFactory: (params) =>
|
||||||
|
buildAcpTargetFactory({
|
||||||
|
cfg: params.cfg,
|
||||||
|
binding: params.binding,
|
||||||
|
channel: params.channel,
|
||||||
|
agentId: params.agentId,
|
||||||
|
}),
|
||||||
|
parseSessionKey: ({ sessionKey }) => parseConfiguredAcpSessionKey(sessionKey),
|
||||||
|
matchesSessionKey: ({ sessionKey, materializedTarget }) =>
|
||||||
|
materializedTarget.record.targetSessionKey === sessionKey,
|
||||||
|
};
|
||||||
102
src/channels/plugins/acp-stateful-target-driver.ts
Normal file
102
src/channels/plugins/acp-stateful-target-driver.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
ensureConfiguredAcpBindingReady,
|
||||||
|
ensureConfiguredAcpBindingSession,
|
||||||
|
resetAcpSessionInPlace,
|
||||||
|
} from "../../acp/persistent-bindings.lifecycle.js";
|
||||||
|
import { resolveConfiguredAcpBindingSpecBySessionKey } from "../../acp/persistent-bindings.resolve.js";
|
||||||
|
import { resolveConfiguredAcpBindingSpecFromRecord } from "../../acp/persistent-bindings.types.js";
|
||||||
|
import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js";
|
||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type {
|
||||||
|
ConfiguredBindingResolution,
|
||||||
|
StatefulBindingTargetDescriptor,
|
||||||
|
} from "./binding-types.js";
|
||||||
|
import type {
|
||||||
|
StatefulBindingTargetDriver,
|
||||||
|
StatefulBindingTargetResetResult,
|
||||||
|
StatefulBindingTargetReadyResult,
|
||||||
|
StatefulBindingTargetSessionResult,
|
||||||
|
} from "./stateful-target-drivers.js";
|
||||||
|
|
||||||
|
function toAcpStatefulBindingTargetDescriptor(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
}): StatefulBindingTargetDescriptor | null {
|
||||||
|
const meta = readAcpSessionEntry(params)?.acp;
|
||||||
|
const metaAgentId = meta?.agent?.trim();
|
||||||
|
if (metaAgentId) {
|
||||||
|
return {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
agentId: metaAgentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const spec = resolveConfiguredAcpBindingSpecBySessionKey(params);
|
||||||
|
if (!spec) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "acp",
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
agentId: spec.agentId,
|
||||||
|
...(spec.label ? { label: spec.label } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAcpTargetReady(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution;
|
||||||
|
}): Promise<StatefulBindingTargetReadyResult> {
|
||||||
|
const configuredBinding = resolveConfiguredAcpBindingSpecFromRecord(
|
||||||
|
params.bindingResolution.record,
|
||||||
|
);
|
||||||
|
if (!configuredBinding) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Configured ACP binding unavailable",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await ensureConfiguredAcpBindingReady({
|
||||||
|
cfg: params.cfg,
|
||||||
|
configuredBinding: {
|
||||||
|
spec: configuredBinding,
|
||||||
|
record: params.bindingResolution.record,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAcpTargetSession(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution;
|
||||||
|
}): Promise<StatefulBindingTargetSessionResult> {
|
||||||
|
const spec = resolveConfiguredAcpBindingSpecFromRecord(params.bindingResolution.record);
|
||||||
|
if (!spec) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
|
||||||
|
error: "Configured ACP binding unavailable",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await ensureConfiguredAcpBindingSession({
|
||||||
|
cfg: params.cfg,
|
||||||
|
spec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAcpTargetInPlace(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
reason: "new" | "reset";
|
||||||
|
}): Promise<StatefulBindingTargetResetResult> {
|
||||||
|
return await resetAcpSessionInPlace(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = {
|
||||||
|
id: "acp",
|
||||||
|
ensureReady: ensureAcpTargetReady,
|
||||||
|
ensureSession: ensureAcpTargetSession,
|
||||||
|
resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor,
|
||||||
|
resetInPlace: resetAcpTargetInPlace,
|
||||||
|
};
|
||||||
14
src/channels/plugins/binding-provider.ts
Normal file
14
src/channels/plugins/binding-provider.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
|
||||||
|
import type { ChannelPlugin } from "./types.plugin.js";
|
||||||
|
|
||||||
|
export function resolveChannelConfiguredBindingProvider(
|
||||||
|
plugin:
|
||||||
|
| Pick<ChannelPlugin, "bindings">
|
||||||
|
| {
|
||||||
|
bindings?: ChannelConfiguredBindingProvider;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
): ChannelConfiguredBindingProvider | undefined {
|
||||||
|
return plugin?.bindings;
|
||||||
|
}
|
||||||
46
src/channels/plugins/binding-registry.ts
Normal file
46
src/channels/plugins/binding-registry.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js";
|
||||||
|
import {
|
||||||
|
primeConfiguredBindingRegistry as primeConfiguredBindingRegistryRaw,
|
||||||
|
resolveConfiguredBinding as resolveConfiguredBindingRaw,
|
||||||
|
resolveConfiguredBindingRecord as resolveConfiguredBindingRecordRaw,
|
||||||
|
resolveConfiguredBindingRecordBySessionKey as resolveConfiguredBindingRecordBySessionKeyRaw,
|
||||||
|
resolveConfiguredBindingRecordForConversation as resolveConfiguredBindingRecordForConversationRaw,
|
||||||
|
} from "./configured-binding-registry.js";
|
||||||
|
|
||||||
|
// Thin public wrapper around the configured-binding registry. Runtime plugin
|
||||||
|
// conversation bindings use a separate approval-driven path in src/plugins/.
|
||||||
|
|
||||||
|
export function primeConfiguredBindingRegistry(
|
||||||
|
...args: Parameters<typeof primeConfiguredBindingRegistryRaw>
|
||||||
|
): ReturnType<typeof primeConfiguredBindingRegistryRaw> {
|
||||||
|
ensureConfiguredBindingBuiltinsRegistered();
|
||||||
|
return primeConfiguredBindingRegistryRaw(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecord(
|
||||||
|
...args: Parameters<typeof resolveConfiguredBindingRecordRaw>
|
||||||
|
): ReturnType<typeof resolveConfiguredBindingRecordRaw> {
|
||||||
|
ensureConfiguredBindingBuiltinsRegistered();
|
||||||
|
return resolveConfiguredBindingRecordRaw(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecordForConversation(
|
||||||
|
...args: Parameters<typeof resolveConfiguredBindingRecordForConversationRaw>
|
||||||
|
): ReturnType<typeof resolveConfiguredBindingRecordForConversationRaw> {
|
||||||
|
ensureConfiguredBindingBuiltinsRegistered();
|
||||||
|
return resolveConfiguredBindingRecordForConversationRaw(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBinding(
|
||||||
|
...args: Parameters<typeof resolveConfiguredBindingRaw>
|
||||||
|
): ReturnType<typeof resolveConfiguredBindingRaw> {
|
||||||
|
ensureConfiguredBindingBuiltinsRegistered();
|
||||||
|
return resolveConfiguredBindingRaw(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecordBySessionKey(
|
||||||
|
...args: Parameters<typeof resolveConfiguredBindingRecordBySessionKeyRaw>
|
||||||
|
): ReturnType<typeof resolveConfiguredBindingRecordBySessionKeyRaw> {
|
||||||
|
ensureConfiguredBindingBuiltinsRegistered();
|
||||||
|
return resolveConfiguredBindingRecordBySessionKeyRaw(...args);
|
||||||
|
}
|
||||||
91
src/channels/plugins/binding-routing.ts
Normal file
91
src/channels/plugins/binding-routing.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
|
||||||
|
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
||||||
|
import { deriveLastRoutePolicy } from "../../routing/resolve-route.js";
|
||||||
|
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||||
|
import { resolveConfiguredBinding } from "./binding-registry.js";
|
||||||
|
import { ensureConfiguredBindingTargetReady } from "./binding-targets.js";
|
||||||
|
import type { ConfiguredBindingResolution } from "./binding-types.js";
|
||||||
|
|
||||||
|
export type ConfiguredBindingRouteResult = {
|
||||||
|
bindingResolution: ConfiguredBindingResolution | null;
|
||||||
|
route: ResolvedAgentRoute;
|
||||||
|
boundSessionKey?: string;
|
||||||
|
boundAgentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfiguredBindingRouteConversationInput =
|
||||||
|
| {
|
||||||
|
conversation: ConversationRef;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
conversationId: string;
|
||||||
|
parentConversationId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveConfiguredBindingConversationRef(
|
||||||
|
params: ConfiguredBindingRouteConversationInput,
|
||||||
|
): ConversationRef {
|
||||||
|
if ("conversation" in params) {
|
||||||
|
return params.conversation;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel: params.channel,
|
||||||
|
accountId: params.accountId,
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
parentConversationId: params.parentConversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRoute(
|
||||||
|
params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
route: ResolvedAgentRoute;
|
||||||
|
} & ConfiguredBindingRouteConversationInput,
|
||||||
|
): ConfiguredBindingRouteResult {
|
||||||
|
const bindingResolution =
|
||||||
|
resolveConfiguredBinding({
|
||||||
|
cfg: params.cfg,
|
||||||
|
conversation: resolveConfiguredBindingConversationRef(params),
|
||||||
|
}) ?? null;
|
||||||
|
if (!bindingResolution) {
|
||||||
|
return {
|
||||||
|
bindingResolution: null,
|
||||||
|
route: params.route,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim();
|
||||||
|
if (!boundSessionKey) {
|
||||||
|
return {
|
||||||
|
bindingResolution,
|
||||||
|
route: params.route,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const boundAgentId =
|
||||||
|
resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId;
|
||||||
|
return {
|
||||||
|
bindingResolution,
|
||||||
|
boundSessionKey,
|
||||||
|
boundAgentId,
|
||||||
|
route: {
|
||||||
|
...params.route,
|
||||||
|
sessionKey: boundSessionKey,
|
||||||
|
agentId: boundAgentId,
|
||||||
|
lastRoutePolicy: deriveLastRoutePolicy({
|
||||||
|
sessionKey: boundSessionKey,
|
||||||
|
mainSessionKey: params.route.mainSessionKey,
|
||||||
|
}),
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureConfiguredBindingRouteReady(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution | null;
|
||||||
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
return await ensureConfiguredBindingTargetReady(params);
|
||||||
|
}
|
||||||
209
src/channels/plugins/binding-targets.test.ts
Normal file
209
src/channels/plugins/binding-targets.test.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ensureConfiguredBindingTargetReady,
|
||||||
|
ensureConfiguredBindingTargetSession,
|
||||||
|
resetConfiguredBindingTargetInPlace,
|
||||||
|
} from "./binding-targets.js";
|
||||||
|
import type { ConfiguredBindingResolution } from "./binding-types.js";
|
||||||
|
import {
|
||||||
|
registerStatefulBindingTargetDriver,
|
||||||
|
unregisterStatefulBindingTargetDriver,
|
||||||
|
type StatefulBindingTargetDriver,
|
||||||
|
} from "./stateful-target-drivers.js";
|
||||||
|
|
||||||
|
function createBindingResolution(driverId: string): ConfiguredBindingResolution {
|
||||||
|
return {
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "123",
|
||||||
|
},
|
||||||
|
compiledBinding: {
|
||||||
|
channel: "discord",
|
||||||
|
binding: {
|
||||||
|
type: "acp" as const,
|
||||||
|
agentId: "codex",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
peer: {
|
||||||
|
kind: "channel" as const,
|
||||||
|
id: "123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acp: {
|
||||||
|
mode: "persistent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindingConversationId: "123",
|
||||||
|
target: {
|
||||||
|
conversationId: "123",
|
||||||
|
},
|
||||||
|
agentId: "codex",
|
||||||
|
provider: {
|
||||||
|
compileConfiguredBinding: () => ({
|
||||||
|
conversationId: "123",
|
||||||
|
}),
|
||||||
|
matchInboundConversation: () => ({
|
||||||
|
conversationId: "123",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
targetFactory: {
|
||||||
|
driverId,
|
||||||
|
materialize: () => ({
|
||||||
|
record: {
|
||||||
|
bindingId: "binding:123",
|
||||||
|
targetSessionKey: `agent:codex:${driverId}`,
|
||||||
|
targetKind: "session",
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "123",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
boundAt: 0,
|
||||||
|
},
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId,
|
||||||
|
sessionKey: `agent:codex:${driverId}`,
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
match: {
|
||||||
|
conversationId: "123",
|
||||||
|
},
|
||||||
|
record: {
|
||||||
|
bindingId: "binding:123",
|
||||||
|
targetSessionKey: `agent:codex:${driverId}`,
|
||||||
|
targetKind: "session",
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "123",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
boundAt: 0,
|
||||||
|
},
|
||||||
|
statefulTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId,
|
||||||
|
sessionKey: `agent:codex:${driverId}`,
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unregisterStatefulBindingTargetDriver("test-driver");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("binding target drivers", () => {
|
||||||
|
it("delegates ensureReady and ensureSession to the resolved driver", async () => {
|
||||||
|
const ensureReady = vi.fn(async () => ({ ok: true as const }));
|
||||||
|
const ensureSession = vi.fn(async () => ({
|
||||||
|
ok: true as const,
|
||||||
|
sessionKey: "agent:codex:test-driver",
|
||||||
|
}));
|
||||||
|
const driver: StatefulBindingTargetDriver = {
|
||||||
|
id: "test-driver",
|
||||||
|
ensureReady,
|
||||||
|
ensureSession,
|
||||||
|
};
|
||||||
|
registerStatefulBindingTargetDriver(driver);
|
||||||
|
|
||||||
|
const bindingResolution = createBindingResolution("test-driver");
|
||||||
|
await expect(
|
||||||
|
ensureConfiguredBindingTargetReady({
|
||||||
|
cfg: {} as never,
|
||||||
|
bindingResolution,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
await expect(
|
||||||
|
ensureConfiguredBindingTargetSession({
|
||||||
|
cfg: {} as never,
|
||||||
|
bindingResolution,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
sessionKey: "agent:codex:test-driver",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ensureReady).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ensureReady).toHaveBeenCalledWith({
|
||||||
|
cfg: {} as never,
|
||||||
|
bindingResolution,
|
||||||
|
});
|
||||||
|
expect(ensureSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ensureSession).toHaveBeenCalledWith({
|
||||||
|
cfg: {} as never,
|
||||||
|
bindingResolution,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves resetInPlace through the driver session-key lookup", async () => {
|
||||||
|
const resetInPlace = vi.fn(async () => ({ ok: true as const }));
|
||||||
|
const driver: StatefulBindingTargetDriver = {
|
||||||
|
id: "test-driver",
|
||||||
|
ensureReady: async () => ({ ok: true }),
|
||||||
|
ensureSession: async () => ({
|
||||||
|
ok: true,
|
||||||
|
sessionKey: "agent:codex:test-driver",
|
||||||
|
}),
|
||||||
|
resolveTargetBySessionKey: ({ sessionKey }) => ({
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "test-driver",
|
||||||
|
sessionKey,
|
||||||
|
agentId: "codex",
|
||||||
|
}),
|
||||||
|
resetInPlace,
|
||||||
|
};
|
||||||
|
registerStatefulBindingTargetDriver(driver);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resetConfiguredBindingTargetInPlace({
|
||||||
|
cfg: {} as never,
|
||||||
|
sessionKey: "agent:codex:test-driver",
|
||||||
|
reason: "reset",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(resetInPlace).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resetInPlace).toHaveBeenCalledWith({
|
||||||
|
cfg: {} as never,
|
||||||
|
sessionKey: "agent:codex:test-driver",
|
||||||
|
reason: "reset",
|
||||||
|
bindingTarget: {
|
||||||
|
kind: "stateful",
|
||||||
|
driverId: "test-driver",
|
||||||
|
sessionKey: "agent:codex:test-driver",
|
||||||
|
agentId: "codex",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a typed error when no driver is registered", async () => {
|
||||||
|
const bindingResolution = createBindingResolution("missing-driver");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureConfiguredBindingTargetReady({
|
||||||
|
cfg: {} as never,
|
||||||
|
bindingResolution,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Configured binding target driver unavailable: missing-driver",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
ensureConfiguredBindingTargetSession({
|
||||||
|
cfg: {} as never,
|
||||||
|
bindingResolution,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
sessionKey: "agent:codex:missing-driver",
|
||||||
|
error: "Configured binding target driver unavailable: missing-driver",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/channels/plugins/binding-targets.ts
Normal file
69
src/channels/plugins/binding-targets.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type { ConfiguredBindingResolution } from "./binding-types.js";
|
||||||
|
import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js";
|
||||||
|
import {
|
||||||
|
getStatefulBindingTargetDriver,
|
||||||
|
resolveStatefulBindingTargetBySessionKey,
|
||||||
|
} from "./stateful-target-drivers.js";
|
||||||
|
|
||||||
|
export async function ensureConfiguredBindingTargetReady(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution | null;
|
||||||
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
ensureStatefulTargetBuiltinsRegistered();
|
||||||
|
if (!params.bindingResolution) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
|
||||||
|
if (!driver) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await driver.ensureReady({
|
||||||
|
cfg: params.cfg,
|
||||||
|
bindingResolution: params.bindingResolution,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetConfiguredBindingTargetInPlace(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
reason: "new" | "reset";
|
||||||
|
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
|
||||||
|
ensureStatefulTargetBuiltinsRegistered();
|
||||||
|
const resolved = resolveStatefulBindingTargetBySessionKey({
|
||||||
|
cfg: params.cfg,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
});
|
||||||
|
if (!resolved?.driver.resetInPlace) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await resolved.driver.resetInPlace({
|
||||||
|
...params,
|
||||||
|
bindingTarget: resolved.bindingTarget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureConfiguredBindingTargetSession(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution;
|
||||||
|
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
|
||||||
|
ensureStatefulTargetBuiltinsRegistered();
|
||||||
|
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
|
||||||
|
if (!driver) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
|
||||||
|
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await driver.ensureSession({
|
||||||
|
cfg: params.cfg,
|
||||||
|
bindingResolution: params.bindingResolution,
|
||||||
|
});
|
||||||
|
}
|
||||||
53
src/channels/plugins/binding-types.ts
Normal file
53
src/channels/plugins/binding-types.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { AgentBinding } from "../../config/types.js";
|
||||||
|
import type {
|
||||||
|
ConversationRef,
|
||||||
|
SessionBindingRecord,
|
||||||
|
} from "../../infra/outbound/session-binding-service.js";
|
||||||
|
import type {
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
|
} from "./types.adapters.js";
|
||||||
|
import type { ChannelId } from "./types.js";
|
||||||
|
|
||||||
|
export type ConfiguredBindingConversation = ConversationRef;
|
||||||
|
export type ConfiguredBindingChannel = ChannelId;
|
||||||
|
export type ConfiguredBindingRuleConfig = AgentBinding;
|
||||||
|
|
||||||
|
export type StatefulBindingTargetDescriptor = {
|
||||||
|
kind: "stateful";
|
||||||
|
driverId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
agentId: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfiguredBindingRecordResolution = {
|
||||||
|
record: SessionBindingRecord;
|
||||||
|
statefulTarget: StatefulBindingTargetDescriptor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfiguredBindingTargetFactory = {
|
||||||
|
driverId: string;
|
||||||
|
materialize: (params: {
|
||||||
|
accountId: string;
|
||||||
|
conversation: ChannelConfiguredBindingConversationRef;
|
||||||
|
}) => ConfiguredBindingRecordResolution;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompiledConfiguredBinding = {
|
||||||
|
channel: ConfiguredBindingChannel;
|
||||||
|
accountPattern?: string;
|
||||||
|
binding: ConfiguredBindingRuleConfig;
|
||||||
|
bindingConversationId: string;
|
||||||
|
target: ChannelConfiguredBindingConversationRef;
|
||||||
|
agentId: string;
|
||||||
|
provider: ChannelConfiguredBindingProvider;
|
||||||
|
targetFactory: ConfiguredBindingTargetFactory;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & {
|
||||||
|
conversation: ConfiguredBindingConversation;
|
||||||
|
compiledBinding: CompiledConfiguredBinding;
|
||||||
|
match: ChannelConfiguredBindingMatch;
|
||||||
|
};
|
||||||
13
src/channels/plugins/configured-binding-builtins.ts
Normal file
13
src/channels/plugins/configured-binding-builtins.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js";
|
||||||
|
import {
|
||||||
|
registerConfiguredBindingConsumer,
|
||||||
|
unregisterConfiguredBindingConsumer,
|
||||||
|
} from "./configured-binding-consumers.js";
|
||||||
|
|
||||||
|
export function ensureConfiguredBindingBuiltinsRegistered(): void {
|
||||||
|
registerConfiguredBindingConsumer(acpConfiguredBindingConsumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetConfiguredBindingBuiltinsForTesting(): void {
|
||||||
|
unregisterConfiguredBindingConsumer(acpConfiguredBindingConsumer.id);
|
||||||
|
}
|
||||||
240
src/channels/plugins/configured-binding-compiler.ts
Normal file
240
src/channels/plugins/configured-binding-compiler.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { listConfiguredBindings } from "../../config/bindings.js";
|
||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js";
|
||||||
|
import { pickFirstExistingAgentId } from "../../routing/resolve-route.js";
|
||||||
|
import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js";
|
||||||
|
import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js";
|
||||||
|
import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js";
|
||||||
|
import { getChannelPlugin } from "./index.js";
|
||||||
|
import type {
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
|
} from "./types.adapters.js";
|
||||||
|
|
||||||
|
// Configured bindings are channel-owned rules compiled from config, separate
|
||||||
|
// from runtime plugin-owned conversation bindings.
|
||||||
|
|
||||||
|
type ChannelPluginLike = NonNullable<ReturnType<typeof getChannelPlugin>>;
|
||||||
|
|
||||||
|
export type CompiledConfiguredBindingRegistry = {
|
||||||
|
rulesByChannel: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CachedCompiledConfiguredBindingRegistry = {
|
||||||
|
registryVersion: number;
|
||||||
|
registry: CompiledConfiguredBindingRegistry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compiledRegistryCache = new WeakMap<
|
||||||
|
OpenClawConfig,
|
||||||
|
CachedCompiledConfiguredBindingRegistry
|
||||||
|
>();
|
||||||
|
|
||||||
|
function findChannelPlugin(params: {
|
||||||
|
registry:
|
||||||
|
| {
|
||||||
|
channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
channel: string;
|
||||||
|
}): ChannelPluginLike | undefined {
|
||||||
|
return (
|
||||||
|
params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLoadedChannelPlugin(channel: string) {
|
||||||
|
const normalized = channel.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getChannelPlugin(normalized as ConfiguredBindingChannel);
|
||||||
|
if (current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findChannelPlugin({
|
||||||
|
registry: getActivePluginRegistry(),
|
||||||
|
channel: normalized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredBindingAdapter(channel: string): {
|
||||||
|
channel: ConfiguredBindingChannel;
|
||||||
|
provider: ChannelConfiguredBindingProvider;
|
||||||
|
} | null {
|
||||||
|
const normalized = channel.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const plugin = resolveLoadedChannelPlugin(normalized);
|
||||||
|
const provider = resolveChannelConfiguredBindingProvider(plugin);
|
||||||
|
if (
|
||||||
|
!plugin ||
|
||||||
|
!provider ||
|
||||||
|
!provider.compileConfiguredBinding ||
|
||||||
|
!provider.matchInboundConversation
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel: plugin.id,
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBindingConversationId(binding: {
|
||||||
|
match?: { peer?: { id?: string } };
|
||||||
|
}): string | null {
|
||||||
|
const id = binding.match?.peer?.id?.trim();
|
||||||
|
return id ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileConfiguredBindingTarget(params: {
|
||||||
|
provider: ChannelConfiguredBindingProvider;
|
||||||
|
binding: CompiledConfiguredBinding["binding"];
|
||||||
|
conversationId: string;
|
||||||
|
}): ChannelConfiguredBindingConversationRef | null {
|
||||||
|
return params.provider.compileConfiguredBinding({
|
||||||
|
binding: params.binding,
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileConfiguredBindingRule(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
channel: ConfiguredBindingChannel;
|
||||||
|
binding: CompiledConfiguredBinding["binding"];
|
||||||
|
target: ChannelConfiguredBindingConversationRef;
|
||||||
|
bindingConversationId: string;
|
||||||
|
provider: ChannelConfiguredBindingProvider;
|
||||||
|
}): CompiledConfiguredBinding | null {
|
||||||
|
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
|
||||||
|
const consumer = resolveConfiguredBindingConsumer(params.binding);
|
||||||
|
if (!consumer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetFactory = consumer.buildTargetFactory({
|
||||||
|
cfg: params.cfg,
|
||||||
|
binding: params.binding,
|
||||||
|
channel: params.channel,
|
||||||
|
agentId,
|
||||||
|
target: params.target,
|
||||||
|
bindingConversationId: params.bindingConversationId,
|
||||||
|
});
|
||||||
|
if (!targetFactory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel: params.channel,
|
||||||
|
accountPattern: params.binding.match.accountId?.trim() || undefined,
|
||||||
|
binding: params.binding,
|
||||||
|
bindingConversationId: params.bindingConversationId,
|
||||||
|
target: params.target,
|
||||||
|
agentId,
|
||||||
|
provider: params.provider,
|
||||||
|
targetFactory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushCompiledRule(
|
||||||
|
target: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>,
|
||||||
|
rule: CompiledConfiguredBinding,
|
||||||
|
) {
|
||||||
|
const existing = target.get(rule.channel);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(rule);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.set(rule.channel, [rule]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileConfiguredBindingRegistry(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
}): CompiledConfiguredBindingRegistry {
|
||||||
|
const rulesByChannel = new Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>();
|
||||||
|
|
||||||
|
for (const binding of listConfiguredBindings(params.cfg)) {
|
||||||
|
const bindingConversationId = resolveBindingConversationId(binding);
|
||||||
|
if (!bindingConversationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel);
|
||||||
|
if (!resolvedChannel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = compileConfiguredBindingTarget({
|
||||||
|
provider: resolvedChannel.provider,
|
||||||
|
binding,
|
||||||
|
conversationId: bindingConversationId,
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = compileConfiguredBindingRule({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channel: resolvedChannel.channel,
|
||||||
|
binding,
|
||||||
|
target,
|
||||||
|
bindingConversationId,
|
||||||
|
provider: resolvedChannel.provider,
|
||||||
|
});
|
||||||
|
if (!rule) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushCompiledRule(rulesByChannel, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rulesByChannel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCompiledBindingRegistry(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
): CompiledConfiguredBindingRegistry {
|
||||||
|
const registryVersion = getActivePluginRegistryVersion();
|
||||||
|
const cached = compiledRegistryCache.get(cfg);
|
||||||
|
if (cached?.registryVersion === registryVersion) {
|
||||||
|
return cached.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = compileConfiguredBindingRegistry({
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
compiledRegistryCache.set(cfg, {
|
||||||
|
registryVersion,
|
||||||
|
registry,
|
||||||
|
});
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primeCompiledBindingRegistry(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
): CompiledConfiguredBindingRegistry {
|
||||||
|
const registry = compileConfiguredBindingRegistry({ cfg });
|
||||||
|
compiledRegistryCache.set(cfg, {
|
||||||
|
registryVersion: getActivePluginRegistryVersion(),
|
||||||
|
registry,
|
||||||
|
});
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): {
|
||||||
|
bindingCount: number;
|
||||||
|
channelCount: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
bindingCount: [...registry.rulesByChannel.values()].reduce(
|
||||||
|
(sum, rules) => sum + rules.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
channelCount: registry.rulesByChannel.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
69
src/channels/plugins/configured-binding-consumers.ts
Normal file
69
src/channels/plugins/configured-binding-consumers.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type {
|
||||||
|
CompiledConfiguredBinding,
|
||||||
|
ConfiguredBindingRecordResolution,
|
||||||
|
ConfiguredBindingRuleConfig,
|
||||||
|
ConfiguredBindingTargetFactory,
|
||||||
|
} from "./binding-types.js";
|
||||||
|
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
|
||||||
|
|
||||||
|
export type ParsedConfiguredBindingSessionKey = {
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfiguredBindingConsumer = {
|
||||||
|
id: string;
|
||||||
|
supports: (binding: ConfiguredBindingRuleConfig) => boolean;
|
||||||
|
buildTargetFactory: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
binding: ConfiguredBindingRuleConfig;
|
||||||
|
channel: string;
|
||||||
|
agentId: string;
|
||||||
|
target: ChannelConfiguredBindingConversationRef;
|
||||||
|
bindingConversationId: string;
|
||||||
|
}) => ConfiguredBindingTargetFactory | null;
|
||||||
|
parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null;
|
||||||
|
matchesSessionKey?: (params: {
|
||||||
|
sessionKey: string;
|
||||||
|
compiledBinding: CompiledConfiguredBinding;
|
||||||
|
accountId: string;
|
||||||
|
materializedTarget: ConfiguredBindingRecordResolution;
|
||||||
|
}) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registeredConfiguredBindingConsumers = new Map<string, ConfiguredBindingConsumer>();
|
||||||
|
|
||||||
|
export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] {
|
||||||
|
return [...registeredConfiguredBindingConsumers.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingConsumer(
|
||||||
|
binding: ConfiguredBindingRuleConfig,
|
||||||
|
): ConfiguredBindingConsumer | null {
|
||||||
|
for (const consumer of listConfiguredBindingConsumers()) {
|
||||||
|
if (consumer.supports(binding)) {
|
||||||
|
return consumer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void {
|
||||||
|
const id = consumer.id.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Configured binding consumer id is required");
|
||||||
|
}
|
||||||
|
const existing = registeredConfiguredBindingConsumers.get(id);
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registeredConfiguredBindingConsumers.set(id, {
|
||||||
|
...consumer,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterConfiguredBindingConsumer(id: string): void {
|
||||||
|
registeredConfiguredBindingConsumers.delete(id.trim());
|
||||||
|
}
|
||||||
116
src/channels/plugins/configured-binding-match.ts
Normal file
116
src/channels/plugins/configured-binding-match.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||||
|
import type {
|
||||||
|
CompiledConfiguredBinding,
|
||||||
|
ConfiguredBindingChannel,
|
||||||
|
ConfiguredBindingRecordResolution,
|
||||||
|
} from "./binding-types.js";
|
||||||
|
import type {
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
} from "./types.adapters.js";
|
||||||
|
|
||||||
|
export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||||
|
const trimmed = (match ?? "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
|
||||||
|
}
|
||||||
|
if (trimmed === "*") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return normalizeAccountId(trimmed) === actual ? 2 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCompiledBindingConversation(params: {
|
||||||
|
rule: CompiledConfiguredBinding;
|
||||||
|
conversationId: string;
|
||||||
|
parentConversationId?: string;
|
||||||
|
}): ChannelConfiguredBindingMatch | null {
|
||||||
|
return params.rule.provider.matchInboundConversation({
|
||||||
|
binding: params.rule.binding,
|
||||||
|
compiledBinding: params.rule.target,
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
parentConversationId: params.parentConversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null {
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
return normalized ? (normalized as ConfiguredBindingChannel) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toConfiguredBindingConversationRef(conversation: ConversationRef): {
|
||||||
|
channel: ConfiguredBindingChannel;
|
||||||
|
accountId: string;
|
||||||
|
conversationId: string;
|
||||||
|
parentConversationId?: string;
|
||||||
|
} | null {
|
||||||
|
const channel = resolveCompiledBindingChannel(conversation.channel);
|
||||||
|
const conversationId = conversation.conversationId.trim();
|
||||||
|
if (!channel || !conversationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
accountId: normalizeAccountId(conversation.accountId),
|
||||||
|
conversationId,
|
||||||
|
parentConversationId: conversation.parentConversationId?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeConfiguredBindingRecord(params: {
|
||||||
|
rule: CompiledConfiguredBinding;
|
||||||
|
accountId: string;
|
||||||
|
conversation: ChannelConfiguredBindingConversationRef;
|
||||||
|
}): ConfiguredBindingRecordResolution {
|
||||||
|
return params.rule.targetFactory.materialize({
|
||||||
|
accountId: normalizeAccountId(params.accountId),
|
||||||
|
conversation: params.conversation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatchingConfiguredBinding(params: {
|
||||||
|
rules: CompiledConfiguredBinding[];
|
||||||
|
conversation: ReturnType<typeof toConfiguredBindingConversationRef>;
|
||||||
|
}): { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null {
|
||||||
|
if (!params.conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wildcardMatch: {
|
||||||
|
rule: CompiledConfiguredBinding;
|
||||||
|
match: ChannelConfiguredBindingMatch;
|
||||||
|
} | null = null;
|
||||||
|
let exactMatch: { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
for (const rule of params.rules) {
|
||||||
|
const accountMatchPriority = resolveAccountMatchPriority(
|
||||||
|
rule.accountPattern,
|
||||||
|
params.conversation.accountId,
|
||||||
|
);
|
||||||
|
if (accountMatchPriority === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = matchCompiledBindingConversation({
|
||||||
|
rule,
|
||||||
|
conversationId: params.conversation.conversationId,
|
||||||
|
parentConversationId: params.conversation.parentConversationId,
|
||||||
|
});
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const matchPriority = match.matchPriority ?? 0;
|
||||||
|
if (accountMatchPriority === 2) {
|
||||||
|
if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) {
|
||||||
|
exactMatch = { rule, match };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!wildcardMatch || matchPriority > (wildcardMatch.match.matchPriority ?? 0)) {
|
||||||
|
wildcardMatch = { rule, match };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exactMatch ?? wildcardMatch;
|
||||||
|
}
|
||||||
116
src/channels/plugins/configured-binding-registry.ts
Normal file
116
src/channels/plugins/configured-binding-registry.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
|
||||||
|
import type {
|
||||||
|
ConfiguredBindingRecordResolution,
|
||||||
|
ConfiguredBindingResolution,
|
||||||
|
} from "./binding-types.js";
|
||||||
|
import {
|
||||||
|
countCompiledBindingRegistry,
|
||||||
|
primeCompiledBindingRegistry,
|
||||||
|
resolveCompiledBindingRegistry,
|
||||||
|
} from "./configured-binding-compiler.js";
|
||||||
|
import {
|
||||||
|
materializeConfiguredBindingRecord,
|
||||||
|
resolveMatchingConfiguredBinding,
|
||||||
|
toConfiguredBindingConversationRef,
|
||||||
|
} from "./configured-binding-match.js";
|
||||||
|
import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js";
|
||||||
|
|
||||||
|
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
|
||||||
|
bindingCount: number;
|
||||||
|
channelCount: number;
|
||||||
|
} {
|
||||||
|
return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecord(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
conversationId: string;
|
||||||
|
parentConversationId?: string;
|
||||||
|
}): ConfiguredBindingRecordResolution | null {
|
||||||
|
const conversation = toConfiguredBindingConversationRef({
|
||||||
|
channel: params.channel,
|
||||||
|
accountId: params.accountId,
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
parentConversationId: params.parentConversationId,
|
||||||
|
});
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveConfiguredBindingRecordForConversation({
|
||||||
|
cfg: params.cfg,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecordForConversation(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
conversation: ConversationRef;
|
||||||
|
}): ConfiguredBindingRecordResolution | null {
|
||||||
|
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const registry = resolveCompiledBindingRegistry(params.cfg);
|
||||||
|
const rules = registry.rulesByChannel.get(conversation.channel);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolved = resolveMatchingConfiguredBinding({
|
||||||
|
rules,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return materializeConfiguredBindingRecord({
|
||||||
|
rule: resolved.rule,
|
||||||
|
accountId: conversation.accountId,
|
||||||
|
conversation: resolved.match,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBinding(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
conversation: ConversationRef;
|
||||||
|
}): ConfiguredBindingResolution | null {
|
||||||
|
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const registry = resolveCompiledBindingRegistry(params.cfg);
|
||||||
|
const rules = registry.rulesByChannel.get(conversation.channel);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolved = resolveMatchingConfiguredBinding({
|
||||||
|
rules,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const materializedTarget = materializeConfiguredBindingRecord({
|
||||||
|
rule: resolved.rule,
|
||||||
|
accountId: conversation.accountId,
|
||||||
|
conversation: resolved.match,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
compiledBinding: resolved.rule,
|
||||||
|
match: resolved.match,
|
||||||
|
...materializedTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecordBySessionKey(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
}): ConfiguredBindingRecordResolution | null {
|
||||||
|
return resolveConfiguredBindingRecordBySessionKeyFromRegistry({
|
||||||
|
registry: resolveCompiledBindingRegistry(params.cfg),
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
74
src/channels/plugins/configured-binding-session-lookup.ts
Normal file
74
src/channels/plugins/configured-binding-session-lookup.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { ConfiguredBindingRecordResolution } from "./binding-types.js";
|
||||||
|
import type { CompiledConfiguredBindingRegistry } from "./configured-binding-compiler.js";
|
||||||
|
import { listConfiguredBindingConsumers } from "./configured-binding-consumers.js";
|
||||||
|
import {
|
||||||
|
materializeConfiguredBindingRecord,
|
||||||
|
resolveAccountMatchPriority,
|
||||||
|
resolveCompiledBindingChannel,
|
||||||
|
} from "./configured-binding-match.js";
|
||||||
|
|
||||||
|
export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: {
|
||||||
|
registry: CompiledConfiguredBindingRegistry;
|
||||||
|
sessionKey: string;
|
||||||
|
}): ConfiguredBindingRecordResolution | null {
|
||||||
|
const sessionKey = params.sessionKey.trim();
|
||||||
|
if (!sessionKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const consumer of listConfiguredBindingConsumers()) {
|
||||||
|
const parsed = consumer.parseSessionKey?.({ sessionKey });
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const channel = resolveCompiledBindingChannel(parsed.channel);
|
||||||
|
if (!channel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rules = params.registry.rulesByChannel.get(channel);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let wildcardMatch: ConfiguredBindingRecordResolution | null = null;
|
||||||
|
let exactMatch: ConfiguredBindingRecordResolution | null = null;
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.targetFactory.driverId !== consumer.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const accountMatchPriority = resolveAccountMatchPriority(
|
||||||
|
rule.accountPattern,
|
||||||
|
parsed.accountId,
|
||||||
|
);
|
||||||
|
if (accountMatchPriority === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const materializedTarget = materializeConfiguredBindingRecord({
|
||||||
|
rule,
|
||||||
|
accountId: parsed.accountId,
|
||||||
|
conversation: rule.target,
|
||||||
|
});
|
||||||
|
const matchesSessionKey =
|
||||||
|
consumer.matchesSessionKey?.({
|
||||||
|
sessionKey,
|
||||||
|
compiledBinding: rule,
|
||||||
|
accountId: parsed.accountId,
|
||||||
|
materializedTarget,
|
||||||
|
}) ?? materializedTarget.record.targetSessionKey === sessionKey;
|
||||||
|
if (matchesSessionKey) {
|
||||||
|
if (accountMatchPriority === 2) {
|
||||||
|
exactMatch = materializedTarget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
wildcardMatch = materializedTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
if (wildcardMatch) {
|
||||||
|
return wildcardMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
13
src/channels/plugins/stateful-target-builtins.ts
Normal file
13
src/channels/plugins/stateful-target-builtins.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js";
|
||||||
|
import {
|
||||||
|
registerStatefulBindingTargetDriver,
|
||||||
|
unregisterStatefulBindingTargetDriver,
|
||||||
|
} from "./stateful-target-drivers.js";
|
||||||
|
|
||||||
|
export function ensureStatefulTargetBuiltinsRegistered(): void {
|
||||||
|
registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetStatefulTargetBuiltinsForTesting(): void {
|
||||||
|
unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id);
|
||||||
|
}
|
||||||
89
src/channels/plugins/stateful-target-drivers.ts
Normal file
89
src/channels/plugins/stateful-target-drivers.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type {
|
||||||
|
ConfiguredBindingResolution,
|
||||||
|
StatefulBindingTargetDescriptor,
|
||||||
|
} from "./binding-types.js";
|
||||||
|
|
||||||
|
export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string };
|
||||||
|
export type StatefulBindingTargetSessionResult =
|
||||||
|
| { ok: true; sessionKey: string }
|
||||||
|
| { ok: false; sessionKey: string; error: string };
|
||||||
|
export type StatefulBindingTargetResetResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; skipped?: boolean; error?: string };
|
||||||
|
|
||||||
|
export type StatefulBindingTargetDriver = {
|
||||||
|
id: string;
|
||||||
|
ensureReady: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution;
|
||||||
|
}) => Promise<StatefulBindingTargetReadyResult>;
|
||||||
|
ensureSession: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
bindingResolution: ConfiguredBindingResolution;
|
||||||
|
}) => Promise<StatefulBindingTargetSessionResult>;
|
||||||
|
resolveTargetBySessionKey?: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
}) => StatefulBindingTargetDescriptor | null;
|
||||||
|
resetInPlace?: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
bindingTarget: StatefulBindingTargetDescriptor;
|
||||||
|
reason: "new" | "reset";
|
||||||
|
}) => Promise<StatefulBindingTargetResetResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registeredStatefulBindingTargetDrivers = new Map<string, StatefulBindingTargetDriver>();
|
||||||
|
|
||||||
|
function listStatefulBindingTargetDrivers(): StatefulBindingTargetDriver[] {
|
||||||
|
return [...registeredStatefulBindingTargetDrivers.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerStatefulBindingTargetDriver(driver: StatefulBindingTargetDriver): void {
|
||||||
|
const id = driver.id.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Stateful binding target driver id is required");
|
||||||
|
}
|
||||||
|
const normalized = { ...driver, id };
|
||||||
|
const existing = registeredStatefulBindingTargetDrivers.get(id);
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registeredStatefulBindingTargetDrivers.set(id, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterStatefulBindingTargetDriver(id: string): void {
|
||||||
|
registeredStatefulBindingTargetDrivers.delete(id.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatefulBindingTargetDriver(id: string): StatefulBindingTargetDriver | null {
|
||||||
|
const normalizedId = id.trim();
|
||||||
|
if (!normalizedId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return registeredStatefulBindingTargetDrivers.get(normalizedId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStatefulBindingTargetBySessionKey(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
}): { driver: StatefulBindingTargetDriver; bindingTarget: StatefulBindingTargetDescriptor } | null {
|
||||||
|
const sessionKey = params.sessionKey.trim();
|
||||||
|
if (!sessionKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const driver of listStatefulBindingTargetDrivers()) {
|
||||||
|
const bindingTarget = driver.resolveTargetBySessionKey?.({
|
||||||
|
cfg: params.cfg,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
if (bindingTarget) {
|
||||||
|
return {
|
||||||
|
driver,
|
||||||
|
bindingTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
|
import type { ConfiguredBindingRule } from "../../config/bindings.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { AgentAcpBinding } from "../../config/types.js";
|
|
||||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
||||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||||
@ -541,24 +541,26 @@ export type ChannelAllowlistAdapter = {
|
|||||||
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
|
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelAcpBindingAdapter = {
|
export type ChannelConfiguredBindingConversationRef = {
|
||||||
normalizeConfiguredBindingTarget?: (params: {
|
conversationId: string;
|
||||||
binding: AgentAcpBinding;
|
parentConversationId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversationRef & {
|
||||||
|
matchPriority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelConfiguredBindingProvider = {
|
||||||
|
compileConfiguredBinding: (params: {
|
||||||
|
binding: ConfiguredBindingRule;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
}) => {
|
}) => ChannelConfiguredBindingConversationRef | null;
|
||||||
|
matchInboundConversation: (params: {
|
||||||
|
binding: ConfiguredBindingRule;
|
||||||
|
compiledBinding: ChannelConfiguredBindingConversationRef;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
parentConversationId?: string;
|
parentConversationId?: string;
|
||||||
} | null;
|
}) => ChannelConfiguredBindingMatch | null;
|
||||||
matchConfiguredBinding?: (params: {
|
|
||||||
binding: AgentAcpBinding;
|
|
||||||
bindingConversationId: string;
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
}) => {
|
|
||||||
conversationId: string;
|
|
||||||
parentConversationId?: string;
|
|
||||||
matchPriority?: number;
|
|
||||||
} | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
|
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import type {
|
|||||||
ChannelSetupAdapter,
|
ChannelSetupAdapter,
|
||||||
ChannelStatusAdapter,
|
ChannelStatusAdapter,
|
||||||
ChannelAllowlistAdapter,
|
ChannelAllowlistAdapter,
|
||||||
ChannelAcpBindingAdapter,
|
ChannelConfiguredBindingProvider,
|
||||||
} from "./types.adapters.js";
|
} from "./types.adapters.js";
|
||||||
import type {
|
import type {
|
||||||
ChannelAgentTool,
|
ChannelAgentTool,
|
||||||
@ -78,7 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
|||||||
lifecycle?: ChannelLifecycleAdapter;
|
lifecycle?: ChannelLifecycleAdapter;
|
||||||
execApprovals?: ChannelExecApprovalAdapter;
|
execApprovals?: ChannelExecApprovalAdapter;
|
||||||
allowlist?: ChannelAllowlistAdapter;
|
allowlist?: ChannelAllowlistAdapter;
|
||||||
acpBindings?: ChannelAcpBindingAdapter;
|
bindings?: ChannelConfiguredBindingProvider;
|
||||||
streaming?: ChannelStreamingAdapter;
|
streaming?: ChannelStreamingAdapter;
|
||||||
threading?: ChannelThreadingAdapter;
|
threading?: ChannelThreadingAdapter;
|
||||||
messaging?: ChannelMessagingAdapter;
|
messaging?: ChannelMessagingAdapter;
|
||||||
|
|||||||
@ -33,7 +33,9 @@ export type {
|
|||||||
ChannelOutboundAdapter,
|
ChannelOutboundAdapter,
|
||||||
ChannelOutboundContext,
|
ChannelOutboundContext,
|
||||||
ChannelAllowlistAdapter,
|
ChannelAllowlistAdapter,
|
||||||
ChannelAcpBindingAdapter,
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
ChannelPairingAdapter,
|
ChannelPairingAdapter,
|
||||||
ChannelSecurityAdapter,
|
ChannelSecurityAdapter,
|
||||||
ChannelSetupAdapter,
|
ChannelSetupAdapter,
|
||||||
|
|||||||
@ -96,6 +96,30 @@ describe("buildGatewayInstallPlan", () => {
|
|||||||
expect(plan.workingDirectory).toBe("/Users/me");
|
expect(plan.workingDirectory).toBe("/Users/me");
|
||||||
expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" });
|
expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" });
|
||||||
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
|
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
env: {},
|
||||||
|
port: 3000,
|
||||||
|
extraPathDirs: ["/custom"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prepend '.' when nodePath is a bare executable name", async () => {
|
||||||
|
mockNodeGatewayPlanFixture();
|
||||||
|
|
||||||
|
await buildGatewayInstallPlan({
|
||||||
|
env: {},
|
||||||
|
port: 3000,
|
||||||
|
runtime: "node",
|
||||||
|
nodePath: "node",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
extraPathDirs: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits warnings when renderSystemNodeWarning returns one", async () => {
|
it("emits warnings when renderSystemNodeWarning returns one", async () => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { buildServiceEnvironment } from "../daemon/service-env.js";
|
|||||||
import {
|
import {
|
||||||
emitDaemonInstallRuntimeWarning,
|
emitDaemonInstallRuntimeWarning,
|
||||||
resolveDaemonInstallRuntimeInputs,
|
resolveDaemonInstallRuntimeInputs,
|
||||||
|
resolveDaemonNodeBinDir,
|
||||||
} from "./daemon-install-plan.shared.js";
|
} from "./daemon-install-plan.shared.js";
|
||||||
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
||||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||||
@ -87,6 +88,9 @@ export async function buildGatewayInstallPlan(params: {
|
|||||||
process.platform === "darwin"
|
process.platform === "darwin"
|
||||||
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
|
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
// Keep npm/pnpm available to the service when the selected daemon node comes from
|
||||||
|
// a version-manager bin directory that isn't covered by static PATH guesses.
|
||||||
|
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge config env vars into the service environment (vars + inline env keys).
|
// Merge config env vars into the service environment (vars + inline env keys).
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolveDaemonInstallRuntimeInputs,
|
resolveDaemonInstallRuntimeInputs,
|
||||||
|
resolveDaemonNodeBinDir,
|
||||||
resolveGatewayDevMode,
|
resolveGatewayDevMode,
|
||||||
} from "./daemon-install-plan.shared.js";
|
} from "./daemon-install-plan.shared.js";
|
||||||
|
|
||||||
@ -29,3 +30,13 @@ describe("resolveDaemonInstallRuntimeInputs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveDaemonNodeBinDir", () => {
|
||||||
|
it("returns the absolute node bin directory", () => {
|
||||||
|
expect(resolveDaemonNodeBinDir("/custom/node/bin/node")).toEqual(["/custom/node/bin"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores bare executable names", () => {
|
||||||
|
expect(resolveDaemonNodeBinDir("node")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import path from "node:path";
|
||||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import {
|
import {
|
||||||
emitNodeRuntimeWarning,
|
emitNodeRuntimeWarning,
|
||||||
@ -42,3 +43,11 @@ export async function emitDaemonInstallRuntimeWarning(params: {
|
|||||||
title: params.title,
|
title: params.title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDaemonNodeBinDir(nodePath?: string): string[] | undefined {
|
||||||
|
const trimmed = nodePath?.trim();
|
||||||
|
if (!trimmed || !path.isAbsolute(trimmed)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [path.dirname(trimmed)];
|
||||||
|
}
|
||||||
|
|||||||
93
src/commands/node-daemon-install-helpers.test.ts
Normal file
93
src/commands/node-daemon-install-helpers.test.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
resolvePreferredNodePath: vi.fn(),
|
||||||
|
resolveNodeProgramArguments: vi.fn(),
|
||||||
|
resolveSystemNodeInfo: vi.fn(),
|
||||||
|
renderSystemNodeWarning: vi.fn(),
|
||||||
|
buildNodeServiceEnvironment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../daemon/runtime-paths.js", () => ({
|
||||||
|
resolvePreferredNodePath: mocks.resolvePreferredNodePath,
|
||||||
|
resolveSystemNodeInfo: mocks.resolveSystemNodeInfo,
|
||||||
|
renderSystemNodeWarning: mocks.renderSystemNodeWarning,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../daemon/program-args.js", () => ({
|
||||||
|
resolveNodeProgramArguments: mocks.resolveNodeProgramArguments,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../daemon/service-env.js", () => ({
|
||||||
|
buildNodeServiceEnvironment: mocks.buildNodeServiceEnvironment,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildNodeInstallPlan", () => {
|
||||||
|
it("passes the selected node bin directory into the node service environment", async () => {
|
||||||
|
mocks.resolveNodeProgramArguments.mockResolvedValue({
|
||||||
|
programArguments: ["node", "node-host"],
|
||||||
|
workingDirectory: "/Users/me",
|
||||||
|
});
|
||||||
|
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||||
|
path: "/opt/node/bin/node",
|
||||||
|
version: "22.0.0",
|
||||||
|
supported: true,
|
||||||
|
});
|
||||||
|
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
|
||||||
|
mocks.buildNodeServiceEnvironment.mockReturnValue({
|
||||||
|
OPENCLAW_SERVICE_VERSION: "2026.3.14",
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = await buildNodeInstallPlan({
|
||||||
|
env: {},
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 18789,
|
||||||
|
runtime: "node",
|
||||||
|
nodePath: "/custom/node/bin/node",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.environment).toEqual({
|
||||||
|
OPENCLAW_SERVICE_VERSION: "2026.3.14",
|
||||||
|
});
|
||||||
|
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
|
||||||
|
env: {},
|
||||||
|
extraPathDirs: ["/custom/node/bin"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prepend '.' when nodePath is a bare executable name", async () => {
|
||||||
|
mocks.resolveNodeProgramArguments.mockResolvedValue({
|
||||||
|
programArguments: ["node", "node-host"],
|
||||||
|
workingDirectory: "/Users/me",
|
||||||
|
});
|
||||||
|
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||||
|
path: "/usr/bin/node",
|
||||||
|
version: "22.0.0",
|
||||||
|
supported: true,
|
||||||
|
});
|
||||||
|
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
|
||||||
|
mocks.buildNodeServiceEnvironment.mockReturnValue({
|
||||||
|
OPENCLAW_SERVICE_VERSION: "2026.3.14",
|
||||||
|
});
|
||||||
|
|
||||||
|
await buildNodeInstallPlan({
|
||||||
|
env: {},
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 18789,
|
||||||
|
runtime: "node",
|
||||||
|
nodePath: "node",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
|
||||||
|
env: {},
|
||||||
|
extraPathDirs: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,6 +4,7 @@ import { buildNodeServiceEnvironment } from "../daemon/service-env.js";
|
|||||||
import {
|
import {
|
||||||
emitDaemonInstallRuntimeWarning,
|
emitDaemonInstallRuntimeWarning,
|
||||||
resolveDaemonInstallRuntimeInputs,
|
resolveDaemonInstallRuntimeInputs,
|
||||||
|
resolveDaemonNodeBinDir,
|
||||||
} from "./daemon-install-plan.shared.js";
|
} from "./daemon-install-plan.shared.js";
|
||||||
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
||||||
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
|
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
|
||||||
@ -54,7 +55,12 @@ export async function buildNodeInstallPlan(params: {
|
|||||||
title: "Node daemon runtime",
|
title: "Node daemon runtime",
|
||||||
});
|
});
|
||||||
|
|
||||||
const environment = buildNodeServiceEnvironment({ env: params.env });
|
const environment = buildNodeServiceEnvironment({
|
||||||
|
env: params.env,
|
||||||
|
// Match the gateway install path so supervised node services keep the chosen
|
||||||
|
// node toolchain on PATH for sibling binaries like npm/pnpm when needed.
|
||||||
|
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
|
||||||
|
});
|
||||||
const description = formatNodeServiceDescription({
|
const description = formatNodeServiceDescription({
|
||||||
version: environment.OPENCLAW_SERVICE_VERSION,
|
version: environment.OPENCLAW_SERVICE_VERSION,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { OpenClawConfig } from "./config.js";
|
import type { OpenClawConfig } from "./config.js";
|
||||||
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
|
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
|
||||||
|
|
||||||
|
export type ConfiguredBindingRule = AgentBinding;
|
||||||
|
|
||||||
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
|
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
|
||||||
return binding.type === "acp" ? "acp" : "route";
|
return binding.type === "acp" ? "acp" : "route";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import fsPromises from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js";
|
||||||
import * as jsonFiles from "../../infra/json-files.js";
|
import * as jsonFiles from "../../infra/json-files.js";
|
||||||
|
import type { OpenClawConfig } from "../config.js";
|
||||||
import {
|
import {
|
||||||
clearSessionStoreCacheForTest,
|
clearSessionStoreCacheForTest,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
@ -279,6 +281,72 @@ describe("session store lock (Promise chain mutex)", () => {
|
|||||||
expect(store[key]?.modelProvider).toBeUndefined();
|
expect(store[key]?.modelProvider).toBeUndefined();
|
||||||
expect(store[key]?.model).toBeUndefined();
|
expect(store[key]?.model).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves ACP metadata when replacing a session entry wholesale", async () => {
|
||||||
|
const key = "agent:codex:acp:binding:discord:default:feedface";
|
||||||
|
const acp = {
|
||||||
|
backend: "acpx",
|
||||||
|
agent: "codex",
|
||||||
|
runtimeSessionName: "codex-discord",
|
||||||
|
mode: "persistent" as const,
|
||||||
|
state: "idle" as const,
|
||||||
|
lastActivityAt: 100,
|
||||||
|
};
|
||||||
|
const { storePath } = await makeTmpStore({
|
||||||
|
[key]: {
|
||||||
|
sessionId: "sess-acp",
|
||||||
|
updatedAt: 100,
|
||||||
|
acp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
store[key] = {
|
||||||
|
sessionId: "sess-acp",
|
||||||
|
updatedAt: 200,
|
||||||
|
modelProvider: "openai-codex",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
expect(store[key]?.acp).toEqual(acp);
|
||||||
|
expect(store[key]?.modelProvider).toBe("openai-codex");
|
||||||
|
expect(store[key]?.model).toBe("gpt-5.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit ACP metadata removal through the ACP session helper", async () => {
|
||||||
|
const key = "agent:codex:acp:binding:discord:default:deadbeef";
|
||||||
|
const { storePath } = await makeTmpStore({
|
||||||
|
[key]: {
|
||||||
|
sessionId: "sess-acp-clear",
|
||||||
|
updatedAt: 100,
|
||||||
|
acp: {
|
||||||
|
backend: "acpx",
|
||||||
|
agent: "codex",
|
||||||
|
runtimeSessionName: "codex-discord",
|
||||||
|
mode: "persistent",
|
||||||
|
state: "idle",
|
||||||
|
lastActivityAt: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cfg = {
|
||||||
|
session: {
|
||||||
|
store: storePath,
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const result = await upsertAcpSessionMeta({
|
||||||
|
cfg,
|
||||||
|
sessionKey: key,
|
||||||
|
mutate: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.acp).toBeUndefined();
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
expect(store[key]?.acp).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("appendAssistantMessageToSessionTranscript", () => {
|
describe("appendAssistantMessageToSessionTranscript", () => {
|
||||||
|
|||||||
@ -309,6 +309,12 @@ type SaveSessionStoreOptions = {
|
|||||||
skipMaintenance?: boolean;
|
skipMaintenance?: boolean;
|
||||||
/** Active session key for warn-only maintenance. */
|
/** Active session key for warn-only maintenance. */
|
||||||
activeSessionKey?: string;
|
activeSessionKey?: string;
|
||||||
|
/**
|
||||||
|
* Session keys that are allowed to drop persisted ACP metadata during this update.
|
||||||
|
* All other updates preserve existing `entry.acp` blocks when callers replace the
|
||||||
|
* whole session entry without carrying ACP state forward.
|
||||||
|
*/
|
||||||
|
allowDropAcpMetaSessionKeys?: string[];
|
||||||
/** Optional callback for warn-only maintenance. */
|
/** Optional callback for warn-only maintenance. */
|
||||||
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
|
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
|
||||||
/** Optional callback with maintenance stats after a save. */
|
/** Optional callback with maintenance stats after a save. */
|
||||||
@ -337,6 +343,64 @@ function updateSessionStoreWriteCaches(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMutableSessionStoreKey(
|
||||||
|
store: Record<string, SessionEntry>,
|
||||||
|
sessionKey: string,
|
||||||
|
): string | undefined {
|
||||||
|
const trimmed = sessionKey.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(store, trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
const normalized = normalizeStoreSessionKey(trimmed);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return Object.keys(store).find((key) => normalizeStoreSessionKey(key) === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAcpMetadataSnapshot(
|
||||||
|
store: Record<string, SessionEntry>,
|
||||||
|
): Map<string, NonNullable<SessionEntry["acp"]>> {
|
||||||
|
const snapshot = new Map<string, NonNullable<SessionEntry["acp"]>>();
|
||||||
|
for (const [sessionKey, entry] of Object.entries(store)) {
|
||||||
|
if (entry?.acp) {
|
||||||
|
snapshot.set(sessionKey, entry.acp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveExistingAcpMetadata(params: {
|
||||||
|
previousAcpByKey: Map<string, NonNullable<SessionEntry["acp"]>>;
|
||||||
|
nextStore: Record<string, SessionEntry>;
|
||||||
|
allowDropSessionKeys?: string[];
|
||||||
|
}): void {
|
||||||
|
const allowDrop = new Set(
|
||||||
|
(params.allowDropSessionKeys ?? []).map((key) => normalizeStoreSessionKey(key)),
|
||||||
|
);
|
||||||
|
for (const [previousKey, previousAcp] of params.previousAcpByKey.entries()) {
|
||||||
|
const normalizedKey = normalizeStoreSessionKey(previousKey);
|
||||||
|
if (allowDrop.has(normalizedKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextKey = resolveMutableSessionStoreKey(params.nextStore, previousKey);
|
||||||
|
if (!nextKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextEntry = params.nextStore[nextKey];
|
||||||
|
if (!nextEntry || nextEntry.acp) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.nextStore[nextKey] = {
|
||||||
|
...nextEntry,
|
||||||
|
acp: previousAcp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSessionStoreUnlocked(
|
async function saveSessionStoreUnlocked(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
store: Record<string, SessionEntry>,
|
store: Record<string, SessionEntry>,
|
||||||
@ -526,7 +590,13 @@ export async function updateSessionStore<T>(
|
|||||||
return await withSessionStoreLock(storePath, async () => {
|
return await withSessionStoreLock(storePath, async () => {
|
||||||
// Always re-read inside the lock to avoid clobbering concurrent writers.
|
// Always re-read inside the lock to avoid clobbering concurrent writers.
|
||||||
const store = loadSessionStore(storePath, { skipCache: true });
|
const store = loadSessionStore(storePath, { skipCache: true });
|
||||||
|
const previousAcpByKey = collectAcpMetadataSnapshot(store);
|
||||||
const result = await mutator(store);
|
const result = await mutator(store);
|
||||||
|
preserveExistingAcpMetadata({
|
||||||
|
previousAcpByKey,
|
||||||
|
nextStore: store,
|
||||||
|
allowDropSessionKeys: opts?.allowDropAcpMetaSessionKeys,
|
||||||
|
});
|
||||||
await saveSessionStoreUnlocked(storePath, store, opts);
|
await saveSessionStoreUnlocked(storePath, store, opts);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -257,6 +257,18 @@ describe("buildMinimalServicePath", () => {
|
|||||||
const unique = [...new Set(parts)];
|
const unique = [...new Set(parts)];
|
||||||
expect(parts.length).toBe(unique.length);
|
expect(parts.length).toBe(unique.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prepends explicit runtime bin directories before guessed user paths", () => {
|
||||||
|
const result = buildMinimalServicePath({
|
||||||
|
platform: "linux",
|
||||||
|
extraDirs: ["/home/alice/.nvm/versions/node/v22.22.0/bin"],
|
||||||
|
env: { HOME: "/home/alice" },
|
||||||
|
});
|
||||||
|
const parts = splitPath(result, "linux");
|
||||||
|
|
||||||
|
expect(parts[0]).toBe("/home/alice/.nvm/versions/node/v22.22.0/bin");
|
||||||
|
expect(parts).toContain("/home/alice/.nvm/current/bin");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildServiceEnvironment", () => {
|
describe("buildServiceEnvironment", () => {
|
||||||
@ -344,6 +356,19 @@ describe("buildServiceEnvironment", () => {
|
|||||||
expect(env).not.toHaveProperty("PATH");
|
expect(env).not.toHaveProperty("PATH");
|
||||||
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
|
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prepends extra runtime directories to the gateway service PATH", () => {
|
||||||
|
const env = buildServiceEnvironment({
|
||||||
|
env: { HOME: "/home/user" },
|
||||||
|
port: 18789,
|
||||||
|
platform: "linux",
|
||||||
|
extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.PATH?.split(path.posix.delimiter)[0]).toBe(
|
||||||
|
"/home/user/.nvm/versions/node/v22.22.0/bin",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildNodeServiceEnvironment", () => {
|
describe("buildNodeServiceEnvironment", () => {
|
||||||
@ -416,6 +441,18 @@ describe("buildNodeServiceEnvironment", () => {
|
|||||||
});
|
});
|
||||||
expect(env.TMPDIR).toBe(os.tmpdir());
|
expect(env.TMPDIR).toBe(os.tmpdir());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prepends extra runtime directories to the node service PATH", () => {
|
||||||
|
const env = buildNodeServiceEnvironment({
|
||||||
|
env: { HOME: "/home/user" },
|
||||||
|
platform: "linux",
|
||||||
|
extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.PATH?.split(path.posix.delimiter)[0]).toBe(
|
||||||
|
"/home/user/.nvm/versions/node/v22.22.0/bin",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shared Node TLS env defaults", () => {
|
describe("shared Node TLS env defaults", () => {
|
||||||
|
|||||||
@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: {
|
|||||||
port: number;
|
port: number;
|
||||||
launchdLabel?: string;
|
launchdLabel?: string;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
|
extraPathDirs?: string[];
|
||||||
}): Record<string, string | undefined> {
|
}): Record<string, string | undefined> {
|
||||||
const { env, port, launchdLabel } = params;
|
const { env, port, launchdLabel, extraPathDirs } = params;
|
||||||
const platform = params.platform ?? process.platform;
|
const platform = params.platform ?? process.platform;
|
||||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
|
||||||
const profile = env.OPENCLAW_PROFILE;
|
const profile = env.OPENCLAW_PROFILE;
|
||||||
const resolvedLaunchdLabel =
|
const resolvedLaunchdLabel =
|
||||||
launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined);
|
launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined);
|
||||||
@ -271,10 +272,11 @@ export function buildServiceEnvironment(params: {
|
|||||||
export function buildNodeServiceEnvironment(params: {
|
export function buildNodeServiceEnvironment(params: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
|
extraPathDirs?: string[];
|
||||||
}): Record<string, string | undefined> {
|
}): Record<string, string | undefined> {
|
||||||
const { env } = params;
|
const { env, extraPathDirs } = params;
|
||||||
const platform = params.platform ?? process.platform;
|
const platform = params.platform ?? process.platform;
|
||||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
|
||||||
const gatewayToken =
|
const gatewayToken =
|
||||||
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined;
|
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined;
|
||||||
return {
|
return {
|
||||||
@ -313,6 +315,7 @@ function buildCommonServiceEnvironment(
|
|||||||
function resolveSharedServiceEnvironmentFields(
|
function resolveSharedServiceEnvironmentFields(
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
platform: NodeJS.Platform,
|
platform: NodeJS.Platform,
|
||||||
|
extraPathDirs: string[] | undefined,
|
||||||
): SharedServiceEnvironmentFields {
|
): SharedServiceEnvironmentFields {
|
||||||
const stateDir = env.OPENCLAW_STATE_DIR;
|
const stateDir = env.OPENCLAW_STATE_DIR;
|
||||||
const configPath = env.OPENCLAW_CONFIG_PATH;
|
const configPath = env.OPENCLAW_CONFIG_PATH;
|
||||||
@ -331,7 +334,10 @@ function resolveSharedServiceEnvironmentFields(
|
|||||||
tmpDir,
|
tmpDir,
|
||||||
// On Windows, Scheduled Tasks should inherit the current task PATH instead of
|
// On Windows, Scheduled Tasks should inherit the current task PATH instead of
|
||||||
// freezing the install-time snapshot into gateway.cmd/node-host.cmd.
|
// freezing the install-time snapshot into gateway.cmd/node-host.cmd.
|
||||||
minimalPath: platform === "win32" ? undefined : buildMinimalServicePath({ env, platform }),
|
minimalPath:
|
||||||
|
platform === "win32"
|
||||||
|
? undefined
|
||||||
|
: buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }),
|
||||||
proxyEnv,
|
proxyEnv,
|
||||||
nodeCaCerts,
|
nodeCaCerts,
|
||||||
nodeUseSystemCa,
|
nodeUseSystemCa,
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import type { PluginDiagnostic } from "../plugins/types.js";
|
|||||||
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
|
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
|
||||||
|
|
||||||
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
|
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
|
||||||
|
const primeConfiguredBindingRegistry = vi.hoisted(() =>
|
||||||
|
vi.fn(() => ({ bindingCount: 0, channelCount: 0 })),
|
||||||
|
);
|
||||||
type HandleGatewayRequestOptions = GatewayRequestOptions & {
|
type HandleGatewayRequestOptions = GatewayRequestOptions & {
|
||||||
extraHandlers?: Record<string, unknown>;
|
extraHandlers?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@ -17,6 +20,10 @@ vi.mock("../plugins/loader.js", () => ({
|
|||||||
loadOpenClawPlugins,
|
loadOpenClawPlugins,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../channels/plugins/binding-registry.js", () => ({
|
||||||
|
primeConfiguredBindingRegistry,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./server-methods.js", () => ({
|
vi.mock("./server-methods.js", () => ({
|
||||||
handleGatewayRequest,
|
handleGatewayRequest,
|
||||||
}));
|
}));
|
||||||
@ -51,6 +58,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
|||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
|
conversationBindingResolvedHandlers: [],
|
||||||
diagnostics,
|
diagnostics,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -110,6 +118,7 @@ async function createSubagentRuntime(
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
loadOpenClawPlugins.mockReset();
|
loadOpenClawPlugins.mockReset();
|
||||||
|
primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 });
|
||||||
handleGatewayRequest.mockReset();
|
handleGatewayRequest.mockReset();
|
||||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||||
runtimeModule.clearGatewaySubagentRuntime();
|
runtimeModule.clearGatewaySubagentRuntime();
|
||||||
@ -440,6 +449,29 @@ describe("loadGatewayPlugins", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("primes configured bindings during gateway startup", async () => {
|
||||||
|
const { loadGatewayPlugins } = await importServerPluginsModule();
|
||||||
|
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const cfg = {};
|
||||||
|
loadGatewayPlugins({
|
||||||
|
cfg,
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
log,
|
||||||
|
coreGatewayHandlers: {},
|
||||||
|
baseMethods: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg });
|
||||||
|
});
|
||||||
|
|
||||||
test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => {
|
test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => {
|
||||||
const { loadGatewayPlugins } = await importServerPluginsModule();
|
const { loadGatewayPlugins } = await importServerPluginsModule();
|
||||||
const diagnostics: PluginDiagnostic[] = [
|
const diagnostics: PluginDiagnostic[] = [
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
|
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
|
||||||
|
import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js";
|
||||||
import type { loadConfig } from "../config/config.js";
|
import type { loadConfig } from "../config/config.js";
|
||||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||||
@ -416,6 +417,7 @@ export function loadGatewayPlugins(params: {
|
|||||||
},
|
},
|
||||||
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
|
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
|
||||||
});
|
});
|
||||||
|
primeConfiguredBindingRegistry({ cfg: params.cfg });
|
||||||
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
|
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
|
||||||
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
|
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
|
||||||
if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) {
|
if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) {
|
||||||
|
|||||||
@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
|||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
|
conversationBindingResolvedHandlers: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,43 @@
|
|||||||
// Public pairing/session-binding helpers for plugins that manage conversation ownership.
|
// Public binding helpers for both runtime plugin-owned bindings and
|
||||||
|
// config-driven channel bindings.
|
||||||
|
|
||||||
export * from "../acp/persistent-bindings.route.js";
|
export {
|
||||||
|
createConversationBindingRecord,
|
||||||
|
getConversationBindingCapabilities,
|
||||||
|
listSessionBindingRecords,
|
||||||
|
resolveConversationBindingRecord,
|
||||||
|
touchConversationBindingRecord,
|
||||||
|
unbindConversationBindingRecord,
|
||||||
|
} from "../bindings/records.js";
|
||||||
|
export {
|
||||||
|
ensureConfiguredBindingRouteReady,
|
||||||
|
resolveConfiguredBindingRoute,
|
||||||
|
type ConfiguredBindingRouteResult,
|
||||||
|
} from "../channels/plugins/binding-routing.js";
|
||||||
|
export {
|
||||||
|
primeConfiguredBindingRegistry,
|
||||||
|
resolveConfiguredBinding,
|
||||||
|
resolveConfiguredBindingRecord,
|
||||||
|
resolveConfiguredBindingRecordBySessionKey,
|
||||||
|
resolveConfiguredBindingRecordForConversation,
|
||||||
|
} from "../channels/plugins/binding-registry.js";
|
||||||
|
export {
|
||||||
|
ensureConfiguredBindingTargetReady,
|
||||||
|
ensureConfiguredBindingTargetSession,
|
||||||
|
resetConfiguredBindingTargetInPlace,
|
||||||
|
} from "../channels/plugins/binding-targets.js";
|
||||||
|
export type {
|
||||||
|
ConfiguredBindingConversation,
|
||||||
|
ConfiguredBindingResolution,
|
||||||
|
CompiledConfiguredBinding,
|
||||||
|
StatefulBindingTargetDescriptor,
|
||||||
|
} from "../channels/plugins/binding-types.js";
|
||||||
|
export type {
|
||||||
|
StatefulBindingTargetDriver,
|
||||||
|
StatefulBindingTargetReadyResult,
|
||||||
|
StatefulBindingTargetResetResult,
|
||||||
|
StatefulBindingTargetSessionResult,
|
||||||
|
} from "../channels/plugins/stateful-target-drivers.js";
|
||||||
export {
|
export {
|
||||||
type BindingStatus,
|
type BindingStatus,
|
||||||
type BindingTargetKind,
|
type BindingTargetKind,
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js";
|
export type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelGatewayContext,
|
||||||
|
ChannelMessageActionAdapter,
|
||||||
|
} from "../channels/plugins/types.js";
|
||||||
export type { OpenClawConfig } from "../config/config.js";
|
export type { OpenClawConfig } from "../config/config.js";
|
||||||
export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js";
|
export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js";
|
||||||
export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js";
|
export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js";
|
||||||
@ -13,6 +17,11 @@ export type {
|
|||||||
ThreadBindingRecord,
|
ThreadBindingRecord,
|
||||||
ThreadBindingTargetKind,
|
ThreadBindingTargetKind,
|
||||||
} from "../../extensions/discord/src/monitor/thread-bindings.js";
|
} from "../../extensions/discord/src/monitor/thread-bindings.js";
|
||||||
|
export type {
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
} from "../channels/plugins/types.adapters.js";
|
||||||
export type {
|
export type {
|
||||||
ChannelMessageActionContext,
|
ChannelMessageActionContext,
|
||||||
ChannelPlugin,
|
ChannelPlugin,
|
||||||
|
|||||||
@ -31,6 +31,11 @@ export type {
|
|||||||
ChannelMeta,
|
ChannelMeta,
|
||||||
ChannelOutboundAdapter,
|
ChannelOutboundAdapter,
|
||||||
} from "../channels/plugins/types.js";
|
} from "../channels/plugins/types.js";
|
||||||
|
export type {
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
} from "../channels/plugins/types.adapters.js";
|
||||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||||
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
||||||
export { createTypingCallbacks } from "../channels/typing.js";
|
export { createTypingCallbacks } from "../channels/typing.js";
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildPluginSdkEntrySources,
|
||||||
buildPluginSdkPackageExports,
|
buildPluginSdkPackageExports,
|
||||||
buildPluginSdkSpecifiers,
|
buildPluginSdkSpecifiers,
|
||||||
pluginSdkEntrypoints,
|
pluginSdkEntrypoints,
|
||||||
@ -11,6 +15,9 @@ import {
|
|||||||
import * as sdk from "./index.js";
|
import * as sdk from "./index.js";
|
||||||
|
|
||||||
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
|
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
|
||||||
|
|
||||||
describe("plugin-sdk exports", () => {
|
describe("plugin-sdk exports", () => {
|
||||||
it("does not expose runtime modules", () => {
|
it("does not expose runtime modules", () => {
|
||||||
@ -63,16 +70,33 @@ describe("plugin-sdk exports", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => {
|
it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => {
|
||||||
|
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-"));
|
||||||
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
|
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
|
||||||
const repoDistDir = path.join(process.cwd(), "dist");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined();
|
const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs");
|
||||||
|
await fs.writeFile(
|
||||||
|
buildScriptPath,
|
||||||
|
`import { build } from ${JSON.stringify(tsdownModuleUrl)};
|
||||||
|
await build(${JSON.stringify({
|
||||||
|
clean: true,
|
||||||
|
config: false,
|
||||||
|
dts: false,
|
||||||
|
entry: buildPluginSdkEntrySources(),
|
||||||
|
env: { NODE_ENV: "production" },
|
||||||
|
fixedExtension: false,
|
||||||
|
logLevel: "error",
|
||||||
|
outDir,
|
||||||
|
platform: "node",
|
||||||
|
})});
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await execFileAsync(process.execPath, [buildScriptPath], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
for (const entry of pluginSdkEntrypoints) {
|
for (const entry of pluginSdkEntrypoints) {
|
||||||
const module = await import(
|
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
|
||||||
pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href
|
|
||||||
);
|
|
||||||
expect(module).toBeTypeOf("object");
|
expect(module).toBeTypeOf("object");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,8 +104,8 @@ describe("plugin-sdk exports", () => {
|
|||||||
const consumerDir = path.join(fixtureDir, "consumer");
|
const consumerDir = path.join(fixtureDir, "consumer");
|
||||||
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
|
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
|
||||||
|
|
||||||
await fs.mkdir(packageDir, { recursive: true });
|
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
|
||||||
await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir");
|
await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(packageDir, "package.json"),
|
path.join(packageDir, "package.json"),
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@ -114,6 +138,7 @@ describe("plugin-sdk exports", () => {
|
|||||||
Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])),
|
Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
await fs.rm(outDir, { recursive: true, force: true });
|
||||||
await fs.rm(fixtureDir, { recursive: true, force: true });
|
await fs.rm(fixtureDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,8 +14,25 @@ export type {
|
|||||||
ChannelMessageActionName,
|
ChannelMessageActionName,
|
||||||
ChannelStatusIssue,
|
ChannelStatusIssue,
|
||||||
} from "../channels/plugins/types.js";
|
} from "../channels/plugins/types.js";
|
||||||
|
export type {
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
|
} from "../channels/plugins/types.adapters.js";
|
||||||
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||||
export type { ChannelSetupAdapter, ChannelSetupInput } from "../channels/plugins/types.js";
|
export type { ChannelSetupAdapter, ChannelSetupInput } from "../channels/plugins/types.js";
|
||||||
|
export type {
|
||||||
|
ConfiguredBindingConversation,
|
||||||
|
ConfiguredBindingResolution,
|
||||||
|
CompiledConfiguredBinding,
|
||||||
|
StatefulBindingTargetDescriptor,
|
||||||
|
} from "../channels/plugins/binding-types.js";
|
||||||
|
export type {
|
||||||
|
StatefulBindingTargetDriver,
|
||||||
|
StatefulBindingTargetReadyResult,
|
||||||
|
StatefulBindingTargetResetResult,
|
||||||
|
StatefulBindingTargetSessionResult,
|
||||||
|
} from "../channels/plugins/stateful-target-drivers.js";
|
||||||
export type {
|
export type {
|
||||||
ChannelSetupWizard,
|
ChannelSetupWizard,
|
||||||
ChannelSetupWizardAllowFromEntry,
|
ChannelSetupWizardAllowFromEntry,
|
||||||
|
|||||||
@ -12,6 +12,11 @@ export type {
|
|||||||
TelegramActionConfig,
|
TelegramActionConfig,
|
||||||
TelegramNetworkConfig,
|
TelegramNetworkConfig,
|
||||||
} from "../config/types.js";
|
} from "../config/types.js";
|
||||||
|
export type {
|
||||||
|
ChannelConfiguredBindingProvider,
|
||||||
|
ChannelConfiguredBindingConversationRef,
|
||||||
|
ChannelConfiguredBindingMatch,
|
||||||
|
} from "../channels/plugins/types.adapters.js";
|
||||||
export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js";
|
export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js";
|
||||||
export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js";
|
export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js";
|
||||||
export type { TelegramProbe } from "../../extensions/telegram/src/probe.js";
|
export type { TelegramProbe } from "../../extensions/telegram/src/probe.js";
|
||||||
@ -26,7 +31,6 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j
|
|||||||
export { parseTelegramTopicConversation } from "../acp/conversation-id.js";
|
export { parseTelegramTopicConversation } from "../acp/conversation-id.js";
|
||||||
export { formatCliCommand } from "../cli/command-format.js";
|
export { formatCliCommand } from "../cli/command-format.js";
|
||||||
export { formatDocsLink } from "../terminal/links.js";
|
export { formatDocsLink } from "../terminal/links.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import type {
|
|||||||
SessionBindingAdapter,
|
SessionBindingAdapter,
|
||||||
SessionBindingRecord,
|
SessionBindingRecord,
|
||||||
} from "../infra/outbound/session-binding-service.js";
|
} from "../infra/outbound/session-binding-service.js";
|
||||||
|
import { createEmptyPluginRegistry } from "./registry.js";
|
||||||
|
import { setActivePluginRegistry } from "./runtime.js";
|
||||||
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-"));
|
||||||
const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json");
|
const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json");
|
||||||
@ -145,6 +147,7 @@ describe("plugin conversation binding approvals", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionBindingState.reset();
|
sessionBindingState.reset();
|
||||||
__testing.reset();
|
__testing.reset();
|
||||||
|
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||||
fs.rmSync(approvalsPath, { force: true });
|
fs.rmSync(approvalsPath, { force: true });
|
||||||
unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" });
|
unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" });
|
||||||
unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" });
|
unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" });
|
||||||
@ -366,6 +369,118 @@ describe("plugin conversation binding approvals", () => {
|
|||||||
expect(currentBinding?.detachHint).toBe("/codex_detach");
|
expect(currentBinding?.detachHint).toBe("/codex_detach");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("notifies the owning plugin when a bind approval is approved", async () => {
|
||||||
|
const registry = createEmptyPluginRegistry();
|
||||||
|
const onResolved = vi.fn(async () => undefined);
|
||||||
|
registry.conversationBindingResolvedHandlers.push({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginRoot: "/plugins/callback-test",
|
||||||
|
handler: onResolved,
|
||||||
|
source: "/plugins/callback-test/index.ts",
|
||||||
|
rootDir: "/plugins/callback-test",
|
||||||
|
});
|
||||||
|
setActivePluginRegistry(registry);
|
||||||
|
|
||||||
|
const request = await requestPluginConversationBinding({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginName: "Codex App Server",
|
||||||
|
pluginRoot: "/plugins/callback-test",
|
||||||
|
requestedBySenderId: "user-1",
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "isolated",
|
||||||
|
conversationId: "channel:callback-test",
|
||||||
|
},
|
||||||
|
binding: { summary: "Bind this conversation to Codex thread abc." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.status).toBe("pending");
|
||||||
|
if (request.status !== "pending") {
|
||||||
|
throw new Error("expected pending bind request");
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = await resolvePluginConversationBindingApproval({
|
||||||
|
approvalId: request.approvalId,
|
||||||
|
decision: "allow-once",
|
||||||
|
senderId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(approved.status).toBe("approved");
|
||||||
|
expect(onResolved).toHaveBeenCalledWith({
|
||||||
|
status: "approved",
|
||||||
|
binding: expect.objectContaining({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginRoot: "/plugins/callback-test",
|
||||||
|
conversationId: "channel:callback-test",
|
||||||
|
}),
|
||||||
|
decision: "allow-once",
|
||||||
|
request: {
|
||||||
|
summary: "Bind this conversation to Codex thread abc.",
|
||||||
|
detachHint: undefined,
|
||||||
|
requestedBySenderId: "user-1",
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "isolated",
|
||||||
|
conversationId: "channel:callback-test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the owning plugin when a bind approval is denied", async () => {
|
||||||
|
const registry = createEmptyPluginRegistry();
|
||||||
|
const onResolved = vi.fn(async () => undefined);
|
||||||
|
registry.conversationBindingResolvedHandlers.push({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginRoot: "/plugins/callback-deny",
|
||||||
|
handler: onResolved,
|
||||||
|
source: "/plugins/callback-deny/index.ts",
|
||||||
|
rootDir: "/plugins/callback-deny",
|
||||||
|
});
|
||||||
|
setActivePluginRegistry(registry);
|
||||||
|
|
||||||
|
const request = await requestPluginConversationBinding({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginName: "Codex App Server",
|
||||||
|
pluginRoot: "/plugins/callback-deny",
|
||||||
|
requestedBySenderId: "user-1",
|
||||||
|
conversation: {
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "8460800771",
|
||||||
|
},
|
||||||
|
binding: { summary: "Bind this conversation to Codex thread deny." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.status).toBe("pending");
|
||||||
|
if (request.status !== "pending") {
|
||||||
|
throw new Error("expected pending bind request");
|
||||||
|
}
|
||||||
|
|
||||||
|
const denied = await resolvePluginConversationBindingApproval({
|
||||||
|
approvalId: request.approvalId,
|
||||||
|
decision: "deny",
|
||||||
|
senderId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(denied.status).toBe("denied");
|
||||||
|
expect(onResolved).toHaveBeenCalledWith({
|
||||||
|
status: "denied",
|
||||||
|
binding: undefined,
|
||||||
|
decision: "deny",
|
||||||
|
request: {
|
||||||
|
summary: "Bind this conversation to Codex thread deny.",
|
||||||
|
detachHint: undefined,
|
||||||
|
requestedBySenderId: "user-1",
|
||||||
|
conversation: {
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "8460800771",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
|
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
|
||||||
const request = await requestPluginConversationBinding({
|
const request = await requestPluginConversationBinding({
|
||||||
pluginId: "codex",
|
pluginId: "codex",
|
||||||
|
|||||||
@ -2,15 +2,20 @@ import crypto from "node:crypto";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import {
|
||||||
|
createConversationBindingRecord,
|
||||||
|
resolveConversationBindingRecord,
|
||||||
|
unbindConversationBindingRecord,
|
||||||
|
} from "../bindings/records.js";
|
||||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||||
import { writeJsonAtomic } from "../infra/json-files.js";
|
import { writeJsonAtomic } from "../infra/json-files.js";
|
||||||
import {
|
import { type ConversationRef } from "../infra/outbound/session-binding-service.js";
|
||||||
getSessionBindingService,
|
|
||||||
type ConversationRef,
|
|
||||||
} from "../infra/outbound/session-binding-service.js";
|
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import { getActivePluginRegistry } from "./runtime.js";
|
||||||
import type {
|
import type {
|
||||||
PluginConversationBinding,
|
PluginConversationBinding,
|
||||||
|
PluginConversationBindingResolvedEvent,
|
||||||
|
PluginConversationBindingResolutionDecision,
|
||||||
PluginConversationBindingRequestParams,
|
PluginConversationBindingRequestParams,
|
||||||
PluginConversationBindingRequestResult,
|
PluginConversationBindingRequestResult,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@ -26,7 +31,9 @@ const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [
|
|||||||
"openclaw-codex-app-server:thread:",
|
"openclaw-codex-app-server:thread:",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny";
|
// Runtime plugin conversation bindings are approval-driven and distinct from
|
||||||
|
// configured channel bindings compiled from config.
|
||||||
|
type PluginBindingApprovalDecision = PluginConversationBindingResolutionDecision;
|
||||||
|
|
||||||
type PluginBindingApprovalEntry = {
|
type PluginBindingApprovalEntry = {
|
||||||
pluginRoot: string;
|
pluginRoot: string;
|
||||||
@ -87,7 +94,7 @@ type PluginBindingResolveResult =
|
|||||||
status: "approved";
|
status: "approved";
|
||||||
binding: PluginConversationBinding;
|
binding: PluginConversationBinding;
|
||||||
request: PendingPluginBindingRequest;
|
request: PendingPluginBindingRequest;
|
||||||
decision: PluginBindingApprovalDecision;
|
decision: Exclude<PluginBindingApprovalDecision, "deny">;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: "denied";
|
status: "denied";
|
||||||
@ -423,7 +430,7 @@ async function bindConversationNow(params: {
|
|||||||
accountId: ref.accountId,
|
accountId: ref.accountId,
|
||||||
conversationId: ref.conversationId,
|
conversationId: ref.conversationId,
|
||||||
});
|
});
|
||||||
const record = await getSessionBindingService().bind({
|
const record = await createConversationBindingRecord({
|
||||||
targetSessionKey,
|
targetSessionKey,
|
||||||
targetKind: "session",
|
targetKind: "session",
|
||||||
conversation: ref,
|
conversation: ref,
|
||||||
@ -574,7 +581,7 @@ export async function requestPluginConversationBinding(params: {
|
|||||||
}): Promise<PluginConversationBindingRequestResult> {
|
}): Promise<PluginConversationBindingRequestResult> {
|
||||||
const conversation = normalizeConversation(params.conversation);
|
const conversation = normalizeConversation(params.conversation);
|
||||||
const ref = toConversationRef(conversation);
|
const ref = toConversationRef(conversation);
|
||||||
const existing = getSessionBindingService().resolveByConversation(ref);
|
const existing = resolveConversationBindingRecord(ref);
|
||||||
const existingPluginBinding = toPluginConversationBinding(existing);
|
const existingPluginBinding = toPluginConversationBinding(existing);
|
||||||
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
|
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
|
||||||
record: existing,
|
record: existing,
|
||||||
@ -665,9 +672,7 @@ export async function getCurrentPluginConversationBinding(params: {
|
|||||||
pluginRoot: string;
|
pluginRoot: string;
|
||||||
conversation: PluginBindingConversation;
|
conversation: PluginBindingConversation;
|
||||||
}): Promise<PluginConversationBinding | null> {
|
}): Promise<PluginConversationBinding | null> {
|
||||||
const record = getSessionBindingService().resolveByConversation(
|
const record = resolveConversationBindingRecord(toConversationRef(params.conversation));
|
||||||
toConversationRef(params.conversation),
|
|
||||||
);
|
|
||||||
const binding = toPluginConversationBinding(record);
|
const binding = toPluginConversationBinding(record);
|
||||||
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
||||||
return null;
|
return null;
|
||||||
@ -684,12 +689,12 @@ export async function detachPluginConversationBinding(params: {
|
|||||||
conversation: PluginBindingConversation;
|
conversation: PluginBindingConversation;
|
||||||
}): Promise<{ removed: boolean }> {
|
}): Promise<{ removed: boolean }> {
|
||||||
const ref = toConversationRef(params.conversation);
|
const ref = toConversationRef(params.conversation);
|
||||||
const record = getSessionBindingService().resolveByConversation(ref);
|
const record = resolveConversationBindingRecord(ref);
|
||||||
const binding = toPluginConversationBinding(record);
|
const binding = toPluginConversationBinding(record);
|
||||||
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
||||||
return { removed: false };
|
return { removed: false };
|
||||||
}
|
}
|
||||||
await getSessionBindingService().unbind({
|
await unbindConversationBindingRecord({
|
||||||
bindingId: binding.bindingId,
|
bindingId: binding.bindingId,
|
||||||
reason: "plugin-detach",
|
reason: "plugin-detach",
|
||||||
});
|
});
|
||||||
@ -717,6 +722,11 @@ export async function resolvePluginConversationBindingApproval(params: {
|
|||||||
}
|
}
|
||||||
pendingRequests.delete(params.approvalId);
|
pendingRequests.delete(params.approvalId);
|
||||||
if (params.decision === "deny") {
|
if (params.decision === "deny") {
|
||||||
|
await notifyPluginConversationBindingResolved({
|
||||||
|
status: "denied",
|
||||||
|
decision: "deny",
|
||||||
|
request,
|
||||||
|
});
|
||||||
log.info(
|
log.info(
|
||||||
`plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
`plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
||||||
);
|
);
|
||||||
@ -745,6 +755,12 @@ export async function resolvePluginConversationBindingApproval(params: {
|
|||||||
log.info(
|
log.info(
|
||||||
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
||||||
);
|
);
|
||||||
|
await notifyPluginConversationBindingResolved({
|
||||||
|
status: "approved",
|
||||||
|
binding,
|
||||||
|
decision: params.decision,
|
||||||
|
request,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "approved",
|
status: "approved",
|
||||||
binding,
|
binding,
|
||||||
@ -753,6 +769,42 @@ export async function resolvePluginConversationBindingApproval(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyPluginConversationBindingResolved(params: {
|
||||||
|
status: "approved" | "denied";
|
||||||
|
binding?: PluginConversationBinding;
|
||||||
|
decision: PluginConversationBindingResolutionDecision;
|
||||||
|
request: PendingPluginBindingRequest;
|
||||||
|
}): Promise<void> {
|
||||||
|
const registrations = getActivePluginRegistry()?.conversationBindingResolvedHandlers ?? [];
|
||||||
|
for (const registration of registrations) {
|
||||||
|
if (registration.pluginId !== params.request.pluginId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const registeredRoot = registration.pluginRoot?.trim();
|
||||||
|
if (registeredRoot && registeredRoot !== params.request.pluginRoot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const event: PluginConversationBindingResolvedEvent = {
|
||||||
|
status: params.status,
|
||||||
|
binding: params.binding,
|
||||||
|
decision: params.decision,
|
||||||
|
request: {
|
||||||
|
summary: params.request.summary,
|
||||||
|
detachHint: params.request.detachHint,
|
||||||
|
requestedBySenderId: params.request.requestedBySenderId,
|
||||||
|
conversation: params.request.conversation,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await registration.handler(event);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(
|
||||||
|
`plugin binding resolved callback failed plugin=${registration.pluginId} root=${registration.pluginRoot ?? "<none>"}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string {
|
export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string {
|
||||||
if (params.status === "expired") {
|
if (params.status === "expired") {
|
||||||
return "That plugin bind approval expired. Retry the bind command.";
|
return "That plugin bind approval expired. Retry the bind command.";
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import type {
|
|||||||
OpenClawPluginChannelRegistration,
|
OpenClawPluginChannelRegistration,
|
||||||
OpenClawPluginCliRegistrar,
|
OpenClawPluginCliRegistrar,
|
||||||
OpenClawPluginCommandDefinition,
|
OpenClawPluginCommandDefinition,
|
||||||
|
PluginConversationBindingResolvedEvent,
|
||||||
OpenClawPluginHttpRouteAuth,
|
OpenClawPluginHttpRouteAuth,
|
||||||
OpenClawPluginHttpRouteMatch,
|
OpenClawPluginHttpRouteMatch,
|
||||||
OpenClawPluginHttpRouteHandler,
|
OpenClawPluginHttpRouteHandler,
|
||||||
@ -147,6 +148,15 @@ export type PluginCommandRegistration = {
|
|||||||
rootDir?: string;
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginConversationBindingResolvedHandlerRegistration = {
|
||||||
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
|
pluginRoot?: string;
|
||||||
|
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>;
|
||||||
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginRecord = {
|
export type PluginRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -199,6 +209,7 @@ export type PluginRegistry = {
|
|||||||
cliRegistrars: PluginCliRegistration[];
|
cliRegistrars: PluginCliRegistration[];
|
||||||
services: PluginServiceRegistration[];
|
services: PluginServiceRegistration[];
|
||||||
commands: PluginCommandRegistration[];
|
commands: PluginCommandRegistration[];
|
||||||
|
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
|
||||||
diagnostics: PluginDiagnostic[];
|
diagnostics: PluginDiagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -247,6 +258,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
|||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
|
conversationBindingResolvedHandlers: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -829,6 +841,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
} as TypedPluginHookRegistration);
|
} as TypedPluginHookRegistration);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const registerConversationBindingResolvedHandler = (
|
||||||
|
record: PluginRecord,
|
||||||
|
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
|
||||||
|
) => {
|
||||||
|
registry.conversationBindingResolvedHandlers.push({
|
||||||
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
|
pluginRoot: record.rootDir,
|
||||||
|
handler,
|
||||||
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
|
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
|
||||||
info: logger.info,
|
info: logger.info,
|
||||||
warn: logger.warn,
|
warn: logger.warn,
|
||||||
@ -942,6 +968,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: () => {},
|
: () => {},
|
||||||
|
onConversationBindingResolved:
|
||||||
|
registrationMode === "full"
|
||||||
|
? (handler) => registerConversationBindingResolvedHandler(record, handler)
|
||||||
|
: () => {},
|
||||||
registerCommand:
|
registerCommand:
|
||||||
registrationMode === "full" ? (command) => registerCommand(record, command) : () => {},
|
registrationMode === "full" ? (command) => registerCommand(record, command) : () => {},
|
||||||
registerContextEngine: (id, factory) => {
|
registerContextEngine: (id, factory) => {
|
||||||
|
|||||||
@ -940,6 +940,8 @@ export type PluginConversationBindingRequestParams = {
|
|||||||
detachHint?: string;
|
detachHint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny";
|
||||||
|
|
||||||
export type PluginConversationBinding = {
|
export type PluginConversationBinding = {
|
||||||
bindingId: string;
|
bindingId: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@ -970,6 +972,24 @@ export type PluginConversationBindingRequestResult =
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginConversationBindingResolvedEvent = {
|
||||||
|
status: "approved" | "denied";
|
||||||
|
binding?: PluginConversationBinding;
|
||||||
|
decision: PluginConversationBindingResolutionDecision;
|
||||||
|
request: {
|
||||||
|
summary?: string;
|
||||||
|
detachHint?: string;
|
||||||
|
requestedBySenderId?: string;
|
||||||
|
conversation: {
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
conversationId: string;
|
||||||
|
parentConversationId?: string;
|
||||||
|
threadId?: string | number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result returned by a plugin command handler.
|
* Result returned by a plugin command handler.
|
||||||
*/
|
*/
|
||||||
@ -1256,6 +1276,9 @@ export type OpenClawPluginApi = {
|
|||||||
registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void;
|
registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void;
|
||||||
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
|
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
|
||||||
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
|
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
|
||||||
|
onConversationBindingResolved: (
|
||||||
|
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
|
||||||
|
) => void;
|
||||||
/**
|
/**
|
||||||
* Register a custom command that bypasses the LLM agent.
|
* Register a custom command that bypasses the LLM agent.
|
||||||
* Plugin commands are processed before built-in commands and before agent invocation.
|
* Plugin commands are processed before built-in commands and before agent invocation.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user