mirror of https://github.com/openclaw/openclaw.git
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/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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,18 @@ export const ACPX_PINNED_VERSION = "0.1.16";
|
|||
export const ACPX_VERSION_ANY = "any";
|
||||
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));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (
|
||||
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(cursor, "package.json"))
|
||||
) {
|
||||
if (isAcpxPluginRoot(cursor)) {
|
||||
return 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)), "..");
|
||||
}
|
||||
|
||||
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_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,13 @@ describe("acpx ensure", () => {
|
|||
});
|
||||
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||
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",
|
||||
stripProviderAuthEnvVars,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -233,7 +233,13 @@ export async function ensureAcpx(params: {
|
|||
|
||||
const install = await spawnAndCollect({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
|
||||
args: [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-save",
|
||||
"--package-lock=false",
|
||||
`acpx@${installVersion}`,
|
||||
],
|
||||
cwd: pluginRoot,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 path from "node:path";
|
||||
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", () => {
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
|
|
@ -57,11 +58,76 @@ const DEFAULT_RUNTIME: SpawnRuntime = {
|
|||
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(
|
||||
params: { command: string; args: string[] },
|
||||
options?: SpawnCommandOptions,
|
||||
runtime: SpawnRuntime = DEFAULT_RUNTIME,
|
||||
): 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 cacheKey = params.command;
|
||||
const cachedProgram = options?.cache;
|
||||
|
|
|
|||
|
|
@ -154,6 +154,90 @@ describe("AcpxRuntime", () => {
|
|||
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 () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,26 @@ function formatAcpxExitMessage(params: {
|
|||
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 {
|
||||
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||||
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
||||
|
|
@ -252,6 +272,146 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
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> {
|
||||
const sessionName = asTrimmedString(input.sessionKey);
|
||||
if (!sessionName) {
|
||||
|
|
@ -264,45 +424,80 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||
const mode = input.mode;
|
||||
const resumeSessionId = asTrimmedString(input.resumeSessionId);
|
||||
const ensureSubcommand = resumeSessionId
|
||||
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
|
||||
: ["sessions", "ensure", "--name", sessionName];
|
||||
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({
|
||||
let events: AcpxJsonObject[];
|
||||
if (resumeSessionId) {
|
||||
events = await this.createNamedSession({
|
||||
agent,
|
||||
cwd,
|
||||
command: ["sessions", "new", "--name", sessionName],
|
||||
sessionName,
|
||||
resumeSessionId,
|
||||
});
|
||||
events = await this.runControlCommand({
|
||||
args: newCommand,
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
} else {
|
||||
try {
|
||||
events = await this.runControlCommand({
|
||||
args: await this.buildVerbArgs({
|
||||
agent,
|
||||
cwd,
|
||||
command: ["sessions", "ensure", "--name", sessionName],
|
||||
}),
|
||||
cwd,
|
||||
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) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,17 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
|
|||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
||||
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") {
|
||||
emitJson({ action: "session_ensured", name: ensureName });
|
||||
} else {
|
||||
|
|
@ -173,11 +184,14 @@ if (command === "set") {
|
|||
|
||||
if (command === "status") {
|
||||
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({
|
||||
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
|
||||
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
|
||||
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
|
||||
status: sessionFromOption ? "alive" : "no-session",
|
||||
status,
|
||||
...(summary ? { summary } : {}),
|
||||
pid: 4242,
|
||||
uptime: 120,
|
||||
});
|
||||
|
|
@ -382,6 +396,9 @@ export async function readMockRuntimeLogEntries(
|
|||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
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;
|
||||
logFileSequence = 0;
|
||||
while (tempDirs.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,87 @@
|
|||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
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 { 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", () => {
|
||||
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
||||
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
||||
|
|
@ -33,4 +112,100 @@ describe("discordPlugin outbound", () => {
|
|||
);
|
||||
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,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { collectDiscordAuditChannelIds } from "./audit.js";
|
||||
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
|
||||
import {
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt,
|
||||
} from "./exec-approvals.js";
|
||||
import { monitorDiscordProvider } from "./monitor.js";
|
||||
import {
|
||||
looksLikeDiscordTargetId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
normalizeDiscordOutboundTarget,
|
||||
} from "./normalize.js";
|
||||
import type { DiscordProbe } from "./probe.js";
|
||||
import { probeDiscord, type DiscordProbe } from "./probe.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
|
|
@ -491,11 +492,15 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||
silent: silent ?? undefined,
|
||||
}),
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) =>
|
||||
normalizeDiscordAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||
matchDiscordAcpConversation({
|
||||
bindingConversationId: compiledBinding.conversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
|
|
@ -514,7 +519,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||
probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
}),
|
||||
formatCapabilitiesProbe: ({ probe }) => {
|
||||
|
|
@ -620,7 +625,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
||||
const audit = await auditDiscordChannelPermissions({
|
||||
token: botToken,
|
||||
accountId: account.accountId,
|
||||
channelIds,
|
||||
|
|
@ -661,7 +666,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||
const token = account.token.trim();
|
||||
let discordBotLabel = "";
|
||||
try {
|
||||
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
||||
const probe = await probeDiscord(token, 2500, {
|
||||
includeApplication: true,
|
||||
});
|
||||
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}`);
|
||||
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
||||
return monitorDiscordProvider({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import WebSocket from "ws";
|
|||
|
||||
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
|
||||
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 DiscordGatewayFetchInit = Record<string, unknown> & {
|
||||
|
|
@ -19,6 +20,8 @@ type DiscordGatewayFetch = (
|
|||
init?: DiscordGatewayFetchInit,
|
||||
) => Promise<DiscordGatewayMetadataResponse>;
|
||||
|
||||
type DiscordGatewayMetadataError = Error & { transient?: boolean };
|
||||
|
||||
export function resolveDiscordGatewayIntents(
|
||||
intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig,
|
||||
): number {
|
||||
|
|
@ -64,14 +67,36 @@ function createGatewayMetadataError(params: {
|
|||
transient: boolean;
|
||||
cause?: unknown;
|
||||
}): Error {
|
||||
if (params.transient) {
|
||||
return new Error("Failed to get gateway information from Discord: fetch failed", {
|
||||
cause: params.cause ?? new Error(params.detail),
|
||||
});
|
||||
}
|
||||
return new Error(`Failed to get gateway information from Discord: ${params.detail}`, {
|
||||
cause: params.cause,
|
||||
const error = new Error(
|
||||
params.transient
|
||||
? "Failed to get gateway information from Discord: fetch failed"
|
||||
: `Failed to get gateway information from Discord: ${params.detail}`,
|
||||
{
|
||||
cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined),
|
||||
},
|
||||
) 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: {
|
||||
|
|
@ -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: {
|
||||
options: {
|
||||
reconnect: { maxAttempts: number };
|
||||
|
|
@ -143,19 +227,29 @@ function createGatewayPlugin(params: {
|
|||
fetchImpl: DiscordGatewayFetch;
|
||||
fetchInit?: DiscordGatewayFetchInit;
|
||||
wsAgent?: HttpsProxyAgent<string>;
|
||||
runtime?: RuntimeEnv;
|
||||
}): GatewayPlugin {
|
||||
class SafeGatewayPlugin extends GatewayPlugin {
|
||||
private gatewayInfoUsedFallback = false;
|
||||
|
||||
constructor() {
|
||||
super(params.options);
|
||||
}
|
||||
|
||||
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
|
||||
if (!this.gatewayInfo) {
|
||||
this.gatewayInfo = await fetchDiscordGatewayInfo({
|
||||
if (!this.gatewayInfo || this.gatewayInfoUsedFallback) {
|
||||
const resolved = await fetchDiscordGatewayInfoWithTimeout({
|
||||
token: client.options.token,
|
||||
fetchImpl: params.fetchImpl,
|
||||
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);
|
||||
}
|
||||
|
|
@ -187,6 +281,7 @@ export function createDiscordGatewayPlugin(params: {
|
|||
return createGatewayPlugin({
|
||||
options,
|
||||
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),
|
||||
fetchInit: { dispatcher: fetchAgent },
|
||||
wsAgent,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
} catch (err) {
|
||||
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
|
||||
return createGatewayPlugin({
|
||||
options,
|
||||
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
||||
runtime: params.runtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../src/acp/persistent-bindings.js", () => ({
|
||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
||||
ensureConfiguredAcpBindingSessionMock(...args),
|
||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
||||
resolveConfiguredAcpBindingRecordMock(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||
resolveConfiguredBindingRouteMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||
|
|
@ -52,6 +56,77 @@ function createConfiguredDiscordBinding() {
|
|||
} 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>) {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-1",
|
||||
|
|
@ -94,13 +169,10 @@ function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
|||
describe("preflightDiscordMessage configured ACP bindings", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding());
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:discord:default:abc123",
|
||||
});
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredDiscordRoute());
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("does not initialize configured ACP bindings for rejected messages", async () => {
|
||||
|
|
@ -121,8 +193,8 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
|
|||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
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());
|
||||
|
||||
vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({
|
||||
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
}));
|
||||
import {
|
||||
|
|
@ -229,16 +229,16 @@ describe("resolvePreflightMentionRequirement", () => {
|
|||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: true,
|
||||
isBoundThreadSession: false,
|
||||
bypassMentionRequirement: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disables mention requirement for bound thread sessions", () => {
|
||||
it("disables mention requirement when the route explicitly bypasses mentions", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: true,
|
||||
isBoundThreadSession: true,
|
||||
bypassMentionRequirement: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
|
@ -247,7 +247,7 @@ describe("resolvePreflightMentionRequirement", () => {
|
|||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: false,
|
||||
isBoundThreadSession: false,
|
||||
bypassMentionRequirement: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
|
@ -378,6 +378,69 @@ describe("preflightDiscordMessage", () => {
|
|||
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 () => {
|
||||
const threadBinding = createThreadBinding();
|
||||
const threadId = "thread-bot-focus";
|
||||
|
|
@ -655,8 +718,8 @@ describe("preflightDiscordMessage", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage(
|
||||
createPreflightArgs({
|
||||
const result = await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
messages: {
|
||||
|
|
@ -674,7 +737,17 @@ describe("preflightDiscordMessage", () => {
|
|||
}),
|
||||
client,
|
||||
}),
|
||||
);
|
||||
guildEntries: {
|
||||
"guild-1": {
|
||||
channels: {
|
||||
[channelId]: {
|
||||
allow: true,
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||
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 { resolveControlCommandGate } 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 { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
|
|
@ -95,12 +96,12 @@ function isBoundThreadBotSystemMessage(params: {
|
|||
|
||||
export function resolvePreflightMentionRequirement(params: {
|
||||
shouldRequireMention: boolean;
|
||||
isBoundThreadSession: boolean;
|
||||
bypassMentionRequirement: boolean;
|
||||
}): boolean {
|
||||
if (!params.shouldRequireMention) {
|
||||
return false;
|
||||
}
|
||||
return !params.isBoundThreadSession;
|
||||
return !params.bypassMentionRequirement;
|
||||
}
|
||||
|
||||
export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
||||
|
|
@ -131,6 +132,95 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
|||
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(
|
||||
params: DiscordMessagePreflightParams,
|
||||
): Promise<DiscordMessagePreflightContext | null> {
|
||||
|
|
@ -138,7 +228,7 @@ export async function preflightDiscordMessage(
|
|||
return null;
|
||||
}
|
||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||
const message = params.data.message;
|
||||
let message = params.data.message;
|
||||
const author = params.data.author;
|
||||
if (!author) {
|
||||
return null;
|
||||
|
|
@ -160,6 +250,15 @@ export async function preflightDiscordMessage(
|
|||
return null;
|
||||
}
|
||||
|
||||
message = await hydrateDiscordMessageIfEmpty({
|
||||
client: params.client,
|
||||
message,
|
||||
messageChannelId,
|
||||
});
|
||||
if (isPreflightAborted(params.abortSignal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluralkitConfig = params.discordConfig?.pluralkit;
|
||||
const webhookId = resolveDiscordWebhookId(message);
|
||||
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
|
||||
|
|
@ -197,6 +296,7 @@ export async function preflightDiscordMessage(
|
|||
}
|
||||
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
||||
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
||||
const data = message === params.data.message ? params.data : { ...params.data, message };
|
||||
logDebug(
|
||||
`[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;
|
||||
const configuredRoute =
|
||||
threadBinding == null
|
||||
? resolveConfiguredAcpRoute({
|
||||
? resolveConfiguredBindingRoute({
|
||||
cfg: freshCfg,
|
||||
route,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: messageChannelId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: messageChannelId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const configuredBinding = configuredRoute?.configuredBinding ?? null;
|
||||
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
||||
if (!threadBinding && configuredBinding) {
|
||||
threadBinding = configuredBinding.record;
|
||||
}
|
||||
|
|
@ -394,6 +496,7 @@ export async function preflightDiscordMessage(
|
|||
});
|
||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||
const bypassMentionRequirement = isBoundThreadSession || Boolean(configuredBinding);
|
||||
if (
|
||||
isBoundThreadBotSystemMessage({
|
||||
isBoundThreadSession,
|
||||
|
|
@ -579,7 +682,7 @@ export async function preflightDiscordMessage(
|
|||
});
|
||||
const shouldRequireMention = resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: shouldRequireMentionByConfig,
|
||||
isBoundThreadSession,
|
||||
bypassMentionRequirement,
|
||||
});
|
||||
|
||||
// Preflight audio transcription for mention detection in guilds.
|
||||
|
|
@ -764,13 +867,13 @@ export async function preflightDiscordMessage(
|
|||
return null;
|
||||
}
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: freshCfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -794,7 +897,7 @@ export async function preflightDiscordMessage(
|
|||
replyToMode: params.replyToMode,
|
||||
ackReactionScope: params.ackReactionScope,
|
||||
groupPolicy: params.groupPolicy,
|
||||
data: params.data,
|
||||
data,
|
||||
client: params.client,
|
||||
message,
|
||||
messageChannelId,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.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 * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
||||
|
|
@ -11,17 +12,17 @@ import {
|
|||
} from "./native-command.test-helpers.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredAcpRoute;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredAcpRouteReady;
|
||||
type ResolveConfiguredBindingRouteFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>((params) => ({
|
||||
configuredBinding: null,
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
})),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
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")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -65,12 +66,7 @@ function createConfig(): OpenClawConfig {
|
|||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStatusCommand(cfg: OpenClawConfig) {
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
|
||||
return createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
|
|
@ -147,39 +143,145 @@ async function expectPairCommandReply(params: {
|
|||
);
|
||||
}
|
||||
|
||||
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: channelId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: `config:acp:discord:${params.accountId}:${channelId}`,
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
},
|
||||
boundSessionKey,
|
||||
boundAgentId: "codex",
|
||||
route: {
|
||||
...params.route,
|
||||
function createStatusCommand(cfg: OpenClawConfig) {
|
||||
return createNativeCommand(cfg, {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConversationFromParams(params: Parameters<ResolveConfiguredBindingRouteFn>[0]) {
|
||||
if ("conversation" in params) {
|
||||
return params.conversation;
|
||||
}
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createConfiguredBindingResolution(params: {
|
||||
conversation: ReturnType<typeof resolveConversationFromParams>;
|
||||
boundSessionKey: string;
|
||||
}) {
|
||||
const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-")
|
||||
? "direct"
|
||||
: "channel";
|
||||
const configuredBinding = {
|
||||
spec: {
|
||||
channel: "discord" as const,
|
||||
accountId: params.conversation.accountId,
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
agentId: "codex",
|
||||
sessionKey: boundSessionKey,
|
||||
matchedBy: "binding.channel",
|
||||
mode: "persistent" as const,
|
||||
},
|
||||
}));
|
||||
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({
|
||||
ok: true,
|
||||
});
|
||||
|
|
@ -234,7 +336,7 @@ describe("Discord native plugin command dispatch", () => {
|
|||
clearPluginCommands();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
||||
configuredBinding: null,
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
}));
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
||||
|
|
@ -519,4 +621,64 @@ describe("Discord native plugin command dispatch", () => {
|
|||
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 { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
|
|
@ -194,6 +194,11 @@ function buildDiscordCommandOptions(params: {
|
|||
}) satisfies CommandOptions;
|
||||
}
|
||||
|
||||
function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
|
||||
const normalized = commandName.trim().toLowerCase();
|
||||
return normalized === "acp" || normalized === "new" || normalized === "reset";
|
||||
}
|
||||
|
||||
function readDiscordCommandArgs(
|
||||
interaction: CommandInteraction,
|
||||
definitions?: CommandArgDefinition[],
|
||||
|
|
@ -1617,24 +1622,27 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
||||
const configuredRoute =
|
||||
threadBinding == null
|
||||
? resolveConfiguredAcpRoute({
|
||||
? resolveConfiguredBindingRoute({
|
||||
cfg,
|
||||
route,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: channelId,
|
||||
parentConversationId: threadParentId,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: channelId,
|
||||
parentConversationId: threadParentId,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const configuredBinding = configuredRoute?.configuredBinding ?? null;
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
||||
const commandName = command.nativeName ?? command.key;
|
||||
if (configuredBinding && !shouldBypassConfiguredAcpEnsure(commandName)) {
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
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.");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -228,6 +228,65 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||
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 () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
|
@ -288,8 +392,11 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||
},
|
||||
sequence: 123,
|
||||
});
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
||||
emitter.emit("debug", "WebSocket connection closed with code 1006");
|
||||
gateway.isConnected = false;
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
|
|
@ -324,8 +431,13 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||
},
|
||||
sequence: 456,
|
||||
});
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
||||
emitter.emit("debug", "WebSocket connection closed with code 1006");
|
||||
gateway.isConnected = false;
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
|
||||
// Successful reconnect (READY/RESUMED sets isConnected=true), then
|
||||
|
|
@ -342,10 +454,11 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||
const { lifecycleParams } = createLifecycleHarness({ gateway });
|
||||
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(2, true);
|
||||
expect(gateway.connect).toHaveBeenNthCalledWith(3, true);
|
||||
expect(gateway.connect).toHaveBeenNthCalledWith(4, true);
|
||||
expect(gateway.connect).not.toHaveBeenCalledWith(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
|
@ -357,6 +470,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
||||
(waitParams: WaitForDiscordGatewayStopParams) =>
|
||||
|
|
@ -382,6 +496,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
|||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
let resolveWait: (() => void) | undefined;
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,37 @@ type ExecApprovalsHandler = {
|
|||
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: {
|
||||
accountId: string;
|
||||
client: Client;
|
||||
|
|
@ -242,20 +273,6 @@ export async function runDiscordGatewayLifecycle(params: {
|
|||
};
|
||||
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;
|
||||
const logGatewayError = (err: unknown) => {
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
|
|
@ -277,28 +294,107 @@ export async function runDiscordGatewayLifecycle(params: {
|
|||
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 {
|
||||
if (params.execApprovalsHandler) {
|
||||
await params.execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
// Drain gateway errors emitted before lifecycle listeners were attached.
|
||||
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
|
||||
if (pendingGatewayErrors.length > 0) {
|
||||
const queuedErrors = [...pendingGatewayErrors];
|
||||
pendingGatewayErrors.length = 0;
|
||||
for (const err of queuedErrors) {
|
||||
logGatewayError(err);
|
||||
if (!shouldStopOnGatewayError(err)) {
|
||||
continue;
|
||||
}
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
if (drainPendingGatewayErrors() === "stop") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Carbon starts the gateway during client construction, before OpenClaw can
|
||||
// attach lifecycle listeners. Require a READY/RESUMED-connected gateway
|
||||
// before continuing so the monitor does not look healthy while silently
|
||||
// missing inbound events.
|
||||
if (gateway && !gateway.isConnected && !lifecycleStopping) {
|
||||
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;
|
||||
}
|
||||
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({
|
||||
gateway: gateway
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -142,11 +142,30 @@ describe("createDiscordGatewayPlugin", () => {
|
|||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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: {
|
||||
plugin: unknown;
|
||||
fetchMock: typeof globalFetchMock;
|
||||
|
|
@ -161,6 +180,7 @@ describe("createDiscordGatewayPlugin", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", globalFetchMock);
|
||||
vi.useRealTimers();
|
||||
baseRegisterClientSpy.mockClear();
|
||||
globalFetchMock.mockClear();
|
||||
restProxyAgentSpy.mockClear();
|
||||
|
|
@ -190,7 +210,7 @@ describe("createDiscordGatewayPlugin", () => {
|
|||
});
|
||||
|
||||
it("maps plain-text Discord 503 responses to fetch failed", async () => {
|
||||
await expectGatewayRegisterFetchFailure({
|
||||
await expectGatewayRegisterFallback({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () =>
|
||||
|
|
@ -198,6 +218,14 @@ describe("createDiscordGatewayPlugin", () => {
|
|||
} 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 () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
|
|
@ -255,7 +283,7 @@ describe("createDiscordGatewayPlugin", () => {
|
|||
});
|
||||
|
||||
it("maps body read failures to fetch failed", async () => {
|
||||
await expectGatewayRegisterFetchFailure({
|
||||
await expectGatewayRegisterFallback({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => {
|
||||
|
|
@ -263,4 +291,68 @@ describe("createDiscordGatewayPlugin", () => {
|
|||
},
|
||||
} 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 os from "node:os";
|
||||
import path from "node:path";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
|
|
@ -12,12 +13,12 @@ import { getSessionBindingService } from "../../../../src/infra/outbound/session
|
|||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _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",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
}));
|
||||
const restPost = vi.fn(async () => ({
|
||||
const restPost = vi.fn(async (..._args: unknown[]) => ({
|
||||
id: "wh-created",
|
||||
token: "tok-created",
|
||||
}));
|
||||
|
|
@ -45,47 +46,151 @@ vi.mock("../send.js", () => ({
|
|||
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
vi.mock("../send.messages.js", () => ({
|
||||
createThreadDiscord: hoisted.createThreadDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/acp/runtime/session-meta.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/acp/runtime/session-meta.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readAcpSessionEntry: hoisted.readAcpSessionEntry,
|
||||
};
|
||||
});
|
||||
|
||||
const { __testing, createThreadBindingManager } = await import("./thread-bindings.manager.js");
|
||||
const {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createThreadBindingManager,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
clearRuntimeConfigSnapshot();
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restPost.mockClear();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
hoisted.sendMessageDiscord.mockReset().mockResolvedValue({});
|
||||
hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({});
|
||||
hoisted.restGet.mockReset().mockResolvedValue({
|
||||
id: "thread-1",
|
||||
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);
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
@ -93,7 +198,7 @@ describe("thread binding lifecycle", () => {
|
|||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
|
@ -139,7 +244,7 @@ describe("thread binding lifecycle", () => {
|
|||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
|
@ -159,6 +264,7 @@ describe("thread binding lifecycle", () => {
|
|||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
|
|
@ -177,7 +283,7 @@ describe("thread binding lifecycle", () => {
|
|||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 0,
|
||||
maxAgeMs: 60_000,
|
||||
});
|
||||
|
|
@ -195,6 +301,7 @@ describe("thread binding lifecycle", () => {
|
|||
hoisted.sendMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -214,6 +321,7 @@ describe("thread binding lifecycle", () => {
|
|||
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
|
|
@ -234,6 +342,7 @@ describe("thread binding lifecycle", () => {
|
|||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
|
|
@ -334,7 +443,7 @@ describe("thread binding lifecycle", () => {
|
|||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
|
@ -358,6 +467,7 @@ describe("thread binding lifecycle", () => {
|
|||
expect(updated[0]?.idleTimeoutMs).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
} finally {
|
||||
|
|
@ -371,7 +481,7 @@ describe("thread binding lifecycle", () => {
|
|||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
|
@ -417,6 +527,7 @@ describe("thread binding lifecycle", () => {
|
|||
hoisted.sendMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-2")).toBeDefined();
|
||||
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: {
|
||||
record: ThreadBindingRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
|
|
@ -200,6 +202,111 @@ export function createThreadBindingManager(
|
|||
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
|
||||
|
||||
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 = {
|
||||
accountId,
|
||||
|
|
@ -444,6 +551,7 @@ export function createThreadBindingManager(
|
|||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
SWEEPERS_BY_ACCOUNT_ID.delete(accountId);
|
||||
unregisterManager(accountId, manager);
|
||||
unregisterSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
|
|
@ -455,110 +563,13 @@ export function createThreadBindingManager(
|
|||
|
||||
if (params.enableSweeper !== false) {
|
||||
sweepTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void runSweepOnce();
|
||||
}, 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({
|
||||
|
|
@ -690,4 +701,10 @@ export const __testing = {
|
|||
resolveThreadBindingsPath,
|
||||
resolveThreadBindingThreadName,
|
||||
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 };
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/sessions.js", () => ({
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
resolveStorePath: hoisted.resolveStorePath,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
resolveStorePath: hoisted.resolveStorePath,
|
||||
};
|
||||
});
|
||||
|
||||
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ const {
|
|||
mockResolveAgentRoute,
|
||||
mockReadSessionUpdatedAt,
|
||||
mockResolveStorePath,
|
||||
mockResolveConfiguredAcpRoute,
|
||||
mockEnsureConfiguredAcpRouteReady,
|
||||
mockResolveConfiguredBindingRoute,
|
||||
mockEnsureConfiguredBindingRouteReady,
|
||||
mockResolveBoundConversation,
|
||||
mockTouchBinding,
|
||||
} = vi.hoisted(() => ({
|
||||
|
|
@ -50,11 +50,12 @@ const {
|
|||
})),
|
||||
mockReadSessionUpdatedAt: vi.fn(),
|
||||
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
|
||||
mockResolveConfiguredBindingRoute: vi.fn(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
configuredBinding: null,
|
||||
route,
|
||||
})),
|
||||
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
||||
mockEnsureConfiguredBindingRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
||||
mockResolveBoundConversation: vi.fn(() => null),
|
||||
mockTouchBinding: vi.fn(),
|
||||
}));
|
||||
|
|
@ -78,12 +79,12 @@ vi.mock("./client.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...original,
|
||||
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
|
||||
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
|
||||
...actual,
|
||||
resolveConfiguredBindingRoute: (params: unknown) => mockResolveConfiguredBindingRoute(params),
|
||||
ensureConfiguredBindingRouteReady: (params: unknown) =>
|
||||
mockEnsureConfiguredBindingRouteReady(params),
|
||||
getSessionBindingService: () => ({
|
||||
resolveByConversation: mockResolveBoundConversation,
|
||||
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 {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
|
|
@ -138,14 +146,15 @@ describe("buildFeishuAgentBody", () => {
|
|||
describe("handleFeishuMessage ACP routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
bindingResolution: null,
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReset().mockReturnValue({
|
||||
|
|
@ -218,7 +227,37 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||
});
|
||||
|
||||
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: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
|
|
@ -268,12 +307,42 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
|
||||
expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
|
|
@ -305,7 +374,7 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
|
||||
mockEnsureConfiguredBindingRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "runtime unavailable",
|
||||
} as any);
|
||||
|
|
@ -433,14 +502,15 @@ describe("handleFeishuMessage command authorization", () => {
|
|||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
bindingResolution: null,
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
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;
|
||||
let configuredBinding = null;
|
||||
if (feishuAcpConversationSupported) {
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
const configuredRoute = resolveConfiguredBindingRoute({
|
||||
cfg: effectiveCfg,
|
||||
route,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
parentConversationId,
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
parentConversationId,
|
||||
},
|
||||
});
|
||||
configuredBinding = configuredRoute.configuredBinding;
|
||||
configuredBinding = configuredRoute.bindingResolution;
|
||||
route = configuredRoute.route;
|
||||
|
||||
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
||||
|
|
@ -1292,9 +1294,9 @@ export async function handleFeishuMessage(params: {
|
|||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: effectiveCfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
const replyTargetMessageId =
|
||||
|
|
|
|||
|
|
@ -822,11 +822,15 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||
});
|
||||
},
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) =>
|
||||
normalizeFeishuAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||
matchFeishuAcpConversation({
|
||||
bindingConversationId: compiledBinding.conversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}),
|
||||
},
|
||||
setup: feishuSetupAdapter,
|
||||
setupWizard: feishuSetupWizard,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const clientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const mockBaseHttpInstance = vi.hoisted(() => ({
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
head: vi.fn().mockResolvedValue({}),
|
||||
options: vi.fn().mockResolvedValue({}),
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js";
|
||||
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
|
||||
async function importProbeModule(scope: string) {
|
||||
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_SUCCESS_RESPONSE = {
|
||||
|
|
@ -35,15 +36,9 @@ function makeRequestFn(response: Record<string, unknown>) {
|
|||
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>) {
|
||||
const requestFn = makeRequestFn(response);
|
||||
installClientCtor(requestFn);
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
return requestFn;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +48,12 @@ function setupSuccessClient() {
|
|||
|
||||
async function expectDefaultSuccessResult(
|
||||
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);
|
||||
expect(result).toEqual(expected);
|
||||
|
|
@ -73,7 +73,7 @@ async function expectErrorResultCached(params: {
|
|||
expectedError: string;
|
||||
ttlMs: number;
|
||||
}) {
|
||||
installClientCtor(params.requestFn);
|
||||
createFeishuClientMock.mockReturnValue({ request: params.requestFn });
|
||||
|
||||
const first = await probeFeishu(DEFAULT_CREDS);
|
||||
const second = await probeFeishu(DEFAULT_CREDS);
|
||||
|
|
@ -106,27 +106,16 @@ async function readSequentialDefaultProbePair() {
|
|||
}
|
||||
|
||||
describe("probeFeishu", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
({ FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } = await importProbeModule(
|
||||
`probe-${Date.now()}-${Math.random()}`,
|
||||
));
|
||||
clearProbeCache();
|
||||
clearClientCache();
|
||||
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,
|
||||
},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearProbeCache();
|
||||
clearClientCache();
|
||||
setFeishuClientRuntimeForTest();
|
||||
});
|
||||
|
||||
it("returns error when credentials are missing", async () => {
|
||||
|
|
@ -168,7 +157,7 @@ describe("probeFeishu", () => {
|
|||
it("returns timeout error when request exceeds timeout", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
|
||||
installClientCtor(requestFn);
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
|
||||
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
|
@ -179,6 +168,7 @@ describe("probeFeishu", () => {
|
|||
});
|
||||
|
||||
it("returns aborted when abort signal is already aborted", async () => {
|
||||
createFeishuClientMock.mockClear();
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
|
|
@ -188,7 +178,7 @@ describe("probeFeishu", () => {
|
|||
);
|
||||
|
||||
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 () => {
|
||||
const requestFn = setupSuccessClient();
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
|||
registerImageGenerationProvider() {},
|
||||
registerWebSearchProvider() {},
|
||||
registerInteractiveHandler() {},
|
||||
onConversationBindingResolved() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerCommand() {},
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.js", () => ({
|
||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
||||
ensureConfiguredAcpBindingSessionMock(...args),
|
||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
||||
resolveConfiguredAcpBindingRecordMock(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||
resolveConfiguredBindingRouteMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
|
||||
|
||||
|
|
@ -43,15 +47,92 @@ function createConfiguredTelegramBinding() {
|
|||
} 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", () => {
|
||||
beforeEach(() => {
|
||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredTelegramBinding());
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
||||
});
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredTelegramRoute());
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
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.matchedBy).toBe("binding.channel");
|
||||
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 () => {
|
||||
|
|
@ -86,8 +167,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers ACP session initialization for unauthorized control commands", async () => {
|
||||
|
|
@ -109,14 +190,13 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops inbound processing when configured ACP binding initialization fails", async () => {
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:work:abc123",
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
|
||||
|
|
@ -130,7 +210,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
|
|||
});
|
||||
|
||||
expect(ctx).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { loadConfig } 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 { deriveLastRoutePolicy } 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) {
|
||||
return true;
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: freshCfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
|
||||
`telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
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({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "configured ACP binding unavailable",
|
||||
target: configuredBinding.spec.conversationId,
|
||||
target: configuredBinding.record.conversation.conversationId,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
createDeferred,
|
||||
createNativeCommandTestParams,
|
||||
|
|
@ -14,10 +15,10 @@ import {
|
|||
|
||||
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
|
||||
type ResolveConfiguredBindingRouteFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherParams =
|
||||
|
|
@ -34,10 +35,12 @@ const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
|||
};
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
||||
resolveConfiguredBindingRoute: vi.fn<ResolveConfiguredBindingRouteFn>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
ensureConfiguredBindingRouteReady: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
||||
})),
|
||||
}));
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
|
|
@ -59,12 +62,58 @@ const sessionBindingMocks = vi.hoisted(() => ({
|
|||
touch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/acp/persistent-bindings.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
|
||||
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", () => ({
|
||||
|
|
@ -74,15 +123,6 @@ vi.mock("../../../src/config/sessions.js", () => ({
|
|||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
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", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
bind: vi.fn(),
|
||||
|
|
@ -93,10 +133,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
|||
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", () => ({
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
matchPluginCommand: vi.fn(() => null),
|
||||
|
|
@ -233,13 +269,93 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) {
|
|||
status: "active",
|
||||
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>) {
|
||||
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled();
|
||||
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
-1001234567890,
|
||||
"You are not authorized to use this command.",
|
||||
|
|
@ -249,13 +365,12 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.f
|
|||
|
||||
describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
beforeEach(() => {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:telegram:default:seed",
|
||||
});
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
||||
|
|
@ -403,13 +518,18 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||
|
||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
|
|
@ -418,8 +538,8 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||
});
|
||||
await handler(createTelegramTopicCommandContext());
|
||||
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = (
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
|
||||
[{ ctx?: { CommandTargetSessionKey?: string } }]
|
||||
|
|
@ -488,12 +608,19 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||
|
||||
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
sessionKey: boundSessionKey,
|
||||
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 () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(
|
||||
{
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: "codex",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
createConfiguredAcpTopicBinding(boundSessionKey),
|
||||
),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
|
||||
const { handler, sendMessage } = registerAndResolveCommandHandler({
|
||||
commandName: "new",
|
||||
|
|
@ -535,7 +667,9 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||
});
|
||||
|
||||
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({
|
||||
commandName: "new",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type {
|
|||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} 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 {
|
||||
executePluginCommand,
|
||||
|
|
@ -490,13 +490,13 @@ export const registerTelegramNativeCommands = ({
|
|||
topicAgentId,
|
||||
});
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
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({
|
||||
operation: "sendMessage",
|
||||
|
|
|
|||
|
|
@ -6,31 +6,32 @@ import type { TelegramContext } from "./types.js";
|
|||
const saveMediaBuffer = vi.fn();
|
||||
const fetchRemoteMedia = vi.fn();
|
||||
|
||||
vi.mock("../../../../src/media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/media/store.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/media/fetch.js", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/globals.js", () => ({
|
||||
danger: (s: string) => s,
|
||||
warn: (s: string) => s,
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
|
||||
return {
|
||||
...actual,
|
||||
logVerbose: () => {},
|
||||
warn: (s: string) => s,
|
||||
danger: (s: string) => s,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../sticker-cache.js", () => ({
|
||||
cacheSticker: () => {},
|
||||
getCachedSticker: () => null,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const { resolveMedia } = await import("./delivery.js");
|
||||
let resolveMedia: typeof import("./delivery.js").resolveMedia;
|
||||
|
||||
const MAX_MEDIA_BYTES = 10_000_000;
|
||||
const BOT_TOKEN = "tok123";
|
||||
|
||||
|
|
@ -164,10 +165,12 @@ async function flushRetryTimers() {
|
|||
}
|
||||
|
||||
describe("resolveMedia getFile retry", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolveMedia } = await import("./delivery.js"));
|
||||
vi.useFakeTimers();
|
||||
fetchRemoteMedia.mockClear();
|
||||
saveMediaBuffer.mockClear();
|
||||
fetchRemoteMedia.mockReset();
|
||||
saveMediaBuffer.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,36 @@ import * as monitorModule from "./monitor.js";
|
|||
import * as probeModule from "./probe.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 {
|
||||
return {
|
||||
channels: {
|
||||
|
|
@ -156,7 +186,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
telegramPlugin.gateway!.startAccount!(
|
||||
|
|
@ -168,15 +200,23 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
),
|
||||
).rejects.toThrow("Duplicate Telegram bot token");
|
||||
|
||||
expect(probeTelegramMock).not.toHaveBeenCalled();
|
||||
expect(monitorTelegramProviderMock).not.toHaveBeenCalled();
|
||||
expect(probeTelegram).not.toHaveBeenCalled();
|
||||
expect(monitorTelegramProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes webhookPort through to monitor startup options", async () => {
|
||||
const { monitorTelegramProvider } = installGatewayRuntime({
|
||||
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
});
|
||||
probeTelegramMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { username: "opsbot" },
|
||||
elapsedMs: 1,
|
||||
});
|
||||
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
||||
|
||||
const cfg = createCfg();
|
||||
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({
|
||||
useWebhook: true,
|
||||
webhookPort: 9876,
|
||||
}),
|
||||
);
|
||||
expect(probeTelegram).toHaveBeenCalled();
|
||||
expect(monitorTelegramProvider).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes account proxy and network settings into Telegram probes", async () => {
|
||||
const { probeTelegram } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
const runtimeProbeTelegram = vi.fn(async () => {
|
||||
throw new Error("runtime probe should not be used");
|
||||
});
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
probeTelegram: runtimeProbeTelegram,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
probeTelegramMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { username: "opsbot" },
|
||||
elapsedMs: 1,
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
|
|
@ -218,7 +279,7 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
cfg,
|
||||
});
|
||||
|
||||
expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, {
|
||||
expect(probeTelegramMock).toHaveBeenCalledWith("token-ops", 5000, {
|
||||
accountId: "ops",
|
||||
proxyUrl: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
|
|
@ -226,19 +287,40 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
});
|
||||
expect(runtimeProbeTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes account proxy and network settings into Telegram membership audits", async () => {
|
||||
const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
const runtimeCollectUnmentionedGroupIds = vi.fn(() => {
|
||||
throw new Error("runtime audit helper should not be used");
|
||||
});
|
||||
|
||||
collectUnmentionedGroupIds.mockReturnValue({
|
||||
const runtimeAuditGroupMembership = vi.fn(async () => {
|
||||
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"],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
});
|
||||
auditTelegramGroupMembershipMock.mockResolvedValue({
|
||||
ok: true,
|
||||
checkedGroups: 1,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: 1,
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
configureOpsProxyNetwork(cfg);
|
||||
|
|
@ -257,7 +339,10 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
cfg,
|
||||
});
|
||||
|
||||
expect(auditGroupMembership).toHaveBeenCalledWith({
|
||||
expect(collectTelegramUnmentionedGroupIdsMock).toHaveBeenCalledWith({
|
||||
"-100123": { requireMention: false },
|
||||
});
|
||||
expect(auditTelegramGroupMembershipMock).toHaveBeenCalledWith({
|
||||
token: "token-ops",
|
||||
botId: 123,
|
||||
groupIds: ["-100123"],
|
||||
|
|
@ -268,6 +353,8 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(runtimeCollectUnmentionedGroupIds).not.toHaveBeenCalled();
|
||||
expect(runtimeAuditGroupMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false });
|
||||
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({
|
||||
probeOk: false,
|
||||
});
|
||||
probeTelegramMock.mockResolvedValue({ ok: false, elapsedMs: 1 });
|
||||
monitorTelegramProviderMock.mockResolvedValue(undefined);
|
||||
|
||||
const cfg = createCfg();
|
||||
const ctx = createStartAccountCtx({
|
||||
|
|
@ -405,11 +496,18 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
} as ResolvedTelegramAccount;
|
||||
|
||||
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({
|
||||
token: "",
|
||||
}),
|
||||
);
|
||||
expect(probeTelegram).toHaveBeenCalled();
|
||||
expect(monitorTelegramProvider).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -333,11 +333,15 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
}),
|
||||
}),
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) =>
|
||||
normalizeTelegramAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||
matchTelegramAcpConversation({
|
||||
bindingConversationId: compiledBinding.conversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveTelegramDmPolicy,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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 { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
|
|
@ -31,7 +34,7 @@ export function resolveTelegramConversationRoute(params: {
|
|||
topicAgentId?: string | null;
|
||||
}): {
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
configuredBinding: ReturnType<typeof resolveConfiguredAcpRoute>["configuredBinding"];
|
||||
configuredBinding: ConfiguredBindingRouteResult["bindingResolution"];
|
||||
configuredBindingSessionKey: string;
|
||||
} {
|
||||
const peerId = params.isGroup
|
||||
|
|
@ -94,15 +97,17 @@ export function resolveTelegramConversationRoute(params: {
|
|||
);
|
||||
}
|
||||
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
const configuredRoute = resolveConfiguredBindingRoute({
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: peerId,
|
||||
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: peerId,
|
||||
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
|
||||
},
|
||||
});
|
||||
let configuredBinding = configuredRoute.configuredBinding;
|
||||
let configuredBinding = configuredRoute.bindingResolution;
|
||||
let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,14 @@ vi.mock("grammy", () => ({
|
|||
botCtorSpy(token, options);
|
||||
}
|
||||
},
|
||||
HttpError: class HttpError extends Error {
|
||||
constructor(
|
||||
message = "HttpError",
|
||||
public error?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
InputFile: class {},
|
||||
}));
|
||||
|
||||
|
|
@ -94,5 +102,6 @@ export function installTelegramSendTestHooks() {
|
|||
}
|
||||
|
||||
export async function importTelegramSendModule() {
|
||||
vi.resetModules();
|
||||
return await import("./send.js");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,29 +7,24 @@ const loadCronStore = vi.fn();
|
|||
const resolveCronStorePath = vi.fn();
|
||||
const saveCronStore = vi.fn();
|
||||
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshotForWrite,
|
||||
writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/cron/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/cron/store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadCronStore,
|
||||
resolveCronStorePath,
|
||||
saveCronStore,
|
||||
};
|
||||
});
|
||||
|
||||
const { maybePersistResolvedTelegramTarget } = await import("./target-writeback.js");
|
||||
|
||||
describe("maybePersistResolvedTelegramTarget", () => {
|
||||
beforeEach(() => {
|
||||
let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"));
|
||||
readConfigFileSnapshotForWrite.mockReset();
|
||||
writeConfigFile.mockReset();
|
||||
loadCronStore.mockReset();
|
||||
|
|
|
|||
|
|
@ -603,137 +603,164 @@ export class AcpSessionManager {
|
|||
}
|
||||
await this.evictIdleRuntimeHandles({ cfg: input.cfg });
|
||||
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 actorKey = normalizeActorKey(sessionKey);
|
||||
|
||||
await this.setSessionState({
|
||||
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({
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const resolution = this.resolveSession({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "idle",
|
||||
clearLastError: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const acpError = toAcpRuntimeError({
|
||||
error,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP turn failed before completion.",
|
||||
});
|
||||
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) {
|
||||
input.signal.removeEventListener("abort", onCallerAbort);
|
||||
}
|
||||
if (this.activeTurnBySession.get(actorKey) === activeTurn) {
|
||||
this.activeTurnBySession.delete(actorKey);
|
||||
}
|
||||
if (meta.mode !== "oneshot") {
|
||||
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
let runtime: AcpRuntime | undefined;
|
||||
let handle: AcpRuntimeHandle | undefined;
|
||||
let meta: SessionAcpMeta | undefined;
|
||||
let activeTurn: ActiveTurnState | undefined;
|
||||
let internalAbortController: AbortController | undefined;
|
||||
let onCallerAbort: (() => void) | undefined;
|
||||
let activeTurnStarted = false;
|
||||
let sawTurnOutput = false;
|
||||
let retryFreshHandle = false;
|
||||
try {
|
||||
const ensured = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
runtime = ensured.runtime;
|
||||
handle = ensured.handle;
|
||||
meta = ensured.meta;
|
||||
await this.applyRuntimeControls({
|
||||
sessionKey,
|
||||
runtime,
|
||||
handle,
|
||||
meta,
|
||||
failOnStatusError: false,
|
||||
}));
|
||||
}
|
||||
if (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);
|
||||
});
|
||||
|
||||
await this.setSessionState({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "running",
|
||||
clearLastError: true,
|
||||
});
|
||||
|
||||
internalAbortController = new AbortController();
|
||||
onCallerAbort = () => {
|
||||
internalAbortController?.abort();
|
||||
};
|
||||
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 (
|
||||
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
|
||||
// cannot continue counting against maxConcurrentSessions.
|
||||
|
|
@ -916,7 +945,17 @@ export class AcpSessionManager {
|
|||
const agentMatches = cached.agent === agent;
|
||||
const modeMatches = cached.mode === mode;
|
||||
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 {
|
||||
runtime: cached.runtime,
|
||||
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: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
|
|
@ -1103,6 +1185,29 @@ export class AcpSessionManager {
|
|||
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> {
|
||||
const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg);
|
||||
if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) {
|
||||
|
|
|
|||
|
|
@ -354,6 +354,52 @@ describe("AcpSessionManager", () => {
|
|||
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 () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
|
|
@ -531,6 +577,61 @@ describe("AcpSessionManager", () => {
|
|||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
|
@ -807,6 +908,82 @@ describe("AcpSessionManager", () => {
|
|||
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 () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
normalizeText,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.types.js";
|
||||
import { readAcpSessionEntry } from "./runtime/session-meta.js";
|
||||
|
||||
|
|
@ -96,7 +97,7 @@ export async function ensureConfiguredAcpBindingSession(params: {
|
|||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(
|
||||
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
||||
`acp-configured-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
||||
);
|
||||
return {
|
||||
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: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
|
|
@ -119,14 +140,17 @@ export async function resetAcpSessionInPlace(params: {
|
|||
};
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
const meta = readAcpSessionEntry({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
})?.acp;
|
||||
const configuredBinding =
|
||||
!meta || !normalizeText(meta.agent)
|
||||
? resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
})
|
||||
: null;
|
||||
if (!meta) {
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
|
|
@ -189,7 +213,7 @@ export async function resetAcpSessionInPlace(params: {
|
|||
return { ok: true };
|
||||
} catch (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 {
|
||||
ok: false,
|
||||
error: message,
|
||||
|
|
|
|||
|
|
@ -1,275 +1,17 @@
|
|||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { listAcpBindings } from "../config/bindings.js";
|
||||
import {
|
||||
resolveConfiguredBindingRecord,
|
||||
resolveConfiguredBindingRecordBySessionKey,
|
||||
resolveConfiguredBindingRecordForConversation,
|
||||
} from "../channels/plugins/binding-registry.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentAcpBinding } from "../config/types.js";
|
||||
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
|
||||
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
normalizeMode,
|
||||
normalizeText,
|
||||
toConfiguredAcpBindingRecord,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
resolveConfiguredAcpBindingSpecFromRecord,
|
||||
toResolvedConfiguredAcpBinding,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} 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: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
|
|
@ -277,36 +19,22 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
|||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
const channel = normalizeBindingChannel(params.channel);
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const conversationId = params.conversationId.trim();
|
||||
const parentConversationId = params.parentConversationId?.trim() || undefined;
|
||||
if (!channel || !conversationId) {
|
||||
return null;
|
||||
}
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const acpBindings = plugin?.acpBindings;
|
||||
if (!acpBindings?.matchConfiguredBinding) {
|
||||
return null;
|
||||
}
|
||||
const matchConfiguredBinding = acpBindings.matchConfiguredBinding;
|
||||
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel,
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId) {
|
||||
return null;
|
||||
}
|
||||
return matchConfiguredBinding({
|
||||
binding,
|
||||
bindingConversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
},
|
||||
});
|
||||
const resolved = resolveConfiguredBindingRecord(params);
|
||||
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingRecordForConversation(params: {
|
||||
cfg: OpenClawConfig;
|
||||
conversation: ConversationRef;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
const resolved = resolveConfiguredBindingRecordForConversation(params);
|
||||
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): ConfiguredAcpBindingSpec | null {
|
||||
const resolved = resolveConfiguredBindingRecordBySessionKey(params);
|
||||
return resolved ? resolveConfiguredAcpBindingSpecFromRecord(resolved.record) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { feishuPlugin } from "../../extensions/feishu/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 { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
|
||||
const managerMocks = vi.hoisted(() => ({
|
||||
resolveSession: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
|
|
@ -27,17 +30,24 @@ vi.mock("./runtime/session-meta.js", () => ({
|
|||
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
||||
}));
|
||||
|
||||
type PersistentBindingsModule = typeof import("./persistent-bindings.js");
|
||||
|
||||
let buildConfiguredAcpSessionKey: PersistentBindingsModule["buildConfiguredAcpSessionKey"];
|
||||
let ensureConfiguredAcpBindingSession: PersistentBindingsModule["ensureConfiguredAcpBindingSession"];
|
||||
let resetAcpSessionInPlace: PersistentBindingsModule["resetAcpSessionInPlace"];
|
||||
let resolveConfiguredAcpBindingRecord: PersistentBindingsModule["resolveConfiguredAcpBindingRecord"];
|
||||
let resolveConfiguredAcpBindingSpecBySessionKey: PersistentBindingsModule["resolveConfiguredAcpBindingSpecBySessionKey"];
|
||||
type PersistentBindingsModule = Pick<
|
||||
typeof import("./persistent-bindings.resolve.js"),
|
||||
"resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey"
|
||||
> &
|
||||
Pick<
|
||||
typeof import("./persistent-bindings.lifecycle.js"),
|
||||
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
|
||||
>;
|
||||
let persistentBindings: PersistentBindingsModule;
|
||||
let persistentBindingsImportScope = 0;
|
||||
|
||||
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||
type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0];
|
||||
type BindingSpec = Parameters<typeof ensureConfiguredAcpBindingSession>[0]["spec"];
|
||||
type BindingRecordInput = Parameters<
|
||||
PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]
|
||||
>[0];
|
||||
type BindingSpec = Parameters<
|
||||
PersistentBindingsModule["ensureConfiguredAcpBindingSession"]
|
||||
>[0]["spec"];
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
|
|
@ -117,7 +127,7 @@ function createFeishuBinding(params: {
|
|||
}
|
||||
|
||||
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
||||
return resolveConfiguredAcpBindingRecord({
|
||||
return persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: defaultDiscordAccountId,
|
||||
|
|
@ -131,7 +141,7 @@ function resolveDiscordBindingSpecBySession(
|
|||
conversationId = defaultDiscordConversationId,
|
||||
) {
|
||||
const resolved = resolveBindingRecord(cfg, { conversationId });
|
||||
return resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
|
|
@ -148,7 +158,11 @@ function createDiscordPersistentSpec(overrides: Partial<BindingSpec> = {}): Bind
|
|||
} 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);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
|
|
@ -159,14 +173,33 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
|||
runtimeSessionName: "existing",
|
||||
mode: params.spec.mode,
|
||||
runtimeOptions: { cwd: params.cwd },
|
||||
state: "idle",
|
||||
state: params.state ?? "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
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(
|
||||
createTestRegistry([
|
||||
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
||||
|
|
@ -184,17 +217,6 @@ beforeEach(() => {
|
|||
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
buildConfiguredAcpSessionKey,
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
} = await import("./persistent-bindings.js"));
|
||||
});
|
||||
|
||||
describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
|
|
@ -263,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
|
|
@ -318,13 +340,13 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const canonical = resolveConfiguredAcpBindingRecord({
|
||||
const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
});
|
||||
const splitIds = resolveConfiguredAcpBindingRecord({
|
||||
const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
|
|
@ -347,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
|
|
@ -364,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
|
|
@ -384,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
|
|
@ -405,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
|
|
@ -427,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
|
|
@ -449,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
|
|
@ -468,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
|
|
@ -514,6 +536,25 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
|||
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
|
||||
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", () => {
|
||||
|
|
@ -534,7 +575,7 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
|||
});
|
||||
|
||||
it("returns null for unknown session keys", () => {
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:main:acp:binding:discord:default:notfound",
|
||||
});
|
||||
|
|
@ -568,13 +609,13 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
|||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "user_123",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
|
|
@ -614,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||
cwd: "/workspace/openclaw",
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
|
@ -633,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||
cwd: "/workspace/other-repo",
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
|
@ -649,6 +690,26 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||
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 () => {
|
||||
const spec = createDiscordPersistentSpec({
|
||||
agentId: "coding",
|
||||
|
|
@ -656,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
|||
});
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
|
@ -692,7 +753,7 @@ describe("resetAcpSessionInPlace", () => {
|
|||
});
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
reason: "new",
|
||||
|
|
@ -721,7 +782,7 @@ describe("resetAcpSessionInPlace", () => {
|
|||
});
|
||||
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
|
|
@ -752,7 +813,7 @@ describe("resetAcpSessionInPlace", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
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 type { ChannelId } from "../channels/plugins/types.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 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(),
|
||||
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 { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
||||
|
|
@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
||||
const configuredBinding = resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
|
|
@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
|||
? boundAcpSessionKey.trim()
|
||||
: undefined;
|
||||
if (boundAcpKey) {
|
||||
const resetResult = await resetAcpSessionInPlace({
|
||||
const resetResult = await resetConfiguredBindingTargetInPlace({
|
||||
cfg: params.cfg,
|
||||
sessionKey: boundAcpKey,
|
||||
reason: commandAction,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
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 { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.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 type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
|
|
@ -192,14 +193,16 @@ vi.mock("../../tts/tts.js", () => ({
|
|||
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 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 {
|
||||
return {
|
||||
|
|
@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
|
|||
}
|
||||
|
||||
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(
|
||||
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
source: "test",
|
||||
plugin: discordTestPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||
resetInboundDedupe();
|
||||
|
|
@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => {
|
|||
},
|
||||
});
|
||||
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
|
||||
throw new AcpRuntimeError(
|
||||
throw new AcpRuntimeErrorClass(
|
||||
"ACP_BACKEND_MISSING",
|
||||
"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 {
|
||||
resolveConversationBindingRecord,
|
||||
touchConversationBindingRecord,
|
||||
} from "../../bindings/records.js";
|
||||
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
|
|
@ -20,7 +24,6 @@ import {
|
|||
toPluginMessageReceivedEvent,
|
||||
} from "../../hooks/message-hook-mappers.js";
|
||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
logMessageProcessed,
|
||||
logMessageQueued,
|
||||
|
|
@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||
|
||||
const pluginOwnedBindingRecord =
|
||||
inboundClaimContext.conversationId && inboundClaimContext.channelId
|
||||
? getSessionBindingService().resolveByConversation({
|
||||
? resolveConversationBindingRecord({
|
||||
channel: inboundClaimContext.channelId,
|
||||
accountId: inboundClaimContext.accountId ?? "default",
|
||||
conversationId: inboundClaimContext.conversationId,
|
||||
|
|
@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||
| undefined;
|
||||
|
||||
if (pluginOwnedBinding) {
|
||||
getSessionBindingService().touch(pluginOwnedBinding.bindingId);
|
||||
touchConversationBindingRecord(pluginOwnedBinding.bindingId);
|
||||
logVerbose(
|
||||
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
|
@ -300,7 +301,7 @@ describe("routeReply", () => {
|
|||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "telegram",
|
||||
|
|
@ -308,10 +309,12 @@ describe("routeReply", () => {
|
|||
threadId: 42,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"hi",
|
||||
expect.objectContaining({ messageThreadId: 42 }),
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 42,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -346,17 +349,19 @@ describe("routeReply", () => {
|
|||
});
|
||||
|
||||
it("passes replyToId to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi", replyToId: "123" },
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"hi",
|
||||
expect.objectContaining({ replyToMessageId: 123 }),
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToId: "123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 { ConfiguredBindingRule } from "../../config/bindings.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 { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
|
|
@ -541,24 +541,26 @@ export type ChannelAllowlistAdapter = {
|
|||
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
|
||||
};
|
||||
|
||||
export type ChannelAcpBindingAdapter = {
|
||||
normalizeConfiguredBindingTarget?: (params: {
|
||||
binding: AgentAcpBinding;
|
||||
export type ChannelConfiguredBindingConversationRef = {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
|
||||
export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversationRef & {
|
||||
matchPriority?: number;
|
||||
};
|
||||
|
||||
export type ChannelConfiguredBindingProvider = {
|
||||
compileConfiguredBinding: (params: {
|
||||
binding: ConfiguredBindingRule;
|
||||
conversationId: string;
|
||||
}) => {
|
||||
}) => ChannelConfiguredBindingConversationRef | null;
|
||||
matchInboundConversation: (params: {
|
||||
binding: ConfiguredBindingRule;
|
||||
compiledBinding: ChannelConfiguredBindingConversationRef;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
matchConfiguredBinding?: (params: {
|
||||
binding: AgentAcpBinding;
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority?: number;
|
||||
} | null;
|
||||
}) => ChannelConfiguredBindingMatch | null;
|
||||
};
|
||||
|
||||
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import type {
|
|||
ChannelSetupAdapter,
|
||||
ChannelStatusAdapter,
|
||||
ChannelAllowlistAdapter,
|
||||
ChannelAcpBindingAdapter,
|
||||
ChannelConfiguredBindingProvider,
|
||||
} from "./types.adapters.js";
|
||||
import type {
|
||||
ChannelAgentTool,
|
||||
|
|
@ -78,7 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
|||
lifecycle?: ChannelLifecycleAdapter;
|
||||
execApprovals?: ChannelExecApprovalAdapter;
|
||||
allowlist?: ChannelAllowlistAdapter;
|
||||
acpBindings?: ChannelAcpBindingAdapter;
|
||||
bindings?: ChannelConfiguredBindingProvider;
|
||||
streaming?: ChannelStreamingAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ export type {
|
|||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelAllowlistAdapter,
|
||||
ChannelAcpBindingAdapter,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelPairingAdapter,
|
||||
ChannelSecurityAdapter,
|
||||
ChannelSetupAdapter,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,30 @@ describe("buildGatewayInstallPlan", () => {
|
|||
expect(plan.workingDirectory).toBe("/Users/me");
|
||||
expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" });
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { buildServiceEnvironment } from "../daemon/service-env.js";
|
|||
import {
|
||||
emitDaemonInstallRuntimeWarning,
|
||||
resolveDaemonInstallRuntimeInputs,
|
||||
resolveDaemonNodeBinDir,
|
||||
} from "./daemon-install-plan.shared.js";
|
||||
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||
|
|
@ -87,6 +88,9 @@ export async function buildGatewayInstallPlan(params: {
|
|||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
|
||||
: 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).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDaemonInstallRuntimeInputs,
|
||||
resolveDaemonNodeBinDir,
|
||||
resolveGatewayDevMode,
|
||||
} 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 {
|
||||
emitNodeRuntimeWarning,
|
||||
|
|
@ -42,3 +43,11 @@ export async function emitDaemonInstallRuntimeWarning(params: {
|
|||
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)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
emitDaemonInstallRuntimeWarning,
|
||||
resolveDaemonInstallRuntimeInputs,
|
||||
resolveDaemonNodeBinDir,
|
||||
} from "./daemon-install-plan.shared.js";
|
||||
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
||||
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
|
||||
|
|
@ -54,7 +55,12 @@ export async function buildNodeInstallPlan(params: {
|
|||
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({
|
||||
version: environment.OPENCLAW_SERVICE_VERSION,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { OpenClawConfig } from "./config.js";
|
||||
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
|
||||
|
||||
export type ConfiguredBindingRule = AgentBinding;
|
||||
|
||||
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
|
||||
return binding.type === "acp" ? "acp" : "route";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import fsPromises from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 type { OpenClawConfig } from "../config.js";
|
||||
import {
|
||||
clearSessionStoreCacheForTest,
|
||||
loadSessionStore,
|
||||
|
|
@ -279,6 +281,72 @@ describe("session store lock (Promise chain mutex)", () => {
|
|||
expect(store[key]?.modelProvider).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", () => {
|
||||
|
|
|
|||
|
|
@ -309,6 +309,12 @@ type SaveSessionStoreOptions = {
|
|||
skipMaintenance?: boolean;
|
||||
/** Active session key for warn-only maintenance. */
|
||||
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. */
|
||||
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
|
||||
/** 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(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
|
|
@ -526,7 +590,13 @@ export async function updateSessionStore<T>(
|
|||
return await withSessionStoreLock(storePath, async () => {
|
||||
// Always re-read inside the lock to avoid clobbering concurrent writers.
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const previousAcpByKey = collectAcpMetadataSnapshot(store);
|
||||
const result = await mutator(store);
|
||||
preserveExistingAcpMetadata({
|
||||
previousAcpByKey,
|
||||
nextStore: store,
|
||||
allowDropSessionKeys: opts?.allowDropAcpMetaSessionKeys,
|
||||
});
|
||||
await saveSessionStoreUnlocked(storePath, store, opts);
|
||||
return result;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -257,6 +257,18 @@ describe("buildMinimalServicePath", () => {
|
|||
const unique = [...new Set(parts)];
|
||||
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", () => {
|
||||
|
|
@ -344,6 +356,19 @@ describe("buildServiceEnvironment", () => {
|
|||
expect(env).not.toHaveProperty("PATH");
|
||||
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", () => {
|
||||
|
|
@ -416,6 +441,18 @@ describe("buildNodeServiceEnvironment", () => {
|
|||
});
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: {
|
|||
port: number;
|
||||
launchdLabel?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
extraPathDirs?: string[];
|
||||
}): Record<string, string | undefined> {
|
||||
const { env, port, launchdLabel } = params;
|
||||
const { env, port, launchdLabel, extraPathDirs } = params;
|
||||
const platform = params.platform ?? process.platform;
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
|
||||
const profile = env.OPENCLAW_PROFILE;
|
||||
const resolvedLaunchdLabel =
|
||||
launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined);
|
||||
|
|
@ -271,10 +272,11 @@ export function buildServiceEnvironment(params: {
|
|||
export function buildNodeServiceEnvironment(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
platform?: NodeJS.Platform;
|
||||
extraPathDirs?: string[];
|
||||
}): Record<string, string | undefined> {
|
||||
const { env } = params;
|
||||
const { env, extraPathDirs } = params;
|
||||
const platform = params.platform ?? process.platform;
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
|
||||
const gatewayToken =
|
||||
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined;
|
||||
return {
|
||||
|
|
@ -313,6 +315,7 @@ function buildCommonServiceEnvironment(
|
|||
function resolveSharedServiceEnvironmentFields(
|
||||
env: Record<string, string | undefined>,
|
||||
platform: NodeJS.Platform,
|
||||
extraPathDirs: string[] | undefined,
|
||||
): SharedServiceEnvironmentFields {
|
||||
const stateDir = env.OPENCLAW_STATE_DIR;
|
||||
const configPath = env.OPENCLAW_CONFIG_PATH;
|
||||
|
|
@ -331,7 +334,10 @@ function resolveSharedServiceEnvironmentFields(
|
|||
tmpDir,
|
||||
// On Windows, Scheduled Tasks should inherit the current task PATH instead of
|
||||
// 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,
|
||||
nodeCaCerts,
|
||||
nodeUseSystemCa,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import type { PluginDiagnostic } from "../plugins/types.js";
|
|||
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
|
||||
|
||||
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
|
||||
const primeConfiguredBindingRegistry = vi.hoisted(() =>
|
||||
vi.fn(() => ({ bindingCount: 0, channelCount: 0 })),
|
||||
);
|
||||
type HandleGatewayRequestOptions = GatewayRequestOptions & {
|
||||
extraHandlers?: Record<string, unknown>;
|
||||
};
|
||||
|
|
@ -17,6 +20,10 @@ vi.mock("../plugins/loader.js", () => ({
|
|||
loadOpenClawPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/binding-registry.js", () => ({
|
||||
primeConfiguredBindingRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("./server-methods.js", () => ({
|
||||
handleGatewayRequest,
|
||||
}));
|
||||
|
|
@ -51,6 +58,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
|||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
|
|
@ -110,6 +118,7 @@ async function createSubagentRuntime(
|
|||
|
||||
beforeEach(async () => {
|
||||
loadOpenClawPlugins.mockReset();
|
||||
primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 });
|
||||
handleGatewayRequest.mockReset();
|
||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||
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 () => {
|
||||
const { loadGatewayPlugins } = await importServerPluginsModule();
|
||||
const diagnostics: PluginDiagnostic[] = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
|
||||
import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js";
|
||||
import type { loadConfig } from "../config/config.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
|
|
@ -416,6 +417,7 @@ export function loadGatewayPlugins(params: {
|
|||
},
|
||||
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
|
||||
});
|
||||
primeConfiguredBindingRegistry({ cfg: params.cfg });
|
||||
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
|
||||
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
|
||||
if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) {
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
|||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
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 {
|
||||
type BindingStatus,
|
||||
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 { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js";
|
||||
export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js";
|
||||
|
|
@ -13,6 +17,11 @@ export type {
|
|||
ThreadBindingRecord,
|
||||
ThreadBindingTargetKind,
|
||||
} from "../../extensions/discord/src/monitor/thread-bindings.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
export type {
|
||||
ChannelMessageActionContext,
|
||||
ChannelPlugin,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export type {
|
|||
ChannelMeta,
|
||||
ChannelOutboundAdapter,
|
||||
} 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 { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPluginSdkEntrySources,
|
||||
buildPluginSdkPackageExports,
|
||||
buildPluginSdkSpecifiers,
|
||||
pluginSdkEntrypoints,
|
||||
|
|
@ -11,6 +15,9 @@ import {
|
|||
import * as sdk from "./index.js";
|
||||
|
||||
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
|
||||
const execFileAsync = promisify(execFile);
|
||||
const require = createRequire(import.meta.url);
|
||||
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
|
||||
|
||||
describe("plugin-sdk exports", () => {
|
||||
it("does not expose runtime modules", () => {
|
||||
|
|
@ -63,16 +70,33 @@ describe("plugin-sdk exports", () => {
|
|||
});
|
||||
|
||||
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 repoDistDir = path.join(process.cwd(), "dist");
|
||||
|
||||
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) {
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href
|
||||
);
|
||||
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
|
||||
expect(module).toBeTypeOf("object");
|
||||
}
|
||||
|
||||
|
|
@ -80,8 +104,8 @@ describe("plugin-sdk exports", () => {
|
|||
const consumerDir = path.join(fixtureDir, "consumer");
|
||||
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
|
||||
|
||||
await fs.mkdir(packageDir, { recursive: true });
|
||||
await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir");
|
||||
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
|
||||
await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
|
|
@ -114,6 +138,7 @@ describe("plugin-sdk exports", () => {
|
|||
Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(outDir, { recursive: true, force: true });
|
||||
await fs.rm(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,8 +14,25 @@ export type {
|
|||
ChannelMessageActionName,
|
||||
ChannelStatusIssue,
|
||||
} 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 { 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 {
|
||||
ChannelSetupWizard,
|
||||
ChannelSetupWizardAllowFromEntry,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export type {
|
|||
TelegramActionConfig,
|
||||
TelegramNetworkConfig,
|
||||
} 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 { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.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 { formatCliCommand } from "../cli/command-format.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
applyAccountNameToChannelSection,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import type {
|
|||
SessionBindingAdapter,
|
||||
SessionBindingRecord,
|
||||
} 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 approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json");
|
||||
|
|
@ -145,6 +147,7 @@ describe("plugin conversation binding approvals", () => {
|
|||
beforeEach(() => {
|
||||
sessionBindingState.reset();
|
||||
__testing.reset();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
fs.rmSync(approvalsPath, { force: true });
|
||||
unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" });
|
||||
unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" });
|
||||
|
|
@ -366,6 +369,118 @@ describe("plugin conversation binding approvals", () => {
|
|||
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 () => {
|
||||
const request = await requestPluginConversationBinding({
|
||||
pluginId: "codex",
|
||||
|
|
|
|||
|
|
@ -2,15 +2,20 @@ import crypto from "node:crypto";
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import {
|
||||
createConversationBindingRecord,
|
||||
resolveConversationBindingRecord,
|
||||
unbindConversationBindingRecord,
|
||||
} from "../bindings/records.js";
|
||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||
import { writeJsonAtomic } from "../infra/json-files.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type ConversationRef,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import { type ConversationRef } from "../infra/outbound/session-binding-service.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getActivePluginRegistry } from "./runtime.js";
|
||||
import type {
|
||||
PluginConversationBinding,
|
||||
PluginConversationBindingResolvedEvent,
|
||||
PluginConversationBindingResolutionDecision,
|
||||
PluginConversationBindingRequestParams,
|
||||
PluginConversationBindingRequestResult,
|
||||
} from "./types.js";
|
||||
|
|
@ -26,7 +31,9 @@ const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [
|
|||
"openclaw-codex-app-server:thread:",
|
||||
] 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 = {
|
||||
pluginRoot: string;
|
||||
|
|
@ -87,7 +94,7 @@ type PluginBindingResolveResult =
|
|||
status: "approved";
|
||||
binding: PluginConversationBinding;
|
||||
request: PendingPluginBindingRequest;
|
||||
decision: PluginBindingApprovalDecision;
|
||||
decision: Exclude<PluginBindingApprovalDecision, "deny">;
|
||||
}
|
||||
| {
|
||||
status: "denied";
|
||||
|
|
@ -423,7 +430,7 @@ async function bindConversationNow(params: {
|
|||
accountId: ref.accountId,
|
||||
conversationId: ref.conversationId,
|
||||
});
|
||||
const record = await getSessionBindingService().bind({
|
||||
const record = await createConversationBindingRecord({
|
||||
targetSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: ref,
|
||||
|
|
@ -574,7 +581,7 @@ export async function requestPluginConversationBinding(params: {
|
|||
}): Promise<PluginConversationBindingRequestResult> {
|
||||
const conversation = normalizeConversation(params.conversation);
|
||||
const ref = toConversationRef(conversation);
|
||||
const existing = getSessionBindingService().resolveByConversation(ref);
|
||||
const existing = resolveConversationBindingRecord(ref);
|
||||
const existingPluginBinding = toPluginConversationBinding(existing);
|
||||
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
|
||||
record: existing,
|
||||
|
|
@ -665,9 +672,7 @@ export async function getCurrentPluginConversationBinding(params: {
|
|||
pluginRoot: string;
|
||||
conversation: PluginBindingConversation;
|
||||
}): Promise<PluginConversationBinding | null> {
|
||||
const record = getSessionBindingService().resolveByConversation(
|
||||
toConversationRef(params.conversation),
|
||||
);
|
||||
const record = resolveConversationBindingRecord(toConversationRef(params.conversation));
|
||||
const binding = toPluginConversationBinding(record);
|
||||
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
||||
return null;
|
||||
|
|
@ -684,12 +689,12 @@ export async function detachPluginConversationBinding(params: {
|
|||
conversation: PluginBindingConversation;
|
||||
}): Promise<{ removed: boolean }> {
|
||||
const ref = toConversationRef(params.conversation);
|
||||
const record = getSessionBindingService().resolveByConversation(ref);
|
||||
const record = resolveConversationBindingRecord(ref);
|
||||
const binding = toPluginConversationBinding(record);
|
||||
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
||||
return { removed: false };
|
||||
}
|
||||
await getSessionBindingService().unbind({
|
||||
await unbindConversationBindingRecord({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "plugin-detach",
|
||||
});
|
||||
|
|
@ -717,6 +722,11 @@ export async function resolvePluginConversationBindingApproval(params: {
|
|||
}
|
||||
pendingRequests.delete(params.approvalId);
|
||||
if (params.decision === "deny") {
|
||||
await notifyPluginConversationBindingResolved({
|
||||
status: "denied",
|
||||
decision: "deny",
|
||||
request,
|
||||
});
|
||||
log.info(
|
||||
`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(
|
||||
`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 {
|
||||
status: "approved",
|
||||
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 {
|
||||
if (params.status === "expired") {
|
||||
return "That plugin bind approval expired. Retry the bind command.";
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
OpenClawPluginChannelRegistration,
|
||||
OpenClawPluginCliRegistrar,
|
||||
OpenClawPluginCommandDefinition,
|
||||
PluginConversationBindingResolvedEvent,
|
||||
OpenClawPluginHttpRouteAuth,
|
||||
OpenClawPluginHttpRouteMatch,
|
||||
OpenClawPluginHttpRouteHandler,
|
||||
|
|
@ -147,6 +148,15 @@ export type PluginCommandRegistration = {
|
|||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingResolvedHandlerRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
pluginRoot?: string;
|
||||
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -199,6 +209,7 @@ export type PluginRegistry = {
|
|||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
|
|
@ -247,6 +258,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
|||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -829,6 +841,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
} 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 => ({
|
||||
info: logger.info,
|
||||
warn: logger.warn,
|
||||
|
|
@ -942,6 +968,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
}
|
||||
}
|
||||
: () => {},
|
||||
onConversationBindingResolved:
|
||||
registrationMode === "full"
|
||||
? (handler) => registerConversationBindingResolvedHandler(record, handler)
|
||||
: () => {},
|
||||
registerCommand:
|
||||
registrationMode === "full" ? (command) => registerCommand(record, command) : () => {},
|
||||
registerContextEngine: (id, factory) => {
|
||||
|
|
|
|||
|
|
@ -940,6 +940,8 @@ export type PluginConversationBindingRequestParams = {
|
|||
detachHint?: string;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
export type PluginConversationBinding = {
|
||||
bindingId: string;
|
||||
pluginId: string;
|
||||
|
|
@ -970,6 +972,24 @@ export type PluginConversationBindingRequestResult =
|
|||
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.
|
||||
*/
|
||||
|
|
@ -1256,6 +1276,9 @@ export type OpenClawPluginApi = {
|
|||
registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void;
|
||||
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
|
||||
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
|
||||
onConversationBindingResolved: (
|
||||
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
|
||||
) => void;
|
||||
/**
|
||||
* Register a custom command that bypasses the LLM agent.
|
||||
* 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…
Reference in New Issue