diff --git a/CHANGELOG.md b/CHANGELOG.md index 84702324273..c6bd8a223eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/experiments/acp-pluginification-architecture-plan.md b/experiments/acp-pluginification-architecture-plan.md new file mode 100644 index 00000000000..b055c1800ce --- /dev/null +++ b/experiments/acp-pluginification-architecture-plan.md @@ -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. diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 5a19d6f43e8..bd75ee1198d 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -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: { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index d6bfb3a44db..e604b69db7c 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -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(); diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index c0bb5469b29..b834a671906 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -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, }); diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 9b85d53f618..05825b75bc9 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -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, }); diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index ef0492308ae..90b7560c47e 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -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( { diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 2724f467ab1..60b85114bcb 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -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; diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 198a0367b59..5c65b032f34 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -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(); diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index e55ef360424..a528de476af 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -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 { + 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 { + 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)) || ""}`, + ); + 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 || ""}`, + ); + return true; + } + + return false; + } + + private async recoverEnsureFailure(params: { + sessionName: string; + agent: string; + cwd: string; + error: unknown; + }): Promise { + 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 || ""}`, + ); + 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)) || ""}`, + ); + 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 { 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", diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts index ebf5052f450..4ebe57b3e2a 100644 --- a/extensions/acpx/src/test-utils/runtime-fixtures.ts +++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts @@ -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 { 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) { diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 0a4ead6c3fd..5e47dda6334 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -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(); + return { + ...actual, + probeDiscord: probeDiscordMock, + }; +}); + +vi.mock("./monitor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + monitorDiscordProvider: monitorDiscordProviderMock, + }; +}); + +vi.mock("./audit.js", async (importOriginal) => { + const actual = await importOriginal(); + 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; +}): ChannelGatewayContext { + 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(); + }); }); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 8cae9c04323..c4ff4827038 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { } } ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); - return getDiscordRuntime().channel.discord.monitorDiscordProvider({ + return monitorDiscordProvider({ token, accountId: account.accountId, config: ctx.cfg, diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 109135a3684..5acab8d5339 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -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; type DiscordGatewayFetchInit = Record & { @@ -19,6 +20,8 @@ type DiscordGatewayFetch = ( init?: DiscordGatewayFetchInit, ) => Promise; +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 { + const timeoutMs = Math.max(1, params.timeoutMs ?? DISCORD_GATEWAY_INFO_TIMEOUT_MS); + const abortController = new AbortController(); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, 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; + runtime?: RuntimeEnv; }): GatewayPlugin { class SafeGatewayPlugin extends GatewayPlugin { + private gatewayInfoUsedFallback = false; + constructor() { super(params.options); } override async registerClient(client: Parameters[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, }); } } diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 01bac15e856..982b9589b22 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -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(); + 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) { const message = createDiscordMessage({ id: "m-1", @@ -94,13 +169,10 @@ function createBasePreflightParams(overrides?: Record) { 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[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[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(" (1 sticker)"); + expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 2fb14bafe8e..0067de03c4e 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -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( diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 0a402518927..9094cabb645 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -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) + : (base as { referencedMessage?: Message }).referencedMessage; + const rawData = { + ...((base as { rawData?: Record }).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 { + const currentText = resolveDiscordMessageText(params.message, { + includeForwarded: true, + }); + if (currentText) { + return params.message; + } + const rest = params.client.rest as { get?: (route: string) => Promise } | 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 { @@ -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, diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 1009c583a81..2b49292b037 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -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((params) => ({ - configuredBinding: null, + resolveConfiguredAcpBindingRecord: vi.fn((params) => ({ + bindingResolution: null, route: params.route, })), - ensureConfiguredAcpBindingSession: vi.fn(async () => ({ + ensureConfiguredAcpBindingSession: vi.fn(async () => ({ ok: true, })), })); @@ -30,8 +31,8 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); 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[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; + 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 }).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.", + }), + ); + }); }); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index ed50aff52a3..1876acbde0a 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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; diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index f03dce881c2..9de21e92d0d 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -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( diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index 0d5fbd66b25..b2a9e8a6019 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -15,6 +15,37 @@ type ExecApprovalsHandler = { stop: () => Promise; }; +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; + abortSignal?: AbortSignal; + timeoutMs: number; + beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop"; +}): Promise { + 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((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 ? { diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 72da5136c7a..f8e9f52c198 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -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, + }); + }); }); diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index ed221645fcf..237cc6b8081 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -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(); - 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(); diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index f6d5f7d3d90..5c37ac4bbf0 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -69,6 +69,8 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) { } } +const SWEEPERS_BY_ACCOUNT_ID = new Map Promise>(); + 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["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(); + } + }, }; diff --git a/extensions/discord/src/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts index f2109150c66..a5cca87119c 100644 --- a/extensions/discord/src/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -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(); + return { + ...actual, + updateSessionStore: hoisted.updateSessionStore, + resolveStorePath: hoisted.resolveStorePath, + }; +}); const { closeDiscordThreadSessions } = await import("./thread-session-close.js"); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 910fa03f28c..0995632e3a1 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -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(); + const actual = await importOriginal(); 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({ diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 6181d32f4af..bc47d6d934f 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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 = diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 52d2e04aa1a..6111eeabffa 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -822,11 +822,15 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, - 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, diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index ec1ebdc5b77..f394aec8b3e 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -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) { 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) { 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> = 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(); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index b154e067116..62c0fed6d81 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -49,6 +49,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerImageGenerationProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, + onConversationBindingResolved() {}, registerHook() {}, registerHttpRoute() {}, registerCommand() {}, diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 1f9adb41a72..44aa89a7623 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -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(); + 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); }); }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index b569b1aeb1e..78ba9f02492 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -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; }; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 0a75b12fc1a..7540f22b1ac 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -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(() => null), - ensureConfiguredAcpBindingSession: vi.fn(async () => ({ + resolveConfiguredBindingRoute: vi.fn(({ route }) => ({ + bindingResolution: null, + route, + })), + ensureConfiguredBindingRouteReady: vi.fn(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(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); 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(); + 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(); + 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(); - 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 | 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) { 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 { 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", diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index c496c1b97f6..0e513131133 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -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", diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 55fec660a82..54dcf963997 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -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(); +vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { + const actual = await importOriginal(); 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(); + 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(() => { diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index bac2de59f0b..6c1f4da5e73 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -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(); + return { + ...actual, + probeTelegram: probeTelegramMock, + }; +}); + +vi.mock("./audit.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + collectTelegramUnmentionedGroupIds: collectTelegramUnmentionedGroupIdsMock, + auditTelegramGroupMembership: auditTelegramGroupMembershipMock, + }; +}); + +vi.mock("./monitor.js", async (importOriginal) => { + const actual = await importOriginal(); + 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(); }); }); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 6dfe12870a2..0e2ce964b95 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -333,11 +333,15 @@ export const telegramPlugin: ChannelPlugin + 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, diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 26c3b039312..5d777763cde 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -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; - configuredBinding: ReturnType["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; diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index f313141dab0..9b82310ef04 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -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"); } diff --git a/extensions/telegram/src/target-writeback.test.ts b/extensions/telegram/src/target-writeback.test.ts index bb8b2129924..8403f7e1b0f 100644 --- a/extensions/telegram/src/target-writeback.test.ts +++ b/extensions/telegram/src/target-writeback.test.ts @@ -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(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, readConfigFileSnapshotForWrite, writeConfigFile, - }; -}); - -vi.mock("../../../src/cron/store.js", async (importOriginal) => { - const actual = await importOriginal(); - 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(); diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index b15aa3bd72e..58f74b72918 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -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 { + 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 { const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg); if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) { diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 66faa84b1d3..7229e34914d 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -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({ diff --git a/src/acp/persistent-bindings.lifecycle.test.ts b/src/acp/persistent-bindings.lifecycle.test.ts new file mode 100644 index 00000000000..44e159d887f --- /dev/null +++ b/src/acp/persistent-bindings.lifecycle.test.ts @@ -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( + 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", + }), + ); + }); +}); diff --git a/src/acp/persistent-bindings.lifecycle.ts b/src/acp/persistent-bindings.lifecycle.ts index 2a2cf6b9c20..9f43b584da3 100644 --- a/src/acp/persistent-bindings.lifecycle.ts +++ b/src/acp/persistent-bindings.lifecycle.ts @@ -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, diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index d0039078378..068b89f8891 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -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; } diff --git a/src/acp/persistent-bindings.route.ts b/src/acp/persistent-bindings.route.ts deleted file mode 100644 index d11d46d423d..00000000000 --- a/src/acp/persistent-bindings.route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ResolvedAgentRoute } from "../routing/resolve-route.js"; -import { deriveLastRoutePolicy } from "../routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { - ensureConfiguredAcpBindingSession, - resolveConfiguredAcpBindingRecord, - type ConfiguredAcpBindingChannel, - type ResolvedConfiguredAcpBinding, -} from "./persistent-bindings.js"; - -export function resolveConfiguredAcpRoute(params: { - cfg: OpenClawConfig; - route: ResolvedAgentRoute; - channel: ConfiguredAcpBindingChannel; - accountId: string; - conversationId: string; - parentConversationId?: string; -}): { - configuredBinding: ResolvedConfiguredAcpBinding | null; - route: ResolvedAgentRoute; - boundSessionKey?: string; - boundAgentId?: string; -} { - const configuredBinding = resolveConfiguredAcpBindingRecord({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - conversationId: params.conversationId, - parentConversationId: params.parentConversationId, - }); - if (!configuredBinding) { - return { - configuredBinding: null, - route: params.route, - }; - } - const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? ""; - if (!boundSessionKey) { - return { - configuredBinding, - route: params.route, - }; - } - const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId; - return { - configuredBinding, - boundSessionKey, - boundAgentId, - route: { - ...params.route, - sessionKey: boundSessionKey, - agentId: boundAgentId, - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: params.route.mainSessionKey, - }), - matchedBy: "binding.channel", - }, - }; -} - -export async function ensureConfiguredAcpRouteReady(params: { - cfg: OpenClawConfig; - configuredBinding: ResolvedConfiguredAcpBinding | null; -}): Promise<{ ok: true } | { ok: false; error: string }> { - if (!params.configuredBinding) { - return { ok: true }; - } - const ensured = await ensureConfiguredAcpBindingSession({ - cfg: params.cfg, - spec: params.configuredBinding.spec, - }); - if (ensured.ok) { - return { ok: true }; - } - return { - ok: false, - error: ensured.error ?? "unknown error", - }; -} diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index cb815b9d948..27b0e59733c 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -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[number]; -type BindingRecordInput = Parameters[0]; -type BindingSpec = Parameters[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 = {}) { - 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 = {}): 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( + import.meta.url, + `./persistent-bindings.resolve.js?scope=${persistentBindingsImportScope}`, + ), + importFreshModule( + 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", + }), + ); + }); }); diff --git a/src/acp/persistent-bindings.ts b/src/acp/persistent-bindings.ts deleted file mode 100644 index d5b1f4ce729..00000000000 --- a/src/acp/persistent-bindings.ts +++ /dev/null @@ -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"; diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 3583fc4cd9f..3b5a0335a59 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -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, + }; +} diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index ff48d1e1ce6..fc94a1f0c05 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: { }, { activeSessionKey: sessionKey.toLowerCase(), + allowDropAcpMetaSessionKeys: [sessionKey], }, ); } diff --git a/src/auto-reply/reply/acp-reset-target.ts b/src/auto-reply/reply/acp-reset-target.ts index cf8952cdc4a..b77d0f320cc 100644 --- a/src/auto-reply/reply/acp-reset-target.ts +++ b/src/auto-reply/reply/acp-reset-target.ts @@ -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, diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index ed3e61e58bb..c3425161773 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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 ({ 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[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 { - 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.", ); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 18a7eb7802d..34950c20950 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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}`, ); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 98fd1144f77..515d71726fb 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -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", + }), ); }); diff --git a/src/bindings/records.ts b/src/bindings/records.ts new file mode 100644 index 00000000000..d4c1909e023 --- /dev/null +++ b/src/bindings/records.ts @@ -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 { + 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 { + return await getSessionBindingService().unbind(input); +} diff --git a/src/channels/plugins/acp-bindings.test.ts b/src/channels/plugins/acp-bindings.test.ts new file mode 100644 index 00000000000..7d380c665a3 --- /dev/null +++ b/src/channels/plugins/acp-bindings.test.ts @@ -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; + matchInboundConversation?: ReturnType; +}) { + 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); + }); +}); diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts new file mode 100644 index 00000000000..d453726b357 --- /dev/null +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -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, +}; diff --git a/src/channels/plugins/acp-stateful-target-driver.ts b/src/channels/plugins/acp-stateful-target-driver.ts new file mode 100644 index 00000000000..787013fc5b0 --- /dev/null +++ b/src/channels/plugins/acp-stateful-target-driver.ts @@ -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 { + 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 { + 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 { + return await resetAcpSessionInPlace(params); +} + +export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = { + id: "acp", + ensureReady: ensureAcpTargetReady, + ensureSession: ensureAcpTargetSession, + resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor, + resetInPlace: resetAcpTargetInPlace, +}; diff --git a/src/channels/plugins/binding-provider.ts b/src/channels/plugins/binding-provider.ts new file mode 100644 index 00000000000..27dc5c49951 --- /dev/null +++ b/src/channels/plugins/binding-provider.ts @@ -0,0 +1,14 @@ +import type { ChannelConfiguredBindingProvider } from "./types.adapters.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +export function resolveChannelConfiguredBindingProvider( + plugin: + | Pick + | { + bindings?: ChannelConfiguredBindingProvider; + } + | null + | undefined, +): ChannelConfiguredBindingProvider | undefined { + return plugin?.bindings; +} diff --git a/src/channels/plugins/binding-registry.ts b/src/channels/plugins/binding-registry.ts new file mode 100644 index 00000000000..f4e95c19eba --- /dev/null +++ b/src/channels/plugins/binding-registry.ts @@ -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 +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return primeConfiguredBindingRegistryRaw(...args); +} + +export function resolveConfiguredBindingRecord( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordRaw(...args); +} + +export function resolveConfiguredBindingRecordForConversation( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordForConversationRaw(...args); +} + +export function resolveConfiguredBinding( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRaw(...args); +} + +export function resolveConfiguredBindingRecordBySessionKey( + ...args: Parameters +): ReturnType { + ensureConfiguredBindingBuiltinsRegistered(); + return resolveConfiguredBindingRecordBySessionKeyRaw(...args); +} diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts new file mode 100644 index 00000000000..6fe8b0c400b --- /dev/null +++ b/src/channels/plugins/binding-routing.ts @@ -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); +} diff --git a/src/channels/plugins/binding-targets.test.ts b/src/channels/plugins/binding-targets.test.ts new file mode 100644 index 00000000000..98503052b3f --- /dev/null +++ b/src/channels/plugins/binding-targets.test.ts @@ -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", + }); + }); +}); diff --git a/src/channels/plugins/binding-targets.ts b/src/channels/plugins/binding-targets.ts new file mode 100644 index 00000000000..2ca8fefea22 --- /dev/null +++ b/src/channels/plugins/binding-targets.ts @@ -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, + }); +} diff --git a/src/channels/plugins/binding-types.ts b/src/channels/plugins/binding-types.ts new file mode 100644 index 00000000000..81ca368bc2b --- /dev/null +++ b/src/channels/plugins/binding-types.ts @@ -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; +}; diff --git a/src/channels/plugins/configured-binding-builtins.ts b/src/channels/plugins/configured-binding-builtins.ts new file mode 100644 index 00000000000..2d27e9b5286 --- /dev/null +++ b/src/channels/plugins/configured-binding-builtins.ts @@ -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); +} diff --git a/src/channels/plugins/configured-binding-compiler.ts b/src/channels/plugins/configured-binding-compiler.ts new file mode 100644 index 00000000000..ca5a88022d1 --- /dev/null +++ b/src/channels/plugins/configured-binding-compiler.ts @@ -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>; + +export type CompiledConfiguredBindingRegistry = { + rulesByChannel: Map; +}; + +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, + 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(); + + 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, + }; +} diff --git a/src/channels/plugins/configured-binding-consumers.ts b/src/channels/plugins/configured-binding-consumers.ts new file mode 100644 index 00000000000..dbe5dc8791c --- /dev/null +++ b/src/channels/plugins/configured-binding-consumers.ts @@ -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(); + +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()); +} diff --git a/src/channels/plugins/configured-binding-match.ts b/src/channels/plugins/configured-binding-match.ts new file mode 100644 index 00000000000..7e9ec4f4b09 --- /dev/null +++ b/src/channels/plugins/configured-binding-match.ts @@ -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; +}): { 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; +} diff --git a/src/channels/plugins/configured-binding-registry.ts b/src/channels/plugins/configured-binding-registry.ts new file mode 100644 index 00000000000..6a7aba3bdfb --- /dev/null +++ b/src/channels/plugins/configured-binding-registry.ts @@ -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, + }); +} diff --git a/src/channels/plugins/configured-binding-session-lookup.ts b/src/channels/plugins/configured-binding-session-lookup.ts new file mode 100644 index 00000000000..e4baa4057d8 --- /dev/null +++ b/src/channels/plugins/configured-binding-session-lookup.ts @@ -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; +} diff --git a/src/channels/plugins/stateful-target-builtins.ts b/src/channels/plugins/stateful-target-builtins.ts new file mode 100644 index 00000000000..0d87ca31d2d --- /dev/null +++ b/src/channels/plugins/stateful-target-builtins.ts @@ -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); +} diff --git a/src/channels/plugins/stateful-target-drivers.ts b/src/channels/plugins/stateful-target-drivers.ts new file mode 100644 index 00000000000..ede52472c57 --- /dev/null +++ b/src/channels/plugins/stateful-target-drivers.ts @@ -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; + ensureSession: (params: { + cfg: OpenClawConfig; + bindingResolution: ConfiguredBindingResolution; + }) => Promise; + resolveTargetBySessionKey?: (params: { + cfg: OpenClawConfig; + sessionKey: string; + }) => StatefulBindingTargetDescriptor | null; + resetInPlace?: (params: { + cfg: OpenClawConfig; + sessionKey: string; + bindingTarget: StatefulBindingTargetDescriptor; + reason: "new" | "reset"; + }) => Promise; +}; + +const registeredStatefulBindingTargetDrivers = new Map(); + +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; +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index eff6878e85e..c31d6057223 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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 = { diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 6798545d22f..b4405a063de 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -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 { 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 () => { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 91248cb86a7..fcd4a6447fb 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -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). diff --git a/src/commands/daemon-install-plan.shared.test.ts b/src/commands/daemon-install-plan.shared.test.ts index 399b521a5d5..8d7a3520eaf 100644 --- a/src/commands/daemon-install-plan.shared.test.ts +++ b/src/commands/daemon-install-plan.shared.test.ts @@ -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(); + }); +}); diff --git a/src/commands/daemon-install-plan.shared.ts b/src/commands/daemon-install-plan.shared.ts index b3a970d05f4..cb2f701e632 100644 --- a/src/commands/daemon-install-plan.shared.ts +++ b/src/commands/daemon-install-plan.shared.ts @@ -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)]; +} diff --git a/src/commands/node-daemon-install-helpers.test.ts b/src/commands/node-daemon-install-helpers.test.ts new file mode 100644 index 00000000000..536bea1d014 --- /dev/null +++ b/src/commands/node-daemon-install-helpers.test.ts @@ -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, + }); + }); +}); diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 2f86d1c3b5e..321dff5a664 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -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, }); diff --git a/src/config/bindings.ts b/src/config/bindings.ts index b035fa3be15..5cbcd19c552 100644 --- a/src/config/bindings.ts +++ b/src/config/bindings.ts @@ -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"; } diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 2773b6d0fe7..eedf63913eb 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -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", () => { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index a70285c4c62..3936086beb8 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -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; /** Optional callback with maintenance stats after a save. */ @@ -337,6 +343,64 @@ function updateSessionStoreWriteCaches(params: { }); } +function resolveMutableSessionStoreKey( + store: Record, + 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, +): Map> { + const snapshot = new Map>(); + for (const [sessionKey, entry] of Object.entries(store)) { + if (entry?.acp) { + snapshot.set(sessionKey, entry.acp); + } + } + return snapshot; +} + +function preserveExistingAcpMetadata(params: { + previousAcpByKey: Map>; + nextStore: Record; + 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, @@ -526,7 +590,13 @@ export async function updateSessionStore( 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; }); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index e5d60fdfc96..f8297a28554 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -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", () => { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index fb6fff41839..cb26c210efb 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: { port: number; launchdLabel?: string; platform?: NodeJS.Platform; + extraPathDirs?: string[]; }): Record { - 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; platform?: NodeJS.Platform; + extraPathDirs?: string[]; }): Record { - 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, 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, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7887d43f24f..1ad6bf858ef 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -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; }; @@ -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[] = [ diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 2ea249b28b4..a997c93cbbc 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -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) { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 36d24537a14..7b8f5cd5f6c 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 77380f6aa9a..66b7e3b938f 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -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, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index a249fde385d..25b5b71580e 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -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, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index ee15823738b..0ca6fe0a38b 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -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"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 20f685cdbd2..07d4dde6d98 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -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 }); } }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 16720cf8961..a683f5437ca 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -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, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 672bde385c5..c7961f91398 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -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, diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 20b0df72337..d3b88697a59 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -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", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 4b5cb0671da..283e6c3d71f 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -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; } | { 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 { 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 { - 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 { + 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 ?? ""}: ${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."; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 4c863c3bdf4..3e89c8462b5 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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; + 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, + ) => { + 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) => { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6deb59669f1..a96913360be 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; /** * Register a custom command that bypasses the LLM agent. * Plugin commands are processed before built-in commands and before agent invocation. diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 6ecf718f895..d2ebbc45933 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl cliRegistrars: [], services: [], commands: [], + conversationBindingResolvedHandlers: [], diagnostics: [], }); diff --git a/test/helpers/extensions/plugin-api.ts b/test/helpers/extensions/plugin-api.ts index bb94c326ee8..ee1e97178a8 100644 --- a/test/helpers/extensions/plugin-api.ts +++ b/test/helpers/extensions/plugin-api.ts @@ -20,6 +20,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerImageGenerationProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, + onConversationBindingResolved() {}, registerCommand() {}, registerContextEngine() {}, resolvePath(input: string) {