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:
Bob
2026-03-17 17:27:52 +01:00
committed by GitHub
parent 8139f83175
commit ea15819ecf
102 changed files with 6606 additions and 1199 deletions

View File

@@ -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

View File

@@ -0,0 +1,519 @@
# Bindings Capability Architecture Plan
Status: in progress
## Summary
The goal is not to move all ACP code out of core.
The goal is to make `bindings` a small core capability, keep the ACP session kernel in core, and move ACP-specific binding policy plus codex app server policy out of core.
That gives us a lightweight core without hiding core semantics behind plugin indirection.
## Current Conclusion
The current architecture should converge on this split:
- Core owns the generic binding capability.
- Core owns the generic ACP session kernel.
- Channel plugins own channel-specific binding semantics.
- ACP backend plugins own runtime protocol details.
- Product-level consumers like ACP configured bindings and the codex app server sit on top of the binding capability instead of hardcoding their own binding plumbing.
This is different from "everything becomes a plugin".
## Why This Changed
The current codebase already shows that there are really three different layers:
- binding and conversation ownership
- long-lived session and runtime-handle orchestration
- product-specific turn logic
Those layers should not all be forced into one runtime engine.
Today the duplication is mostly in the execution/control-plane shape, not in storage or binding plumbing:
- the main harness has its own turn engine
- ACP has its own session control plane
- the codex app server plugin path likely owns its own app-level turn engine outside this repo
The right move is to share the stable control-plane contracts, not to force all three into one giant executor.
## Verified Current State
### Generic binding pieces already exist
- `src/infra/outbound/session-binding-service.ts` already provides a generic binding store and adapter model.
- `src/plugins/conversation-binding.ts` already lets plugins request a conversation binding and stores plugin-owned binding metadata.
- `src/plugins/types.ts` already exposes plugin-facing binding APIs.
- `src/plugins/types.ts` already exposes the generic `inbound_claim` hook.
### ACP is only partially pluginified
- `src/channels/plugins/configured-binding-registry.ts` now owns generic configured binding compilation and lookup.
- `src/channels/plugins/binding-routing.ts` and `src/channels/plugins/binding-targets.ts` now own the generic route and target lifecycle seams.
- ACP now plugs into that seam through `src/channels/plugins/acp-configured-binding-consumer.ts` and `src/channels/plugins/acp-stateful-target-driver.ts`.
- `src/acp/persistent-bindings.lifecycle.ts` still owns configured ACP ensure and reset behavior.
- runtime-created plugin conversation bindings still use a separate path in `src/plugins/conversation-binding.ts`.
### Codex app server is already closer to the desired shape
From this repo's side, the codex app server path is much thinner:
- a plugin binds a conversation
- core stores that binding
- inbound dispatch targets the plugin's `inbound_claim` hook
What core does not provide for the codex app server path is an ACP-like shared session kernel. If the app server needs retries, long-lived runtime handles, cancellation, or session health logic, it must own that itself today.
## The Durable Split
### 1. Core Binding Capability
This should become the primary shared seam.
Responsibilities:
- canonical `ConversationRef`
- binding record storage
- configured binding compilation
- runtime-created binding storage
- fast binding lookup on inbound
- binding touch/unbind lifecycle
- generic dispatch handoff to the binding target
What core binding capability must not own:
- Discord thread rules
- Telegram topic rules
- Feishu chat rules
- ACP session orchestration
- codex app server business logic
### 2. Core Stateful Target Kernel
This is the small generic kernel for long-lived bound targets.
Responsibilities:
- ensure target ready
- run turn
- cancel turn
- close target
- reset target
- status and health
- persistence of target metadata
- retries and runtime-handle safety
- per-target serialization and concurrency
ACP is the first real implementation of this shape.
This kernel should stay in core because it is mandatory infrastructure and has strict startup, reset, and recovery semantics.
### 3. Channel Binding Providers
Each channel plugin should own the meaning of "this channel conversation maps to this binding rule".
Responsibilities:
- normalize configured binding targets
- normalize inbound conversations
- match inbound conversations against compiled bindings
- define channel-specific matching priority
- optionally provide binding description text for status and logs
This is where Discord channel vs thread logic, Telegram topic rules, and Feishu conversation rules belong.
### 4. Product Consumers
Bindings are a shared capability. Different products should consume it differently.
ACP configured bindings:
- compile config rules
- resolve a target session
- ensure the ACP session is ready through the ACP kernel
Codex app server:
- create runtime-requested bindings
- claim inbound messages through plugin hooks
- optionally adopt the shared stateful target contract later if it really needs long-lived session orchestration
Main harness:
- does not need to become "a binding product"
- may eventually share small lifecycle contracts, but it should not be forced into the same engine as ACP
## The Key Architectural Decision
The shared abstraction should be:
- `bindings` as the capability
- `stateful target drivers` as an optional lower-level contract
The shared abstraction should not be:
- "one runtime engine for main harness, ACP, and codex app server"
That would overfit very different systems into one executor.
## Stable Nouns
Core should understand only stable nouns.
The stable nouns are:
- `ConversationRef`
- `BindingRule`
- `CompiledBinding`
- `BindingResolution`
- `BindingTargetDescriptor`
- `StatefulTargetDriver`
- `StatefulTargetHandle`
ACP, codex app server, and future products should compile down to those nouns instead of leaking product-specific routing rules through core.
## Proposed Capability Model
### Binding capability
The binding capability should support both configured bindings and runtime-created bindings.
Required operations:
- compile configured bindings at startup or reload
- resolve a binding from an inbound `ConversationRef`
- create a runtime binding
- touch and unbind an existing binding
- dispatch a resolved binding to its target
### Binding target descriptor
A resolved binding should point to a typed target descriptor rather than ad hoc ACP- or plugin-specific metadata blobs.
The descriptor should be able to represent at least:
- plugin-owned inbound claim targets
- stateful target drivers
That means the same binding capability can support both:
- codex app server plugin-bound conversations
- ACP configured bindings
without pretending they are the same product.
### Stateful target driver
This is the reusable control-plane contract for long-lived bound targets.
Required operations:
- `ensureReady`
- `runTurn`
- `cancel`
- `close`
- `reset`
- `status`
- `health`
ACP should remain the first built-in driver.
If the codex app server later proves that it also needs durable session handles, it can either:
- use a driver that consumes this contract, or
- keep its own product-owned runtime if that remains simpler
That should be a product decision, not something forced by the binding capability.
## Why ACP Kernel Stays In Core
ACP's kernel should remain in core because session lifecycle, persistence, retries, cancellation, and runtime-handle safety are generic platform machinery.
Those concerns are not channel-specific, and they are not codex-app-server-specific.
If we move that machinery into an ordinary plugin, we create circular bootstrapping:
- channels need it during startup and inbound routing
- reset and recovery need it when plugins may already be degraded
- failure semantics become special-case core logic anyway
If we later wrap it in a "built-in capability module", that is still effectively core.
## What Should Move Out Of Core
The following should move out of ACP-shaped core code:
- channel-specific configured binding matching
- channel-specific binding target normalization
- channel-specific recovery UX
- ACP-specific route wrapping helpers as named ACP seams
- codex app server fallback policy beyond generic plugin-bound dispatch behavior
The following should stay:
- generic binding storage and dispatch
- generic ACP control plane
- generic stateful target driver contract
## Current Problems To Remove
### Residual cleanup is now small
Most ACP-era compatibility names are gone from the generic seam.
The remaining cleanup is smaller:
- `src/acp/persistent-bindings.ts` compatibility barrel can be deleted once tests stop importing it
- ACP-named tests and mocks can be renamed over time for consistency
- docs should stop describing already-removed ACP wrappers as if they still exist
### Configured binding implementation is still too monolithic
`src/channels/plugins/configured-binding-registry.ts` still mixes:
- registry compilation
- cache invalidation
- inbound matching
- materialization of binding targets
- session-key reverse lookup
That file is now generic, but still too large and too coupled.
### Runtime-created plugin bindings still use a separate stack
`src/plugins/conversation-binding.ts` is still a separate implementation path for plugin-created bindings.
That means configured bindings and runtime-created bindings share storage, but not one consistent capability layer.
### Generic registries still hardcode ACP as a built-in
`src/channels/plugins/configured-binding-consumers.ts` and `src/channels/plugins/stateful-target-drivers.ts` still import ACP directly.
That is acceptable for now, but the clean final shape is to keep ACP built in while registering it from a dedicated bootstrap point instead of wiring it inside the generic registry files.
## Target Contracts
### Channel binding provider contract
Conceptually, each channel plugin should support:
- `compileConfiguredBinding(binding, cfg) -> CompiledBinding | null`
- `resolveInboundConversation(event) -> ConversationRef | null`
- `matchInboundConversation(compiledBinding, conversation) -> BindingMatch | null`
- `describeBinding(compiledBinding) -> string | undefined`
### Binding capability contract
Core should support:
- `compileConfiguredBindings(cfg, plugins) -> CompiledBindingRegistry`
- `resolveBinding(conversationRef) -> BindingResolution | null`
- `createRuntimeBinding(target, conversationRef, metadata) -> BindingRecord`
- `touchBinding(bindingId)`
- `unbindBinding(bindingId | target)`
- `dispatchResolvedBinding(bindingResolution, inboundEvent)`
### Stateful target driver contract
Core should support:
- `ensureReady(targetRef, cfg)`
- `runTurn(targetRef, input)`
- `cancel(targetRef, reason)`
- `close(targetRef, reason)`
- `reset(targetRef, reason)`
- `status(targetRef)`
- `health(targetRef)`
## File-Level Transition Plan
### Keep
- `src/infra/outbound/session-binding-service.ts`
- `src/acp/control-plane/*`
- `extensions/acpx/*`
### Generalize
- `src/plugins/conversation-binding.ts`
- fold runtime-created plugin bindings into the same generic binding capability instead of keeping a separate implementation stack
- `src/channels/plugins/configured-binding-registry.ts`
- split into compiler, matcher, and session-key resolution modules with a thin facade
- `src/channels/plugins/types.adapters.ts`
- finish removing ACP-era aliases after the deprecation window
- `src/plugin-sdk/conversation-runtime.ts`
- export only the generic binding capability surfaces
- `src/acp/persistent-bindings.lifecycle.ts`
- either become a generic stateful target driver consumer or be renamed to ACP driver-specific lifecycle code
### Shrink Or Delete
- `src/acp/persistent-bindings.ts`
- delete the compatibility barrel once tests import the real modules directly
- `src/acp/persistent-bindings.resolve.ts`
- keep only while ACP-specific compatibility helpers are still useful to internal callers
- ACP-named test files
- rename over time once the behavior is stable and there is no risk of mixing behavioral and naming churn
## Recommended Refactor Order
### Completed groundwork
The current branch has already completed most of the first migration wave:
- stable generic binding nouns exist
- configured bindings compile through a generic registry
- inbound routing goes through generic binding resolution
- configured binding lookup no longer performs fallback plugin discovery
- ACP is expressed as a configured-binding consumer plus a built-in stateful target driver
The remaining work is cleanup and unification, not first-principles redesign.
### Phase 1: Freeze the nouns
Introduce and document the stable binding and target types:
- `ConversationRef`
- `CompiledBinding`
- `BindingResolution`
- `BindingTargetDescriptor`
- `StatefulTargetDriver`
Do this before more movement so the rest of the refactor has firm vocabulary.
### Phase 2: Promote bindings to a first-class core capability
Refactor the existing generic binding store into an explicit capability layer.
Requirements:
- runtime-created bindings stay supported
- configured bindings become first-class
- lookup becomes channel-agnostic
### Phase 3: Compile configured bindings at startup and reload
Move configured binding compilation off the inbound hot path.
Requirements:
- load enabled channel plugins once
- compile configured bindings once
- rebuild on config or plugin reload
- inbound path becomes pure registry lookup
### Phase 4: Expand the channel provider seam
Replace the ACP-specific adapter shape with a generic channel binding provider contract.
Requirements:
- channel plugins own normalization and matching
- core no longer knows channel-specific configured binding rules
### Phase 5: Re-express ACP as a binding consumer plus built-in stateful target driver
Move ACP configured binding policy to the new binding capability while keeping ACP runtime orchestration in core.
Requirements:
- ACP configured bindings resolve through the generic binding registry
- ACP target readiness uses the ACP driver contract
- ACP-specific naming disappears from generic binding code
### Phase 6: Finish residual ACP cleanup
Remove the last compatibility leftovers and stale naming.
Requirements:
- delete `src/acp/persistent-bindings.ts`
- rename ACP-named tests where that improves clarity without changing behavior
- keep docs synchronized with the actual generic seam instead of the earlier transition state
### Phase 7: Split the configured binding registry by responsibility
Refactor `src/channels/plugins/configured-binding-registry.ts` into smaller modules.
Suggested split:
- compiler module
- inbound matcher module
- session-key reverse lookup module
- thin public facade
Requirements:
- caching behavior remains unchanged
- matching behavior remains unchanged
- session-key resolution behavior remains unchanged
### Phase 8: Keep codex app server on the same binding capability
Do not force the codex app server into ACP semantics.
Requirements:
- codex app server keeps runtime-created bindings through the same binding capability
- inbound claim remains the default delivery path
- only adopt the stateful target driver seam if the app server truly needs long-lived target orchestration
- `src/plugins/conversation-binding.ts` stops being a separate binding stack and becomes a consumer of the generic binding capability
### Phase 9: Decouple built-in ACP registration from generic registry files
Keep ACP built in, but stop importing it directly from the generic registry modules.
Requirements:
- `src/channels/plugins/configured-binding-consumers.ts` no longer hardcodes ACP imports
- `src/channels/plugins/stateful-target-drivers.ts` no longer hardcodes ACP imports
- ACP still registers by default during normal startup
- generic registry files remain product-agnostic
### Phase 10: Remove ACP-shaped compatibility facades
Once all call sites are on the generic capability:
- delete ACP-shaped routing helpers
- delete hot-path plugin bootstrapping logic
- keep only thin compatibility exports if external plugins still need a deprecation window
## Success Criteria
The architecture is done when all of these are true:
- no inbound configured-binding resolution performs plugin discovery
- no channel-specific binding semantics remain in generic core binding code
- ACP still uses a core session kernel
- codex app server and ACP both sit on top of the same binding capability
- the binding capability can represent both configured and runtime-created bindings
- runtime-created plugin bindings do not use a separate implementation stack
- long-lived target orchestration is shared through a small core driver contract
- generic registry files do not import ACP directly
- ACP-era alias names are gone from the generic/plugin SDK surface
- the main harness is not forced into the ACP engine
- external plugins can use the same capability without internal imports
## Non-Goals
These are not goals of the remaining refactor:
- moving the ACP session kernel into an ordinary plugin
- forcing the main harness, ACP, and codex app server into one executor
- making every channel implement its own retry and session-safety logic
- keeping ACP-shaped naming in the long-term generic binding layer
## Bottom Line
The right 20-year split is:
- bindings are the shared core capability
- ACP session orchestration remains a small built-in core kernel
- channel plugins own binding semantics
- backend plugins own runtime protocol details
- product consumers like ACP configured bindings and codex app server build on the same binding capability without being forced into one runtime engine
That is the leanest core that still has honest boundaries.

View File

@@ -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: {

View File

@@ -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();

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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(
{

View File

@@ -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;

View File

@@ -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();

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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,
});
}
}

View File

@@ -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);
});
});

View File

@@ -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(

View File

@@ -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,

View File

@@ -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.",
}),
);
});
});

View File

@@ -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;

View File

@@ -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(

View File

@@ -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
? {

View File

@@ -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,
});
});
});

View File

@@ -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();

View File

@@ -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();
}
},
};

View File

@@ -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");

View File

@@ -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({

View File

@@ -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 =

View File

@@ -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,

View File

@@ -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();

View File

@@ -49,6 +49,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerImageGenerationProvider() {},
registerWebSearchProvider() {},
registerInteractiveHandler() {},
onConversationBindingResolved() {},
registerHook() {},
registerHttpRoute() {},
registerCommand() {},

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(() => {

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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({

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../test/helpers/import-fresh.js";
import type { OpenClawConfig } from "../config/config.js";
const managerMocks = vi.hoisted(() => ({
closeSession: vi.fn(),
initializeSession: vi.fn(),
updateSessionRuntimeOptions: vi.fn(),
}));
const sessionMetaMocks = vi.hoisted(() => ({
readAcpSessionEntry: vi.fn(),
}));
const resolveMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingSpecBySessionKey: vi.fn(),
}));
vi.mock("./control-plane/manager.js", () => ({
getAcpSessionManager: () => ({
closeSession: managerMocks.closeSession,
initializeSession: managerMocks.initializeSession,
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
}),
}));
vi.mock("./runtime/session-meta.js", () => ({
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
}));
vi.mock("./persistent-bindings.resolve.js", () => ({
resolveConfiguredAcpBindingSpecBySessionKey:
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey,
}));
type BindingTargetsModule = typeof import("../channels/plugins/binding-targets.js");
let bindingTargets: BindingTargetsModule;
let bindingTargetsImportScope = 0;
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "codex" }, { id: "claude" }],
},
} satisfies OpenClawConfig;
beforeEach(async () => {
vi.resetModules();
bindingTargetsImportScope += 1;
bindingTargets = await importFreshModule<BindingTargetsModule>(
import.meta.url,
`../channels/plugins/binding-targets.js?scope=${bindingTargetsImportScope}`,
);
managerMocks.closeSession.mockReset().mockResolvedValue({
runtimeClosed: true,
metaCleared: false,
});
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockReset().mockReturnValue(null);
});
describe("resetConfiguredBindingTargetInPlace", () => {
it("does not resolve configured bindings when ACP metadata already exists", async () => {
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
agent: "claude",
mode: "persistent",
backend: "acpx",
runtimeOptions: { cwd: "/home/bob/clawd" },
},
});
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockImplementation(() => {
throw new Error("configured binding resolution should be skipped");
});
const result = await bindingTargets.resetConfiguredBindingTargetInPlace({
cfg: baseCfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: true });
expect(resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey).not.toHaveBeenCalled();
expect(managerMocks.closeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
clearMeta: false,
}),
);
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "claude",
backendId: "acpx",
}),
);
});
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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",
};
}

View File

@@ -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",
}),
);
});
});

View File

@@ -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";

View File

@@ -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,
};
}

View File

@@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: {
},
{
activeSessionKey: sessionKey.toLowerCase(),
allowDropAcpMetaSessionKeys: [sessionKey],
},
);
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.",
);

View File

@@ -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}`,
);

View File

@@ -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",
}),
);
});

48
src/bindings/records.ts Normal file
View File

@@ -0,0 +1,48 @@
import {
getSessionBindingService,
type ConversationRef,
type SessionBindingBindInput,
type SessionBindingCapabilities,
type SessionBindingRecord,
type SessionBindingUnbindInput,
} from "../infra/outbound/session-binding-service.js";
// Shared binding record helpers used by both configured bindings and
// runtime-created plugin conversation bindings.
export async function createConversationBindingRecord(
input: SessionBindingBindInput,
): Promise<SessionBindingRecord> {
return await getSessionBindingService().bind(input);
}
export function getConversationBindingCapabilities(params: {
channel: string;
accountId: string;
}): SessionBindingCapabilities {
return getSessionBindingService().getCapabilities(params);
}
export function listSessionBindingRecords(targetSessionKey: string): SessionBindingRecord[] {
return getSessionBindingService().listBySession(targetSessionKey);
}
export function resolveConversationBindingRecord(
conversation: ConversationRef,
): SessionBindingRecord | null {
return getSessionBindingService().resolveByConversation(conversation);
}
export function touchConversationBindingRecord(bindingId: string, at?: number): void {
const service = getSessionBindingService();
if (typeof at === "number") {
service.touch(bindingId, at);
return;
}
service.touch(bindingId);
}
export async function unbindConversationBindingRecord(
input: SessionBindingUnbindInput,
): Promise<SessionBindingRecord[]> {
return await getSessionBindingService().unbind(input);
}

View File

@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js";
const resolveAgentConfigMock = vi.hoisted(() => vi.fn());
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn());
const getChannelPluginMock = vi.hoisted(() => vi.fn());
const getActivePluginRegistryMock = vi.hoisted(() => vi.fn());
const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn());
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: (...args: unknown[]) => resolveAgentConfigMock(...args),
resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args),
resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args),
}));
vi.mock("./index.js", () => ({
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
}));
vi.mock("../../plugins/runtime.js", () => ({
getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args),
getActivePluginRegistryVersion: (...args: unknown[]) =>
getActivePluginRegistryVersionMock(...args),
}));
async function importConfiguredBindings() {
const builtins = await import("./configured-binding-builtins.js");
builtins.ensureConfiguredBindingBuiltinsRegistered();
return await import("./configured-binding-registry.js");
}
function createConfig(options?: { bindingAgentId?: string; accountId?: string }) {
return {
agents: {
list: [{ id: "main" }, { id: "codex" }],
},
bindings: [
{
type: "acp",
agentId: options?.bindingAgentId ?? "codex",
match: {
channel: "discord",
accountId: options?.accountId ?? "default",
peer: {
kind: "channel",
id: "1479098716916023408",
},
},
acp: {
backend: "acpx",
},
},
],
};
}
function createDiscordAcpPlugin(overrides?: {
compileConfiguredBinding?: ReturnType<typeof vi.fn>;
matchInboundConversation?: ReturnType<typeof vi.fn>;
}) {
const compileConfiguredBinding =
overrides?.compileConfiguredBinding ??
vi.fn(({ conversationId }: { conversationId: string }) => ({
conversationId,
}));
const matchInboundConversation =
overrides?.matchInboundConversation ??
vi.fn(
({
compiledBinding,
conversationId,
parentConversationId,
}: {
compiledBinding: { conversationId: string };
conversationId: string;
parentConversationId?: string;
}) => {
if (compiledBinding.conversationId === conversationId) {
return { conversationId, matchPriority: 2 };
}
if (parentConversationId && compiledBinding.conversationId === parentConversationId) {
return { conversationId: parentConversationId, matchPriority: 1 };
}
return null;
},
);
return {
id: "discord",
bindings: {
compileConfiguredBinding,
matchInboundConversation,
},
};
}
describe("configured binding registry", () => {
beforeEach(() => {
vi.resetModules();
resolveAgentConfigMock.mockReset().mockReturnValue(undefined);
resolveDefaultAgentIdMock.mockReset().mockReturnValue("main");
resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace");
getChannelPluginMock.mockReset();
getActivePluginRegistryMock.mockReset().mockReturnValue({ channels: [] });
getActivePluginRegistryVersionMock.mockReset().mockReturnValue(1);
});
it("resolves configured ACP bindings from an already loaded channel plugin", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
cfg: createConfig() as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(resolved?.record.conversation.channel).toBe("discord");
expect(resolved?.record.metadata?.backend).toBe("acpx");
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
});
it("resolves configured ACP bindings from canonical conversation refs", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBinding({
cfg: createConfig() as never,
conversation: {
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
},
});
expect(resolved?.conversation).toEqual({
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(resolved?.record.conversation.channel).toBe("discord");
expect(resolved?.statefulTarget).toEqual({
kind: "stateful",
driverId: "acp",
sessionKey: resolved?.record.targetSessionKey,
agentId: "codex",
label: undefined,
});
});
it("primes compiled ACP bindings from the already loaded active registry once", async () => {
const plugin = createDiscordAcpPlugin();
const cfg = createConfig({ bindingAgentId: "codex" });
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginRegistryMock.mockReturnValue({
channels: [{ plugin }],
});
const bindingRegistry = await importConfiguredBindings();
const primed = bindingRegistry.primeConfiguredBindingRegistry({
cfg: cfg as never,
});
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(primed).toEqual({ bindingCount: 1, channelCount: 1 });
expect(resolved?.statefulTarget.agentId).toBe("codex");
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
const second = bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(second?.statefulTarget.agentId).toBe("codex");
});
it("resolves wildcard binding session keys from the compiled registry", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({
cfg: createConfig({ accountId: "*" }) as never,
sessionKey: buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "work",
conversationId: "1479098716916023408",
agentId: "codex",
mode: "persistent",
backend: "acpx",
}),
});
expect(resolved?.record.conversation.channel).toBe("discord");
expect(resolved?.record.conversation.accountId).toBe("work");
expect(resolved?.record.metadata?.backend).toBe("acpx");
});
it("does not perform late plugin discovery when a channel plugin is unavailable", async () => {
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
cfg: createConfig() as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(resolved).toBeNull();
});
it("rebuilds the compiled registry when the active plugin registry version changes", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
getActivePluginRegistryVersionMock.mockReturnValue(10);
const cfg = createConfig();
const bindingRegistry = await importConfiguredBindings();
bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
getActivePluginRegistryVersionMock.mockReturnValue(11);
bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,155 @@
import {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
parseConfiguredAcpSessionKey,
toConfiguredAcpBindingRecord,
type ConfiguredAcpBindingSpec,
} from "../../acp/persistent-bindings.types.js";
import {
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/config.js";
import type {
ConfiguredBindingRuleConfig,
ConfiguredBindingTargetFactory,
} from "./binding-types.js";
import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
cwd?: string;
backend?: string;
} {
const agent = params.cfg.agents?.list?.find(
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
);
if (!agent || agent.runtime?.type !== "acp") {
return {};
}
return {
acpAgentId: normalizeText(agent.runtime.acp?.agent),
mode: normalizeText(agent.runtime.acp?.mode),
cwd: normalizeText(agent.runtime.acp?.cwd),
backend: normalizeText(agent.runtime.acp?.backend),
};
}
function resolveConfiguredBindingWorkspaceCwd(params: {
cfg: OpenClawConfig;
agentId: string;
}): string | undefined {
const explicitAgentWorkspace = normalizeText(
resolveAgentConfig(params.cfg, params.agentId)?.workspace,
);
if (explicitAgentWorkspace) {
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
}
if (params.agentId === resolveDefaultAgentId(params.cfg)) {
const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace);
if (defaultWorkspace) {
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
}
}
return undefined;
}
function buildConfiguredAcpSpec(params: {
channel: string;
accountId: string;
conversation: ChannelConfiguredBindingConversationRef;
agentId: string;
acpAgentId?: string;
mode: "persistent" | "oneshot";
cwd?: string;
backend?: string;
label?: string;
}): ConfiguredAcpBindingSpec {
return {
channel: params.channel as ConfiguredAcpBindingSpec["channel"],
accountId: params.accountId,
conversationId: params.conversation.conversationId,
parentConversationId: params.conversation.parentConversationId,
agentId: params.agentId,
acpAgentId: params.acpAgentId,
mode: params.mode,
cwd: params.cwd,
backend: params.backend,
label: params.label,
};
}
function buildAcpTargetFactory(params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
channel: string;
agentId: string;
}): ConfiguredBindingTargetFactory | null {
if (params.binding.type !== "acp") {
return null;
}
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
cfg: params.cfg,
ownerAgentId: params.agentId,
});
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
const cwd =
bindingOverrides.cwd ??
runtimeDefaults.cwd ??
resolveConfiguredBindingWorkspaceCwd({
cfg: params.cfg,
agentId: params.agentId,
});
const backend = bindingOverrides.backend ?? runtimeDefaults.backend;
const label = bindingOverrides.label;
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
return {
driverId: "acp",
materialize: ({ accountId, conversation }) => {
const spec = buildConfiguredAcpSpec({
channel: params.channel,
accountId,
conversation,
agentId: params.agentId,
acpAgentId,
mode,
cwd,
backend,
label,
});
const record = toConfiguredAcpBindingRecord(spec);
return {
record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: buildConfiguredAcpSessionKey(spec),
agentId: params.agentId,
...(label ? { label } : {}),
},
};
},
};
}
export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = {
id: "acp",
supports: (binding) => binding.type === "acp",
buildTargetFactory: (params) =>
buildAcpTargetFactory({
cfg: params.cfg,
binding: params.binding,
channel: params.channel,
agentId: params.agentId,
}),
parseSessionKey: ({ sessionKey }) => parseConfiguredAcpSessionKey(sessionKey),
matchesSessionKey: ({ sessionKey, materializedTarget }) =>
materializedTarget.record.targetSessionKey === sessionKey,
};

View File

@@ -0,0 +1,102 @@
import {
ensureConfiguredAcpBindingReady,
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
} from "../../acp/persistent-bindings.lifecycle.js";
import { resolveConfiguredAcpBindingSpecBySessionKey } from "../../acp/persistent-bindings.resolve.js";
import { resolveConfiguredAcpBindingSpecFromRecord } from "../../acp/persistent-bindings.types.js";
import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js";
import type { OpenClawConfig } from "../../config/config.js";
import type {
ConfiguredBindingResolution,
StatefulBindingTargetDescriptor,
} from "./binding-types.js";
import type {
StatefulBindingTargetDriver,
StatefulBindingTargetResetResult,
StatefulBindingTargetReadyResult,
StatefulBindingTargetSessionResult,
} from "./stateful-target-drivers.js";
function toAcpStatefulBindingTargetDescriptor(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): StatefulBindingTargetDescriptor | null {
const meta = readAcpSessionEntry(params)?.acp;
const metaAgentId = meta?.agent?.trim();
if (metaAgentId) {
return {
kind: "stateful",
driverId: "acp",
sessionKey: params.sessionKey,
agentId: metaAgentId,
};
}
const spec = resolveConfiguredAcpBindingSpecBySessionKey(params);
if (!spec) {
return null;
}
return {
kind: "stateful",
driverId: "acp",
sessionKey: params.sessionKey,
agentId: spec.agentId,
...(spec.label ? { label: spec.label } : {}),
};
}
async function ensureAcpTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<StatefulBindingTargetReadyResult> {
const configuredBinding = resolveConfiguredAcpBindingSpecFromRecord(
params.bindingResolution.record,
);
if (!configuredBinding) {
return {
ok: false,
error: "Configured ACP binding unavailable",
};
}
return await ensureConfiguredAcpBindingReady({
cfg: params.cfg,
configuredBinding: {
spec: configuredBinding,
record: params.bindingResolution.record,
},
});
}
async function ensureAcpTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<StatefulBindingTargetSessionResult> {
const spec = resolveConfiguredAcpBindingSpecFromRecord(params.bindingResolution.record);
if (!spec) {
return {
ok: false,
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
error: "Configured ACP binding unavailable",
};
}
return await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec,
});
}
async function resetAcpTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
reason: "new" | "reset";
}): Promise<StatefulBindingTargetResetResult> {
return await resetAcpSessionInPlace(params);
}
export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = {
id: "acp",
ensureReady: ensureAcpTargetReady,
ensureSession: ensureAcpTargetSession,
resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor,
resetInPlace: resetAcpTargetInPlace,
};

View File

@@ -0,0 +1,14 @@
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
export function resolveChannelConfiguredBindingProvider(
plugin:
| Pick<ChannelPlugin, "bindings">
| {
bindings?: ChannelConfiguredBindingProvider;
}
| null
| undefined,
): ChannelConfiguredBindingProvider | undefined {
return plugin?.bindings;
}

View File

@@ -0,0 +1,46 @@
import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js";
import {
primeConfiguredBindingRegistry as primeConfiguredBindingRegistryRaw,
resolveConfiguredBinding as resolveConfiguredBindingRaw,
resolveConfiguredBindingRecord as resolveConfiguredBindingRecordRaw,
resolveConfiguredBindingRecordBySessionKey as resolveConfiguredBindingRecordBySessionKeyRaw,
resolveConfiguredBindingRecordForConversation as resolveConfiguredBindingRecordForConversationRaw,
} from "./configured-binding-registry.js";
// Thin public wrapper around the configured-binding registry. Runtime plugin
// conversation bindings use a separate approval-driven path in src/plugins/.
export function primeConfiguredBindingRegistry(
...args: Parameters<typeof primeConfiguredBindingRegistryRaw>
): ReturnType<typeof primeConfiguredBindingRegistryRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return primeConfiguredBindingRegistryRaw(...args);
}
export function resolveConfiguredBindingRecord(
...args: Parameters<typeof resolveConfiguredBindingRecordRaw>
): ReturnType<typeof resolveConfiguredBindingRecordRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRecordRaw(...args);
}
export function resolveConfiguredBindingRecordForConversation(
...args: Parameters<typeof resolveConfiguredBindingRecordForConversationRaw>
): ReturnType<typeof resolveConfiguredBindingRecordForConversationRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRecordForConversationRaw(...args);
}
export function resolveConfiguredBinding(
...args: Parameters<typeof resolveConfiguredBindingRaw>
): ReturnType<typeof resolveConfiguredBindingRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRaw(...args);
}
export function resolveConfiguredBindingRecordBySessionKey(
...args: Parameters<typeof resolveConfiguredBindingRecordBySessionKeyRaw>
): ReturnType<typeof resolveConfiguredBindingRecordBySessionKeyRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRecordBySessionKeyRaw(...args);
}

View File

@@ -0,0 +1,91 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
import { deriveLastRoutePolicy } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { resolveConfiguredBinding } from "./binding-registry.js";
import { ensureConfiguredBindingTargetReady } from "./binding-targets.js";
import type { ConfiguredBindingResolution } from "./binding-types.js";
export type ConfiguredBindingRouteResult = {
bindingResolution: ConfiguredBindingResolution | null;
route: ResolvedAgentRoute;
boundSessionKey?: string;
boundAgentId?: string;
};
type ConfiguredBindingRouteConversationInput =
| {
conversation: ConversationRef;
}
| {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
};
function resolveConfiguredBindingConversationRef(
params: ConfiguredBindingRouteConversationInput,
): ConversationRef {
if ("conversation" in params) {
return params.conversation;
}
return {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
};
}
export function resolveConfiguredBindingRoute(
params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
} & ConfiguredBindingRouteConversationInput,
): ConfiguredBindingRouteResult {
const bindingResolution =
resolveConfiguredBinding({
cfg: params.cfg,
conversation: resolveConfiguredBindingConversationRef(params),
}) ?? null;
if (!bindingResolution) {
return {
bindingResolution: null,
route: params.route,
};
}
const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim();
if (!boundSessionKey) {
return {
bindingResolution,
route: params.route,
};
}
const boundAgentId =
resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId;
return {
bindingResolution,
boundSessionKey,
boundAgentId,
route: {
...params.route,
sessionKey: boundSessionKey,
agentId: boundAgentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: params.route.mainSessionKey,
}),
matchedBy: "binding.channel",
},
};
}
export async function ensureConfiguredBindingRouteReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
return await ensureConfiguredBindingTargetReady(params);
}

View File

@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ensureConfiguredBindingTargetReady,
ensureConfiguredBindingTargetSession,
resetConfiguredBindingTargetInPlace,
} from "./binding-targets.js";
import type { ConfiguredBindingResolution } from "./binding-types.js";
import {
registerStatefulBindingTargetDriver,
unregisterStatefulBindingTargetDriver,
type StatefulBindingTargetDriver,
} from "./stateful-target-drivers.js";
function createBindingResolution(driverId: string): ConfiguredBindingResolution {
return {
conversation: {
channel: "discord",
accountId: "default",
conversationId: "123",
},
compiledBinding: {
channel: "discord",
binding: {
type: "acp" as const,
agentId: "codex",
match: {
channel: "discord",
peer: {
kind: "channel" as const,
id: "123",
},
},
acp: {
mode: "persistent",
},
},
bindingConversationId: "123",
target: {
conversationId: "123",
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: "123",
}),
matchInboundConversation: () => ({
conversationId: "123",
}),
},
targetFactory: {
driverId,
materialize: () => ({
record: {
bindingId: "binding:123",
targetSessionKey: `agent:codex:${driverId}`,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "123",
},
status: "active",
boundAt: 0,
},
statefulTarget: {
kind: "stateful",
driverId,
sessionKey: `agent:codex:${driverId}`,
agentId: "codex",
},
}),
},
},
match: {
conversationId: "123",
},
record: {
bindingId: "binding:123",
targetSessionKey: `agent:codex:${driverId}`,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "123",
},
status: "active",
boundAt: 0,
},
statefulTarget: {
kind: "stateful",
driverId,
sessionKey: `agent:codex:${driverId}`,
agentId: "codex",
},
};
}
afterEach(() => {
unregisterStatefulBindingTargetDriver("test-driver");
});
describe("binding target drivers", () => {
it("delegates ensureReady and ensureSession to the resolved driver", async () => {
const ensureReady = vi.fn(async () => ({ ok: true as const }));
const ensureSession = vi.fn(async () => ({
ok: true as const,
sessionKey: "agent:codex:test-driver",
}));
const driver: StatefulBindingTargetDriver = {
id: "test-driver",
ensureReady,
ensureSession,
};
registerStatefulBindingTargetDriver(driver);
const bindingResolution = createBindingResolution("test-driver");
await expect(
ensureConfiguredBindingTargetReady({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({ ok: true });
await expect(
ensureConfiguredBindingTargetSession({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({
ok: true,
sessionKey: "agent:codex:test-driver",
});
expect(ensureReady).toHaveBeenCalledTimes(1);
expect(ensureReady).toHaveBeenCalledWith({
cfg: {} as never,
bindingResolution,
});
expect(ensureSession).toHaveBeenCalledTimes(1);
expect(ensureSession).toHaveBeenCalledWith({
cfg: {} as never,
bindingResolution,
});
});
it("resolves resetInPlace through the driver session-key lookup", async () => {
const resetInPlace = vi.fn(async () => ({ ok: true as const }));
const driver: StatefulBindingTargetDriver = {
id: "test-driver",
ensureReady: async () => ({ ok: true }),
ensureSession: async () => ({
ok: true,
sessionKey: "agent:codex:test-driver",
}),
resolveTargetBySessionKey: ({ sessionKey }) => ({
kind: "stateful",
driverId: "test-driver",
sessionKey,
agentId: "codex",
}),
resetInPlace,
};
registerStatefulBindingTargetDriver(driver);
await expect(
resetConfiguredBindingTargetInPlace({
cfg: {} as never,
sessionKey: "agent:codex:test-driver",
reason: "reset",
}),
).resolves.toEqual({ ok: true });
expect(resetInPlace).toHaveBeenCalledTimes(1);
expect(resetInPlace).toHaveBeenCalledWith({
cfg: {} as never,
sessionKey: "agent:codex:test-driver",
reason: "reset",
bindingTarget: {
kind: "stateful",
driverId: "test-driver",
sessionKey: "agent:codex:test-driver",
agentId: "codex",
},
});
});
it("returns a typed error when no driver is registered", async () => {
const bindingResolution = createBindingResolution("missing-driver");
await expect(
ensureConfiguredBindingTargetReady({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({
ok: false,
error: "Configured binding target driver unavailable: missing-driver",
});
await expect(
ensureConfiguredBindingTargetSession({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({
ok: false,
sessionKey: "agent:codex:missing-driver",
error: "Configured binding target driver unavailable: missing-driver",
});
});
});

View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ConfiguredBindingResolution } from "./binding-types.js";
import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js";
import {
getStatefulBindingTargetDriver,
resolveStatefulBindingTargetBySessionKey,
} from "./stateful-target-drivers.js";
export async function ensureConfiguredBindingTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
ensureStatefulTargetBuiltinsRegistered();
if (!params.bindingResolution) {
return { ok: true };
}
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
if (!driver) {
return {
ok: false,
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
};
}
return await driver.ensureReady({
cfg: params.cfg,
bindingResolution: params.bindingResolution,
});
}
export async function resetConfiguredBindingTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
reason: "new" | "reset";
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
ensureStatefulTargetBuiltinsRegistered();
const resolved = resolveStatefulBindingTargetBySessionKey({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
if (!resolved?.driver.resetInPlace) {
return {
ok: false,
skipped: true,
};
}
return await resolved.driver.resetInPlace({
...params,
bindingTarget: resolved.bindingTarget,
});
}
export async function ensureConfiguredBindingTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
ensureStatefulTargetBuiltinsRegistered();
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
if (!driver) {
return {
ok: false,
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
};
}
return await driver.ensureSession({
cfg: params.cfg,
bindingResolution: params.bindingResolution,
});
}

View File

@@ -0,0 +1,53 @@
import type { AgentBinding } from "../../config/types.js";
import type {
ConversationRef,
SessionBindingRecord,
} from "../../infra/outbound/session-binding-service.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,
} from "./types.adapters.js";
import type { ChannelId } from "./types.js";
export type ConfiguredBindingConversation = ConversationRef;
export type ConfiguredBindingChannel = ChannelId;
export type ConfiguredBindingRuleConfig = AgentBinding;
export type StatefulBindingTargetDescriptor = {
kind: "stateful";
driverId: string;
sessionKey: string;
agentId: string;
label?: string;
};
export type ConfiguredBindingRecordResolution = {
record: SessionBindingRecord;
statefulTarget: StatefulBindingTargetDescriptor;
};
export type ConfiguredBindingTargetFactory = {
driverId: string;
materialize: (params: {
accountId: string;
conversation: ChannelConfiguredBindingConversationRef;
}) => ConfiguredBindingRecordResolution;
};
export type CompiledConfiguredBinding = {
channel: ConfiguredBindingChannel;
accountPattern?: string;
binding: ConfiguredBindingRuleConfig;
bindingConversationId: string;
target: ChannelConfiguredBindingConversationRef;
agentId: string;
provider: ChannelConfiguredBindingProvider;
targetFactory: ConfiguredBindingTargetFactory;
};
export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & {
conversation: ConfiguredBindingConversation;
compiledBinding: CompiledConfiguredBinding;
match: ChannelConfiguredBindingMatch;
};

View File

@@ -0,0 +1,13 @@
import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js";
import {
registerConfiguredBindingConsumer,
unregisterConfiguredBindingConsumer,
} from "./configured-binding-consumers.js";
export function ensureConfiguredBindingBuiltinsRegistered(): void {
registerConfiguredBindingConsumer(acpConfiguredBindingConsumer);
}
export function resetConfiguredBindingBuiltinsForTesting(): void {
unregisterConfiguredBindingConsumer(acpConfiguredBindingConsumer.id);
}

View File

@@ -0,0 +1,240 @@
import { listConfiguredBindings } from "../../config/bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js";
import { pickFirstExistingAgentId } from "../../routing/resolve-route.js";
import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js";
import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js";
import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import { getChannelPlugin } from "./index.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingProvider,
} from "./types.adapters.js";
// Configured bindings are channel-owned rules compiled from config, separate
// from runtime plugin-owned conversation bindings.
type ChannelPluginLike = NonNullable<ReturnType<typeof getChannelPlugin>>;
export type CompiledConfiguredBindingRegistry = {
rulesByChannel: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
};
type CachedCompiledConfiguredBindingRegistry = {
registryVersion: number;
registry: CompiledConfiguredBindingRegistry;
};
const compiledRegistryCache = new WeakMap<
OpenClawConfig,
CachedCompiledConfiguredBindingRegistry
>();
function findChannelPlugin(params: {
registry:
| {
channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null;
}
| null
| undefined;
channel: string;
}): ChannelPluginLike | undefined {
return (
params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ??
undefined
);
}
function resolveLoadedChannelPlugin(channel: string) {
const normalized = channel.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const current = getChannelPlugin(normalized as ConfiguredBindingChannel);
if (current) {
return current;
}
return findChannelPlugin({
registry: getActivePluginRegistry(),
channel: normalized,
});
}
function resolveConfiguredBindingAdapter(channel: string): {
channel: ConfiguredBindingChannel;
provider: ChannelConfiguredBindingProvider;
} | null {
const normalized = channel.trim().toLowerCase();
if (!normalized) {
return null;
}
const plugin = resolveLoadedChannelPlugin(normalized);
const provider = resolveChannelConfiguredBindingProvider(plugin);
if (
!plugin ||
!provider ||
!provider.compileConfiguredBinding ||
!provider.matchInboundConversation
) {
return null;
}
return {
channel: plugin.id,
provider,
};
}
function resolveBindingConversationId(binding: {
match?: { peer?: { id?: string } };
}): string | null {
const id = binding.match?.peer?.id?.trim();
return id ? id : null;
}
function compileConfiguredBindingTarget(params: {
provider: ChannelConfiguredBindingProvider;
binding: CompiledConfiguredBinding["binding"];
conversationId: string;
}): ChannelConfiguredBindingConversationRef | null {
return params.provider.compileConfiguredBinding({
binding: params.binding,
conversationId: params.conversationId,
});
}
function compileConfiguredBindingRule(params: {
cfg: OpenClawConfig;
channel: ConfiguredBindingChannel;
binding: CompiledConfiguredBinding["binding"];
target: ChannelConfiguredBindingConversationRef;
bindingConversationId: string;
provider: ChannelConfiguredBindingProvider;
}): CompiledConfiguredBinding | null {
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
const consumer = resolveConfiguredBindingConsumer(params.binding);
if (!consumer) {
return null;
}
const targetFactory = consumer.buildTargetFactory({
cfg: params.cfg,
binding: params.binding,
channel: params.channel,
agentId,
target: params.target,
bindingConversationId: params.bindingConversationId,
});
if (!targetFactory) {
return null;
}
return {
channel: params.channel,
accountPattern: params.binding.match.accountId?.trim() || undefined,
binding: params.binding,
bindingConversationId: params.bindingConversationId,
target: params.target,
agentId,
provider: params.provider,
targetFactory,
};
}
function pushCompiledRule(
target: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>,
rule: CompiledConfiguredBinding,
) {
const existing = target.get(rule.channel);
if (existing) {
existing.push(rule);
return;
}
target.set(rule.channel, [rule]);
}
function compileConfiguredBindingRegistry(params: {
cfg: OpenClawConfig;
}): CompiledConfiguredBindingRegistry {
const rulesByChannel = new Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>();
for (const binding of listConfiguredBindings(params.cfg)) {
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId) {
continue;
}
const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel);
if (!resolvedChannel) {
continue;
}
const target = compileConfiguredBindingTarget({
provider: resolvedChannel.provider,
binding,
conversationId: bindingConversationId,
});
if (!target) {
continue;
}
const rule = compileConfiguredBindingRule({
cfg: params.cfg,
channel: resolvedChannel.channel,
binding,
target,
bindingConversationId,
provider: resolvedChannel.provider,
});
if (!rule) {
continue;
}
pushCompiledRule(rulesByChannel, rule);
}
return {
rulesByChannel,
};
}
export function resolveCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const registryVersion = getActivePluginRegistryVersion();
const cached = compiledRegistryCache.get(cfg);
if (cached?.registryVersion === registryVersion) {
return cached.registry;
}
const registry = compileConfiguredBindingRegistry({
cfg,
});
compiledRegistryCache.set(cfg, {
registryVersion,
registry,
});
return registry;
}
export function primeCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const registry = compileConfiguredBindingRegistry({ cfg });
compiledRegistryCache.set(cfg, {
registryVersion: getActivePluginRegistryVersion(),
registry,
});
return registry;
}
export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): {
bindingCount: number;
channelCount: number;
} {
return {
bindingCount: [...registry.rulesByChannel.values()].reduce(
(sum, rules) => sum + rules.length,
0,
),
channelCount: registry.rulesByChannel.size,
};
}

View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "../../config/config.js";
import type {
CompiledConfiguredBinding,
ConfiguredBindingRecordResolution,
ConfiguredBindingRuleConfig,
ConfiguredBindingTargetFactory,
} from "./binding-types.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
export type ParsedConfiguredBindingSessionKey = {
channel: string;
accountId: string;
};
export type ConfiguredBindingConsumer = {
id: string;
supports: (binding: ConfiguredBindingRuleConfig) => boolean;
buildTargetFactory: (params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
channel: string;
agentId: string;
target: ChannelConfiguredBindingConversationRef;
bindingConversationId: string;
}) => ConfiguredBindingTargetFactory | null;
parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null;
matchesSessionKey?: (params: {
sessionKey: string;
compiledBinding: CompiledConfiguredBinding;
accountId: string;
materializedTarget: ConfiguredBindingRecordResolution;
}) => boolean;
};
const registeredConfiguredBindingConsumers = new Map<string, ConfiguredBindingConsumer>();
export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] {
return [...registeredConfiguredBindingConsumers.values()];
}
export function resolveConfiguredBindingConsumer(
binding: ConfiguredBindingRuleConfig,
): ConfiguredBindingConsumer | null {
for (const consumer of listConfiguredBindingConsumers()) {
if (consumer.supports(binding)) {
return consumer;
}
}
return null;
}
export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void {
const id = consumer.id.trim();
if (!id) {
throw new Error("Configured binding consumer id is required");
}
const existing = registeredConfiguredBindingConsumers.get(id);
if (existing) {
return;
}
registeredConfiguredBindingConsumers.set(id, {
...consumer,
id,
});
}
export function unregisterConfiguredBindingConsumer(id: string): void {
registeredConfiguredBindingConsumers.delete(id.trim());
}

View File

@@ -0,0 +1,116 @@
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type {
CompiledConfiguredBinding,
ConfiguredBindingChannel,
ConfiguredBindingRecordResolution,
} from "./binding-types.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
} from "./types.adapters.js";
export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
}
if (trimmed === "*") {
return 1;
}
return normalizeAccountId(trimmed) === actual ? 2 : 0;
}
function matchCompiledBindingConversation(params: {
rule: CompiledConfiguredBinding;
conversationId: string;
parentConversationId?: string;
}): ChannelConfiguredBindingMatch | null {
return params.rule.provider.matchInboundConversation({
binding: params.rule.binding,
compiledBinding: params.rule.target,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
}
export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null {
const normalized = raw.trim().toLowerCase();
return normalized ? (normalized as ConfiguredBindingChannel) : null;
}
export function toConfiguredBindingConversationRef(conversation: ConversationRef): {
channel: ConfiguredBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const channel = resolveCompiledBindingChannel(conversation.channel);
const conversationId = conversation.conversationId.trim();
if (!channel || !conversationId) {
return null;
}
return {
channel,
accountId: normalizeAccountId(conversation.accountId),
conversationId,
parentConversationId: conversation.parentConversationId?.trim() || undefined,
};
}
export function materializeConfiguredBindingRecord(params: {
rule: CompiledConfiguredBinding;
accountId: string;
conversation: ChannelConfiguredBindingConversationRef;
}): ConfiguredBindingRecordResolution {
return params.rule.targetFactory.materialize({
accountId: normalizeAccountId(params.accountId),
conversation: params.conversation,
});
}
export function resolveMatchingConfiguredBinding(params: {
rules: CompiledConfiguredBinding[];
conversation: ReturnType<typeof toConfiguredBindingConversationRef>;
}): { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null {
if (!params.conversation) {
return null;
}
let wildcardMatch: {
rule: CompiledConfiguredBinding;
match: ChannelConfiguredBindingMatch;
} | null = null;
let exactMatch: { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null =
null;
for (const rule of params.rules) {
const accountMatchPriority = resolveAccountMatchPriority(
rule.accountPattern,
params.conversation.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const match = matchCompiledBindingConversation({
rule,
conversationId: params.conversation.conversationId,
parentConversationId: params.conversation.parentConversationId,
});
if (!match) {
continue;
}
const matchPriority = match.matchPriority ?? 0;
if (accountMatchPriority === 2) {
if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) {
exactMatch = { rule, match };
}
continue;
}
if (!wildcardMatch || matchPriority > (wildcardMatch.match.matchPriority ?? 0)) {
wildcardMatch = { rule, match };
}
}
return exactMatch ?? wildcardMatch;
}

View File

@@ -0,0 +1,116 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import type {
ConfiguredBindingRecordResolution,
ConfiguredBindingResolution,
} from "./binding-types.js";
import {
countCompiledBindingRegistry,
primeCompiledBindingRegistry,
resolveCompiledBindingRegistry,
} from "./configured-binding-compiler.js";
import {
materializeConfiguredBindingRecord,
resolveMatchingConfiguredBinding,
toConfiguredBindingConversationRef,
} from "./configured-binding-match.js";
import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js";
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
bindingCount: number;
channelCount: number;
} {
return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg));
}
export function resolveConfiguredBindingRecord(params: {
cfg: OpenClawConfig;
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): ConfiguredBindingRecordResolution | null {
const conversation = toConfiguredBindingConversationRef({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (!conversation) {
return null;
}
return resolveConfiguredBindingRecordForConversation({
cfg: params.cfg,
conversation,
});
}
export function resolveConfiguredBindingRecordForConversation(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
}): ConfiguredBindingRecordResolution | null {
const conversation = toConfiguredBindingConversationRef(params.conversation);
if (!conversation) {
return null;
}
const registry = resolveCompiledBindingRegistry(params.cfg);
const rules = registry.rulesByChannel.get(conversation.channel);
if (!rules || rules.length === 0) {
return null;
}
const resolved = resolveMatchingConfiguredBinding({
rules,
conversation,
});
if (!resolved) {
return null;
}
return materializeConfiguredBindingRecord({
rule: resolved.rule,
accountId: conversation.accountId,
conversation: resolved.match,
});
}
export function resolveConfiguredBinding(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
}): ConfiguredBindingResolution | null {
const conversation = toConfiguredBindingConversationRef(params.conversation);
if (!conversation) {
return null;
}
const registry = resolveCompiledBindingRegistry(params.cfg);
const rules = registry.rulesByChannel.get(conversation.channel);
if (!rules || rules.length === 0) {
return null;
}
const resolved = resolveMatchingConfiguredBinding({
rules,
conversation,
});
if (!resolved) {
return null;
}
const materializedTarget = materializeConfiguredBindingRecord({
rule: resolved.rule,
accountId: conversation.accountId,
conversation: resolved.match,
});
return {
conversation,
compiledBinding: resolved.rule,
match: resolved.match,
...materializedTarget,
};
}
export function resolveConfiguredBindingRecordBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): ConfiguredBindingRecordResolution | null {
return resolveConfiguredBindingRecordBySessionKeyFromRegistry({
registry: resolveCompiledBindingRegistry(params.cfg),
sessionKey: params.sessionKey,
});
}

View File

@@ -0,0 +1,74 @@
import type { ConfiguredBindingRecordResolution } from "./binding-types.js";
import type { CompiledConfiguredBindingRegistry } from "./configured-binding-compiler.js";
import { listConfiguredBindingConsumers } from "./configured-binding-consumers.js";
import {
materializeConfiguredBindingRecord,
resolveAccountMatchPriority,
resolveCompiledBindingChannel,
} from "./configured-binding-match.js";
export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: {
registry: CompiledConfiguredBindingRegistry;
sessionKey: string;
}): ConfiguredBindingRecordResolution | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
for (const consumer of listConfiguredBindingConsumers()) {
const parsed = consumer.parseSessionKey?.({ sessionKey });
if (!parsed) {
continue;
}
const channel = resolveCompiledBindingChannel(parsed.channel);
if (!channel) {
continue;
}
const rules = params.registry.rulesByChannel.get(channel);
if (!rules || rules.length === 0) {
continue;
}
let wildcardMatch: ConfiguredBindingRecordResolution | null = null;
let exactMatch: ConfiguredBindingRecordResolution | null = null;
for (const rule of rules) {
if (rule.targetFactory.driverId !== consumer.id) {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
rule.accountPattern,
parsed.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const materializedTarget = materializeConfiguredBindingRecord({
rule,
accountId: parsed.accountId,
conversation: rule.target,
});
const matchesSessionKey =
consumer.matchesSessionKey?.({
sessionKey,
compiledBinding: rule,
accountId: parsed.accountId,
materializedTarget,
}) ?? materializedTarget.record.targetSessionKey === sessionKey;
if (matchesSessionKey) {
if (accountMatchPriority === 2) {
exactMatch = materializedTarget;
break;
}
wildcardMatch = materializedTarget;
}
}
if (exactMatch) {
return exactMatch;
}
if (wildcardMatch) {
return wildcardMatch;
}
}
return null;
}

View File

@@ -0,0 +1,13 @@
import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js";
import {
registerStatefulBindingTargetDriver,
unregisterStatefulBindingTargetDriver,
} from "./stateful-target-drivers.js";
export function ensureStatefulTargetBuiltinsRegistered(): void {
registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver);
}
export function resetStatefulTargetBuiltinsForTesting(): void {
unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id);
}

View File

@@ -0,0 +1,89 @@
import type { OpenClawConfig } from "../../config/config.js";
import type {
ConfiguredBindingResolution,
StatefulBindingTargetDescriptor,
} from "./binding-types.js";
export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string };
export type StatefulBindingTargetSessionResult =
| { ok: true; sessionKey: string }
| { ok: false; sessionKey: string; error: string };
export type StatefulBindingTargetResetResult =
| { ok: true }
| { ok: false; skipped?: boolean; error?: string };
export type StatefulBindingTargetDriver = {
id: string;
ensureReady: (params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}) => Promise<StatefulBindingTargetReadyResult>;
ensureSession: (params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}) => Promise<StatefulBindingTargetSessionResult>;
resolveTargetBySessionKey?: (params: {
cfg: OpenClawConfig;
sessionKey: string;
}) => StatefulBindingTargetDescriptor | null;
resetInPlace?: (params: {
cfg: OpenClawConfig;
sessionKey: string;
bindingTarget: StatefulBindingTargetDescriptor;
reason: "new" | "reset";
}) => Promise<StatefulBindingTargetResetResult>;
};
const registeredStatefulBindingTargetDrivers = new Map<string, StatefulBindingTargetDriver>();
function listStatefulBindingTargetDrivers(): StatefulBindingTargetDriver[] {
return [...registeredStatefulBindingTargetDrivers.values()];
}
export function registerStatefulBindingTargetDriver(driver: StatefulBindingTargetDriver): void {
const id = driver.id.trim();
if (!id) {
throw new Error("Stateful binding target driver id is required");
}
const normalized = { ...driver, id };
const existing = registeredStatefulBindingTargetDrivers.get(id);
if (existing) {
return;
}
registeredStatefulBindingTargetDrivers.set(id, normalized);
}
export function unregisterStatefulBindingTargetDriver(id: string): void {
registeredStatefulBindingTargetDrivers.delete(id.trim());
}
export function getStatefulBindingTargetDriver(id: string): StatefulBindingTargetDriver | null {
const normalizedId = id.trim();
if (!normalizedId) {
return null;
}
return registeredStatefulBindingTargetDrivers.get(normalizedId) ?? null;
}
export function resolveStatefulBindingTargetBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): { driver: StatefulBindingTargetDriver; bindingTarget: StatefulBindingTargetDescriptor } | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
for (const driver of listStatefulBindingTargetDrivers()) {
const bindingTarget = driver.resolveTargetBySessionKey?.({
cfg: params.cfg,
sessionKey,
});
if (bindingTarget) {
return {
driver,
bindingTarget,
};
}
}
return null;
}

View File

@@ -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> = {

View File

@@ -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;

View File

@@ -33,7 +33,9 @@ export type {
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,

View File

@@ -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 () => {

View File

@@ -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).

View File

@@ -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();
});
});

View File

@@ -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)];
}

View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolvePreferredNodePath: vi.fn(),
resolveNodeProgramArguments: vi.fn(),
resolveSystemNodeInfo: vi.fn(),
renderSystemNodeWarning: vi.fn(),
buildNodeServiceEnvironment: vi.fn(),
}));
vi.mock("../daemon/runtime-paths.js", () => ({
resolvePreferredNodePath: mocks.resolvePreferredNodePath,
resolveSystemNodeInfo: mocks.resolveSystemNodeInfo,
renderSystemNodeWarning: mocks.renderSystemNodeWarning,
}));
vi.mock("../daemon/program-args.js", () => ({
resolveNodeProgramArguments: mocks.resolveNodeProgramArguments,
}));
vi.mock("../daemon/service-env.js", () => ({
buildNodeServiceEnvironment: mocks.buildNodeServiceEnvironment,
}));
import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js";
afterEach(() => {
vi.resetAllMocks();
});
describe("buildNodeInstallPlan", () => {
it("passes the selected node bin directory into the node service environment", async () => {
mocks.resolveNodeProgramArguments.mockResolvedValue({
programArguments: ["node", "node-host"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node/bin/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildNodeServiceEnvironment.mockReturnValue({
OPENCLAW_SERVICE_VERSION: "2026.3.14",
});
const plan = await buildNodeInstallPlan({
env: {},
host: "127.0.0.1",
port: 18789,
runtime: "node",
nodePath: "/custom/node/bin/node",
});
expect(plan.environment).toEqual({
OPENCLAW_SERVICE_VERSION: "2026.3.14",
});
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
env: {},
extraPathDirs: ["/custom/node/bin"],
});
});
it("does not prepend '.' when nodePath is a bare executable name", async () => {
mocks.resolveNodeProgramArguments.mockResolvedValue({
programArguments: ["node", "node-host"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/usr/bin/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildNodeServiceEnvironment.mockReturnValue({
OPENCLAW_SERVICE_VERSION: "2026.3.14",
});
await buildNodeInstallPlan({
env: {},
host: "127.0.0.1",
port: 18789,
runtime: "node",
nodePath: "node",
});
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
env: {},
extraPathDirs: undefined,
});
});
});

View File

@@ -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,
});

View File

@@ -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";
}

View File

@@ -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", () => {

View File

@@ -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;
});

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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[] = [

View File

@@ -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) {

View File

@@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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";

View File

@@ -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 });
}
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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.";

View File

@@ -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) => {

View File

@@ -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