mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 10:21:45 +08:00
Compare commits
2 Commits
main
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03578e7a7b | ||
|
|
426c137e36 |
@@ -81,7 +81,7 @@ Automatic fast mode starts short conversations quickly, then returns longer or f
|
||||
|
||||
- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695).
|
||||
|
||||
### Plugins and Packaging
|
||||
### Plugins, Packaging, and QA
|
||||
|
||||
#### Codex service-tier clearing
|
||||
|
||||
@@ -96,6 +96,7 @@ Automatic fast mode starts short conversations quickly, then returns longer or f
|
||||
#### Doctor check ordering
|
||||
|
||||
- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo.
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -7187,20 +7187,17 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let limit: Int?
|
||||
public let offset: Int?
|
||||
public let maxchars: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
limit: Int?,
|
||||
offset: Int? = nil,
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
self.maxchars = maxchars
|
||||
}
|
||||
|
||||
@@ -7208,7 +7205,6 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case limit
|
||||
case offset
|
||||
case maxchars = "maxChars"
|
||||
}
|
||||
}
|
||||
|
||||
11
docs/ci.md
11
docs/ci.md
@@ -651,16 +651,7 @@ pnpm crabbox:run -- --provider blacksmith-testbox \
|
||||
"corepack pnpm test"
|
||||
```
|
||||
|
||||
Read the final JSON summary. The useful fields are `provider`, `leaseId`,
|
||||
`syncDelegated`, `exitCode`, `commandMs`, and `totalMs`. For delegated
|
||||
Blacksmith Testbox runs, the Crabbox wrapper exit code and JSON summary are the
|
||||
command result. The linked GitHub Actions run owns hydration and keepalive; it
|
||||
can finish as `cancelled` when the Testbox is stopped externally after the SSH
|
||||
command has already returned. Treat that as a cleanup/status artifact unless
|
||||
the wrapper `exitCode` is non-zero or the command output shows a failed test.
|
||||
One-shot Blacksmith-backed Crabbox runs should stop the Testbox automatically;
|
||||
if a run is interrupted or cleanup is unclear, inspect live boxes and stop only
|
||||
the boxes you created:
|
||||
Read the final JSON summary. The useful fields are `provider`, `leaseId`, `syncDelegated`, `exitCode`, `commandMs`, and `totalMs`. One-shot Blacksmith-backed Crabbox runs should stop the Testbox automatically; if a run is interrupted or cleanup is unclear, inspect live boxes and stop only the boxes you created:
|
||||
|
||||
```bash
|
||||
blacksmith testbox list --all
|
||||
|
||||
@@ -297,8 +297,7 @@ tool-call XML payloads (including `<tool_call>...</tool_call>`,
|
||||
downgraded tool-call scaffolding / leaked ASCII/full-width model control
|
||||
tokens / malformed MiniMax tool-call XML from assistant recall, and can
|
||||
replace oversized rows with `[sessions_history omitted: message too large]`
|
||||
instead of returning a raw transcript dump. Use `nextOffset` when present to
|
||||
page backward through older transcript windows.
|
||||
instead of returning a raw transcript dump.
|
||||
|
||||
## Scaling pattern
|
||||
|
||||
|
||||
@@ -58,11 +58,6 @@ results may be scope-limited.
|
||||
|
||||
`sessions_history` fetches the conversation transcript for a specific session.
|
||||
By default, tool results are excluded -- pass `includeTools: true` to see them.
|
||||
Use `limit` for the newest bounded tail. Pass `offset: 0` when you need
|
||||
pagination metadata, then pass returned `nextOffset` values to page backward
|
||||
through older OpenClaw transcript windows without reading raw transcript files.
|
||||
Explicit offset pages do not merge external CLI fallback imports; use the
|
||||
default newest-tail view when you need that merged display history.
|
||||
The returned view is intentionally bounded and safety-filtered:
|
||||
|
||||
- assistant text is normalized before recall:
|
||||
@@ -83,7 +78,7 @@ The returned view is intentionally bounded and safety-filtered:
|
||||
- very large histories can drop older rows or replace an oversized row with
|
||||
`[sessions_history omitted: message too large]`
|
||||
- the tool reports summary flags such as `truncated`, `droppedMessages`,
|
||||
`contentTruncated`, `contentRedacted`, `bytes`, and pagination metadata
|
||||
`contentTruncated`, `contentRedacted`, and `bytes`
|
||||
|
||||
Both tools accept either a **session key** (like `"main"`) or a **session ID**
|
||||
from a previous list call.
|
||||
|
||||
@@ -57,34 +57,6 @@ Logging:
|
||||
The macOS app checks the gateway version against its own version. If they're
|
||||
incompatible, update the global CLI to match the app version.
|
||||
|
||||
## State directory on macOS
|
||||
|
||||
Keep OpenClaw state on a local, non-synced disk. Avoid iCloud Drive and other
|
||||
cloud-synced folders because sync latency and file locks can affect sessions,
|
||||
credentials, and Gateway state.
|
||||
|
||||
Set `OPENCLAW_STATE_DIR` to a local path only when you need an override.
|
||||
`openclaw doctor` warns about common cloud-synced state paths and recommends
|
||||
moving back to local storage. See
|
||||
[environment variables](/help/environment#path-related-env-vars) and
|
||||
[Doctor](/gateway/doctor).
|
||||
|
||||
## Debug app connectivity
|
||||
|
||||
Use the macOS debug CLI from a source checkout to exercise the same Gateway
|
||||
WebSocket handshake and discovery logic the app uses:
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run openclaw-mac connect --json
|
||||
swift run openclaw-mac discover --timeout 3000 --json
|
||||
```
|
||||
|
||||
`connect` accepts `--url`, `--token`, `--timeout`, and `--json`. `discover`
|
||||
accepts `--timeout`, `--json`, and `--include-local`. Compare discovery output
|
||||
with `openclaw gateway discover --json` when you need to separate CLI discovery
|
||||
from app-side connection issues.
|
||||
|
||||
## Smoke check
|
||||
|
||||
```bash
|
||||
|
||||
@@ -114,18 +114,7 @@ Example (in JS):
|
||||
window.location.href = "openclaw://agent?message=Review%20this%20design";
|
||||
```
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
- `message`: prefilled agent prompt.
|
||||
- `sessionKey`: stable session identifier.
|
||||
- `thinking`: optional thinking profile.
|
||||
- `deliver`, `to`, or `channel`: delivery target.
|
||||
- `timeoutSeconds`: optional run timeout.
|
||||
- `key`: app-generated safety token for trusted local callers.
|
||||
|
||||
The app prompts for confirmation unless a valid key is provided. Unkeyed links
|
||||
show the message and URL before approval, and ignore delivery routing fields;
|
||||
keyed links use the normal Gateway run path.
|
||||
The app prompts for confirmation unless a valid key is provided.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as
|
||||
`gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local
|
||||
tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and
|
||||
the local node-host service all use the same safe loopback transport.
|
||||
When discovery returns both raw Tailnet IPs and stable hostnames, the app
|
||||
prefers Tailscale MagicDNS or LAN names so remote connections survive address
|
||||
changes better.
|
||||
If the local tunnel port differs from the remote gateway port, set
|
||||
`gateway.remote.remotePort` to the port on the remote host.
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@ title: "macOS IPC"
|
||||
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
- Common Mac node commands include `canvas.*`, `camera.snap`, `camera.clip`,
|
||||
`screen.snapshot`, `screen.record`, `system.run`, and `system.notify`.
|
||||
- The node reports a `permissions` map so agents can see whether screen,
|
||||
camera, microphone, speech, automation, or accessibility access is available.
|
||||
|
||||
### Node service + app IPC
|
||||
|
||||
|
||||
@@ -1,87 +1,228 @@
|
||||
---
|
||||
summary: "Install and use the OpenClaw macOS menu bar app"
|
||||
summary: "OpenClaw macOS companion app (menu bar + gateway broker)"
|
||||
read_when:
|
||||
- Installing the macOS app
|
||||
- Deciding between local and remote Gateway mode on macOS
|
||||
- Looking for macOS app release downloads
|
||||
- Implementing macOS app features
|
||||
- Changing gateway lifecycle or node bridging on macOS
|
||||
title: "macOS app"
|
||||
---
|
||||
|
||||
The macOS app is the OpenClaw **menu bar companion**. Use it when you want a
|
||||
native tray UI, macOS permission prompts, notifications, WebChat, voice input,
|
||||
Canvas, or Mac-hosted node tools such as `system.run`.
|
||||
The macOS app is the **menu-bar companion** for OpenClaw. It owns permissions,
|
||||
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
||||
capabilities to the agent as a node.
|
||||
|
||||
If you only need the CLI and Gateway, start with [Getting started](/start/getting-started).
|
||||
## What it does
|
||||
|
||||
## Download
|
||||
- Shows native notifications and status in the menu bar.
|
||||
- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Microphone,
|
||||
Speech Recognition, Automation/AppleScript).
|
||||
- Runs or connects to the Gateway (local or remote).
|
||||
- Exposes macOS-only tools (Canvas, Camera, Screen Recording, `system.run`).
|
||||
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
|
||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||
- Installs the global CLI (`openclaw`) on request via npm, pnpm, or bun (the app prefers npm, then pnpm, then bun; Node remains the recommended Gateway runtime).
|
||||
|
||||
Download macOS app builds from the
|
||||
[OpenClaw GitHub releases](https://github.com/openclaw/openclaw/releases).
|
||||
When a release includes macOS app assets, look for:
|
||||
## Local vs remote mode
|
||||
|
||||
- `OpenClaw-<version>.dmg` (preferred)
|
||||
- `OpenClaw-<version>.zip`
|
||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||
otherwise it enables the launchd service via `openclaw gateway install`.
|
||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||
a local process.
|
||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
||||
The app does not spawn the Gateway as a child process.
|
||||
Gateway discovery now prefers Tailscale MagicDNS names over raw tailnet IPs,
|
||||
so the Mac app recovers more reliably when tailnet IPs change.
|
||||
|
||||
Some releases only include CLI, evidence, or Windows assets. If the newest
|
||||
release has no macOS app asset, use the newest release that does, or build the
|
||||
app from source with [macOS dev setup](/platforms/mac/dev-setup).
|
||||
## Launchd control
|
||||
|
||||
## First run
|
||||
The app manages a per-user LaunchAgent labeled `ai.openclaw.gateway`
|
||||
(or `ai.openclaw.<profile>` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads).
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$UID/ai.openclaw.gateway
|
||||
launchctl bootout gui/$UID/ai.openclaw.gateway
|
||||
```
|
||||
|
||||
Replace the label with `ai.openclaw.<profile>` when running a named profile.
|
||||
|
||||
If the LaunchAgent isn't installed, enable it from the app or run
|
||||
`openclaw gateway install`.
|
||||
|
||||
If the gateway repeatedly disappears for minutes to hours and only resumes when you touch the Control UI or SSH into the host, see the troubleshooting note for macOS Maintenance Sleep / `ENETDOWN` crashes and launchd's respawn-protection gate in [Gateway troubleshooting](/gateway/troubleshooting#macos-gateway-silently-stops-responding-then-resumes-when-you-touch-the-dashboard).
|
||||
|
||||
## Node capabilities (mac)
|
||||
|
||||
The macOS app presents itself as a node. Common commands:
|
||||
|
||||
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
|
||||
- Camera: `camera.snap`, `camera.clip`
|
||||
- Screen: `screen.snapshot`, `screen.record`
|
||||
- System: `system.run`, `system.notify`
|
||||
|
||||
The node reports a `permissions` map so agents can decide what's allowed.
|
||||
|
||||
Node service + app IPC:
|
||||
|
||||
- When the headless node host service is running (remote mode), it connects to the Gateway WS as a node.
|
||||
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
||||
|
||||
Diagram (SCI):
|
||||
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
|
||||
## Exec approvals (system.run)
|
||||
|
||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||
Security + ask + allowlist are stored locally on the Mac in:
|
||||
|
||||
```
|
||||
~/.openclaw/exec-approvals.json
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "on-miss"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"allowlist": [{ "pattern": "/opt/homebrew/bin/rg" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
|
||||
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
|
||||
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
|
||||
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
|
||||
## Deep links
|
||||
|
||||
The app registers the `openclaw://` URL scheme for local actions.
|
||||
|
||||
### `openclaw://agent`
|
||||
|
||||
Triggers a Gateway `agent` request.
|
||||
|
||||
```bash
|
||||
open 'openclaw://agent?message=Hello%20from%20deep%20link'
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `message` (required)
|
||||
- `sessionKey` (optional)
|
||||
- `thinking` (optional)
|
||||
- `deliver` / `to` / `channel` (optional)
|
||||
- `timeoutSeconds` (optional)
|
||||
- `key` (optional unattended mode key)
|
||||
|
||||
Safety:
|
||||
|
||||
- Without `key`, the app prompts for confirmation.
|
||||
- Without `key`, the app enforces a short message limit for the confirmation prompt and ignores `deliver` / `to` / `channel`.
|
||||
- With a valid `key`, the run is unattended (intended for personal automations).
|
||||
|
||||
## Onboarding flow (typical)
|
||||
|
||||
1. Install and launch **OpenClaw.app**.
|
||||
2. Complete the macOS permission checklist.
|
||||
3. Pick **Local** or **Remote** mode.
|
||||
4. Install the `openclaw` CLI if the app asks for it.
|
||||
5. Open WebChat from the menu bar and send a test message.
|
||||
2. Complete the permissions checklist (TCC prompts).
|
||||
3. Ensure **Local** mode is active and the Gateway is running.
|
||||
4. Install the CLI if you want terminal access.
|
||||
|
||||
For the CLI/Gateway setup path, use [Getting started](/start/getting-started).
|
||||
For permission recovery, use [macOS permissions](/platforms/mac/permissions).
|
||||
## State dir placement (macOS)
|
||||
|
||||
## Choose a Gateway mode
|
||||
Avoid putting your OpenClaw state dir in iCloud or other cloud-synced folders.
|
||||
Sync-backed paths can add latency and occasionally cause file-lock/sync races for
|
||||
sessions and credentials.
|
||||
|
||||
| Mode | Use it when | Detail page |
|
||||
| ------ | --------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Local | This Mac should run the Gateway and keep it alive with launchd. | [Gateway on macOS](/platforms/mac/bundled-gateway) |
|
||||
| Remote | Another host runs the Gateway and this Mac should control it over SSH, LAN, or Tailnet. | [Remote control](/platforms/mac/remote) |
|
||||
Prefer a local non-synced state path such as:
|
||||
|
||||
Local mode requires an installed `openclaw` CLI. The app can install it, or you
|
||||
can follow [Gateway on macOS](/platforms/mac/bundled-gateway).
|
||||
```bash
|
||||
OPENCLAW_STATE_DIR=~/.openclaw
|
||||
```
|
||||
|
||||
## What the app owns
|
||||
If `openclaw doctor` detects state under:
|
||||
|
||||
- Menu bar status, notifications, health, and WebChat.
|
||||
- macOS permission prompts for screen, microphone, speech, automation, and accessibility.
|
||||
- Local node tools such as Canvas, camera/screen capture, notifications, and `system.run`.
|
||||
- Exec approval prompts for Mac-hosted commands.
|
||||
- Remote-mode SSH tunnels or direct Gateway connections.
|
||||
- `~/Library/Mobile Documents/com~apple~CloudDocs/...`
|
||||
- `~/Library/CloudStorage/...`
|
||||
|
||||
The app does **not** replace the OpenClaw Gateway or general CLI docs. Core
|
||||
Gateway configuration, providers, plugins, channels, tools, and security live in
|
||||
their own docs.
|
||||
it will warn and recommend moving back to a local path.
|
||||
|
||||
## macOS detail pages
|
||||
## Build and dev workflow (native)
|
||||
|
||||
| Task | Read |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Install or debug the CLI/Gateway service | [Gateway on macOS](/platforms/mac/bundled-gateway) |
|
||||
| Keep state out of cloud-synced folders | [Gateway on macOS](/platforms/mac/bundled-gateway#state-directory-on-macos) |
|
||||
| Debug app discovery and connectivity | [Gateway on macOS](/platforms/mac/bundled-gateway#debug-app-connectivity) |
|
||||
| Understand launchd behavior | [Gateway lifecycle](/platforms/mac/child-process) |
|
||||
| Fix permissions or signing/TCC issues | [macOS permissions](/platforms/mac/permissions) |
|
||||
| Connect to a remote Gateway | [Remote control](/platforms/mac/remote) |
|
||||
| Read menu bar status and health checks | [Menu bar](/platforms/mac/menu-bar), [Health checks](/platforms/mac/health) |
|
||||
| Use the embedded chat UI | [WebChat](/platforms/mac/webchat) |
|
||||
| Use voice wake or push-to-talk | [Voice wake](/platforms/mac/voicewake) |
|
||||
| Use Canvas and Canvas deep links | [Canvas](/platforms/mac/canvas) |
|
||||
| Host PeekabooBridge for UI automation | [Peekaboo bridge](/platforms/mac/peekaboo) |
|
||||
| Configure command approvals | [Exec approvals](/tools/exec-approvals), [advanced details](/tools/exec-approvals-advanced) |
|
||||
| Inspect Mac node commands and app IPC | [macOS IPC](/platforms/mac/xpc) |
|
||||
| Capture logs | [macOS logging](/platforms/mac/logging) |
|
||||
| Build from source | [macOS dev setup](/platforms/mac/dev-setup) |
|
||||
- `cd apps/macos && swift build`
|
||||
- `swift run OpenClaw` (or Xcode)
|
||||
- Package app: `scripts/package-mac-app.sh`
|
||||
|
||||
## Related
|
||||
## Debug gateway connectivity (macOS CLI)
|
||||
|
||||
- [Platforms](/platforms)
|
||||
- [Getting started](/start/getting-started)
|
||||
- [Gateway](/gateway)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery
|
||||
logic that the macOS app uses, without launching the app.
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run openclaw-mac connect --json
|
||||
swift run openclaw-mac discover --timeout 3000 --json
|
||||
```
|
||||
|
||||
Connect options:
|
||||
|
||||
- `--url <ws://host:port>`: override config
|
||||
- `--mode <local|remote>`: resolve from config (default: config or local)
|
||||
- `--probe`: force a fresh health probe
|
||||
- `--timeout <ms>`: request timeout (default: `15000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
Discovery options:
|
||||
|
||||
- `--include-local`: include gateways that would be filtered as "local"
|
||||
- `--timeout <ms>`: overall discovery window (default: `2000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
<Tip>
|
||||
Compare against `openclaw gateway discover --json` to see whether the macOS app's discovery pipeline (`local.` plus the configured wide-area domain, with wide-area and Tailscale Serve fallbacks) differs from the Node CLI's `dns-sd` based discovery.
|
||||
</Tip>
|
||||
|
||||
## Remote connection plumbing (SSH tunnels)
|
||||
|
||||
When the macOS app runs in **Remote** mode, it opens an SSH tunnel so local UI
|
||||
components can talk to a remote Gateway as if it were on localhost.
|
||||
|
||||
### Control tunnel (Gateway WebSocket port)
|
||||
|
||||
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
|
||||
- **Local port:** the Gateway port (default `18789`), always stable.
|
||||
- **Remote port:** the same Gateway port on the remote host.
|
||||
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
|
||||
or restarts it if needed.
|
||||
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
||||
ExitOnForwardFailure + keepalive options.
|
||||
- **IP reporting:** the SSH tunnel uses loopback, so the gateway will see the node
|
||||
IP as `127.0.0.1`. Use **Direct (ws/wss)** transport if you want the real client
|
||||
IP to appear (see [macOS remote access](/platforms/mac/remote)).
|
||||
|
||||
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
||||
details, see [Gateway protocol](/gateway/protocol).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Gateway runbook](/gateway)
|
||||
- [Gateway (macOS)](/platforms/mac/bundled-gateway)
|
||||
- [macOS permissions](/platforms/mac/permissions)
|
||||
- [Canvas](/platforms/mac/canvas)
|
||||
|
||||
@@ -20,7 +20,6 @@ title: "Tests"
|
||||
- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`.
|
||||
- `pnpm check:changed`: delegates to Crabbox/Testbox by default outside CI, then runs the smart changed check gate for the diff against `origin/main` inside the remote child. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
|
||||
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
|
||||
- Testbox-through-Crabbox proof: use the wrapper's final `exitCode` and timing JSON as the command result. The delegated Blacksmith GitHub Actions run may show `cancelled` after a successful SSH command because the Testbox is stopped from outside the keepalive action; verify the wrapper summary and command output before treating that as a test failure.
|
||||
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs are full-suite proof: they use fixed shard groups, expand to leaf configs for local parallel execution, and print the expected local shard fanout before starting. The extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
|
||||
|
||||
@@ -523,7 +523,6 @@ should be rewritten in normal assistant voice.
|
||||
- Credential/token-like text is redacted.
|
||||
- Long blocks can be truncated.
|
||||
- Very large histories can drop older rows or replace an oversized row with `[sessions_history omitted: message too large]`.
|
||||
- Use `nextOffset` when present to page backward through older transcript windows.
|
||||
- Raw on-disk transcript inspection is the fallback when you need the full byte-for-byte transcript.
|
||||
|
||||
## Tool policy
|
||||
|
||||
@@ -4445,12 +4445,12 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
|
||||
expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1);
|
||||
for (const [index, call] of sendMessageSpy.mock.calls.entries()) {
|
||||
for (const call of sendMessageSpy.mock.calls) {
|
||||
const params = call[2] as
|
||||
| { reply_to_message_id?: number; reply_parameters?: { message_id?: number } }
|
||||
| undefined;
|
||||
const actual = params?.reply_parameters?.message_id ?? params?.reply_to_message_id;
|
||||
if (mode === "all" || index === 0) {
|
||||
if (mode === "all") {
|
||||
expect(actual).toBe(messageId);
|
||||
} else {
|
||||
expect(actual).toBeUndefined();
|
||||
|
||||
@@ -326,36 +326,39 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
replyToMode?: ReplyToMode;
|
||||
}): Promise<number | undefined> {
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text));
|
||||
let appliedReplyTo = false;
|
||||
for (const chunk of chunks) {
|
||||
// Only apply reply reference, quote text, and buttons to the first chunk.
|
||||
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||
const applyQuoteForChunk = !appliedReplyTo;
|
||||
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteMessageId: applyQuoteForChunk ? opts.replyQuoteMessageId : undefined,
|
||||
replyQuoteText: applyQuoteForChunk ? opts.replyQuoteText : undefined,
|
||||
replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined,
|
||||
replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: chunk.textMode,
|
||||
plainText: chunk.plainText,
|
||||
richMessages: opts.richMessages,
|
||||
linkPreview: opts.linkPreview,
|
||||
tableMode: opts.tableMode,
|
||||
silent: opts.silent,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
}
|
||||
if (replyToForChunk) {
|
||||
appliedReplyTo = true;
|
||||
}
|
||||
}
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: { hasReplied: false, hasDelivered: false },
|
||||
replyToId: opts.replyToId,
|
||||
replyToMode: opts.replyToMode ?? "first",
|
||||
replyMarkup: opts.replyMarkup,
|
||||
replyQuoteText: opts.replyQuoteText,
|
||||
quoteOnlyOnFirstChunk: true,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.text, opts.runtime, {
|
||||
replyToMessageId,
|
||||
replyQuoteMessageId: replyToMessageId ? opts.replyQuoteMessageId : undefined,
|
||||
replyQuoteText,
|
||||
replyQuotePosition: replyToMessageId ? opts.replyQuotePosition : undefined,
|
||||
replyQuoteEntities: replyToMessageId ? opts.replyQuoteEntities : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: chunk.textMode,
|
||||
plainText: chunk.plainText,
|
||||
richMessages: opts.richMessages,
|
||||
linkPreview: opts.linkPreview,
|
||||
tableMode: opts.tableMode,
|
||||
silent: opts.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = messageId;
|
||||
}
|
||||
},
|
||||
});
|
||||
return firstDeliveredMessageId;
|
||||
}
|
||||
|
||||
@@ -535,6 +538,7 @@ async function deliverMediaReply(params: {
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyToMode: params.replyToMode,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
firstDeliveredMessageId = fallbackMessageId;
|
||||
|
||||
@@ -1484,7 +1484,7 @@ describe("deliverReplies", () => {
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), { disable_notification: true });
|
||||
});
|
||||
|
||||
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
|
||||
it("voice fallback avoids native replies for chunked first-mode fallback text", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
sendMessageResult: {
|
||||
@@ -1520,15 +1520,12 @@ describe("deliverReplies", () => {
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
|
||||
reply_parameters: {
|
||||
message_id: 77,
|
||||
quote: "quoted context",
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]],
|
||||
},
|
||||
});
|
||||
expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("reply_to_message_id");
|
||||
expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("reply_parameters");
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_to_message_id", 77);
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_parameters");
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_markup");
|
||||
@@ -1555,7 +1552,32 @@ describe("deliverReplies", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replyToMode 'first' only applies reply-to to the first text chunk", async () => {
|
||||
it("replyToMode 'first' keeps native reply-to for a single text chunk", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 20,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "one chunk", replyToId: "700" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "first",
|
||||
textLimit: 4000,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
|
||||
reply_to_message_id: 700,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replyToMode 'first' avoids native reply-to for chunked text", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 20,
|
||||
@@ -1575,13 +1597,10 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
// First chunk should have reply_to_message_id
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
|
||||
reply_to_message_id: 700,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
// Second chunk should NOT have reply_to_message_id
|
||||
expect(mockCallArg(sendMessage, 1, 2)).not.toHaveProperty("reply_to_message_id");
|
||||
for (const call of sendMessage.mock.calls) {
|
||||
expect(call[2]).not.toHaveProperty("reply_to_message_id");
|
||||
expect(call[2]).not.toHaveProperty("reply_parameters");
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps reply chunks to Telegram rich message limit", async () => {
|
||||
|
||||
@@ -184,9 +184,9 @@ describe("buildTelegramThreadParams", () => {
|
||||
{ input: { id: 0, scope: "dm" as const }, expected: undefined },
|
||||
{ input: { id: -1, scope: "dm" as const }, expected: undefined },
|
||||
{ input: { id: 1.9, scope: "dm" as const }, expected: { message_thread_id: 1 } },
|
||||
// id=0 should be included for forum and none scopes (not falsy)
|
||||
// id=0 should be included for forum scope (not falsy).
|
||||
{ input: { id: 0, scope: "forum" as const }, expected: { message_thread_id: 0 } },
|
||||
{ input: { id: 0, scope: "none" as const }, expected: { message_thread_id: 0 } },
|
||||
{ input: { id: 42, scope: "none" as const }, expected: undefined },
|
||||
])("builds thread params", ({ input, expected }) => {
|
||||
expect(buildTelegramThreadParams(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -427,6 +427,10 @@ export function buildTelegramThreadParams(thread?: TelegramThreadSpec | null) {
|
||||
return normalized > 0 ? { message_thread_id: normalized } : undefined;
|
||||
}
|
||||
|
||||
if (thread.scope === "none") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Telegram rejects message_thread_id=1 for General forum topic
|
||||
if (normalized === TELEGRAM_GENERAL_TOPIC_ID) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Telegram plugin module implements reply threading behavior.
|
||||
import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
|
||||
|
||||
export type DeliveryProgress = {
|
||||
hasReplied: boolean;
|
||||
@@ -48,17 +49,24 @@ export async function sendChunkedTelegramReplyText<
|
||||
}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const applyDelivered = params.markDelivered ?? markDelivered;
|
||||
const suppressSingleUseReply =
|
||||
params.chunks.length > 1 && isSingleUseReplyToMode(params.replyToMode);
|
||||
for (let i = 0; i < params.chunks.length; i += 1) {
|
||||
const chunk = params.chunks[i];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const isFirstChunk = i === 0;
|
||||
const replyToMessageId = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
// Telegram Desktop can render long formatted native-reply chunks as
|
||||
// unsupported messages. Multi-part `first` replies consume the reply target
|
||||
// without adding native reply params, preserving visible text.
|
||||
const replyToMessageId = suppressSingleUseReply
|
||||
? undefined
|
||||
: resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const shouldAttachQuote =
|
||||
Boolean(replyToMessageId) &&
|
||||
Boolean(params.replyQuoteText) &&
|
||||
@@ -70,7 +78,10 @@ export async function sendChunkedTelegramReplyText<
|
||||
replyMarkup: isFirstChunk ? params.replyMarkup : undefined,
|
||||
replyQuoteText: shouldAttachQuote ? params.replyQuoteText : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
markReplyApplied(
|
||||
params.progress,
|
||||
suppressSingleUseReply && isFirstChunk ? params.replyToId : replyToMessageId,
|
||||
);
|
||||
applyDelivered(params.progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,19 +82,37 @@ describe("telegram channel message adapter", () => {
|
||||
};
|
||||
|
||||
const provePayload = async () => {
|
||||
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-payload", chatId: "12345" });
|
||||
sendMessageTelegramMock.mockResolvedValueOnce({
|
||||
messageId: "tg-payload-2",
|
||||
chatId: "12345",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "tg-payload-1",
|
||||
platformMessageIds: ["tg-payload-1", "tg-payload-2"],
|
||||
parts: [
|
||||
{ platformMessageId: "tg-payload-1", kind: "text", index: 0 },
|
||||
{ platformMessageId: "tg-payload-2", kind: "text", index: 1 },
|
||||
],
|
||||
sentAt: 123,
|
||||
},
|
||||
});
|
||||
const result = await adapter.send!.payload!({
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "payload",
|
||||
payload: { text: "payload" },
|
||||
replyToId: "900",
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
threadId: "12",
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
});
|
||||
expect(sendMessageTelegramMock).toHaveBeenLastCalledWith("12345", "payload", {
|
||||
cfg: {},
|
||||
verbose: false,
|
||||
messageThreadId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
messageThreadId: 12,
|
||||
replyToMessageId: 900,
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
accountId: undefined,
|
||||
silent: undefined,
|
||||
gatewayClientScopes: undefined,
|
||||
@@ -104,7 +122,8 @@ describe("telegram channel message adapter", () => {
|
||||
quoteText: undefined,
|
||||
buttons: undefined,
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["tg-payload"]);
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("tg-payload-1");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["tg-payload-1", "tg-payload-2"]);
|
||||
};
|
||||
|
||||
const proveReplyThreadSilent = async () => {
|
||||
@@ -114,6 +133,8 @@ describe("telegram channel message adapter", () => {
|
||||
to: "12345",
|
||||
text: "threaded",
|
||||
replyToId: "900",
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
threadId: "12",
|
||||
silent: true,
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
@@ -123,6 +144,8 @@ describe("telegram channel message adapter", () => {
|
||||
verbose: false,
|
||||
messageThreadId: 12,
|
||||
replyToMessageId: 900,
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
accountId: undefined,
|
||||
silent: true,
|
||||
gatewayClientScopes: undefined,
|
||||
@@ -138,6 +161,9 @@ describe("telegram channel message adapter", () => {
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "batch",
|
||||
replyToId: "900",
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
payload: {
|
||||
text: "batch",
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
@@ -152,7 +178,9 @@ describe("telegram channel message adapter", () => {
|
||||
cfg: {},
|
||||
verbose: false,
|
||||
messageThreadId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyToMessageId: 900,
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
accountId: undefined,
|
||||
silent: undefined,
|
||||
gatewayClientScopes: undefined,
|
||||
@@ -172,6 +200,8 @@ describe("telegram channel message adapter", () => {
|
||||
verbose: false,
|
||||
messageThreadId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyToIdSource: undefined,
|
||||
replyToMode: undefined,
|
||||
accountId: undefined,
|
||||
silent: undefined,
|
||||
gatewayClientScopes: undefined,
|
||||
@@ -222,6 +252,36 @@ describe("telegram channel message adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps implicit first replies on the first delivered payload media", async () => {
|
||||
const adapter = requireTelegramMessageAdapter();
|
||||
sendMessageTelegramMock
|
||||
.mockResolvedValueOnce({ messageId: "tg-media-1", chatId: "12345" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-media-2", chatId: "12345" });
|
||||
|
||||
await adapter.send!.payload!({
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "batch",
|
||||
replyToId: "900",
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
payload: {
|
||||
text: "batch",
|
||||
mediaUrls: ["", "https://example.com/a.png", "https://example.com/b.png"],
|
||||
},
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
});
|
||||
|
||||
const firstOpts = sendMessageTelegramMock.mock.calls[0]?.[2] as
|
||||
| { replyToMessageId?: number }
|
||||
| undefined;
|
||||
const secondOpts = sendMessageTelegramMock.mock.calls[1]?.[2] as
|
||||
| { replyToMessageId?: number }
|
||||
| undefined;
|
||||
expect(firstOpts?.replyToMessageId).toBe(900);
|
||||
expect(secondOpts?.replyToMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
|
||||
const adapter = requireTelegramMessageAdapter();
|
||||
|
||||
|
||||
@@ -364,11 +364,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
};
|
||||
|
||||
const candidateTexts = [stream.lastDeliveredText?.(), lane.lastPartialText];
|
||||
if (
|
||||
useFinalTextRecovery &&
|
||||
remainingChunks.length === 0 &&
|
||||
isPotentialTruncatedFinal(activeFullText)
|
||||
) {
|
||||
if (useFinalTextRecovery && remainingChunks.length === 0 && isPotentialTruncatedFinal(activeFullText)) {
|
||||
const resolvedFullCandidate = await params.resolveFinalTextCandidate?.({
|
||||
finalText: text,
|
||||
laneName,
|
||||
@@ -383,9 +379,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
|
||||
const retainedPreview =
|
||||
useFinalTextRecovery &&
|
||||
remainingChunks.length === 0 &&
|
||||
isPotentialTruncatedFinal(activeFullText)
|
||||
useFinalTextRecovery && remainingChunks.length === 0 && isPotentialTruncatedFinal(activeFullText)
|
||||
? selectLongerFinalText({
|
||||
finalText: activeFullText,
|
||||
candidateTexts,
|
||||
@@ -449,20 +443,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
} else {
|
||||
await params.flushDraftLane(lane);
|
||||
}
|
||||
const activeChunkIndexAfterStop = useFinalTextRecovery
|
||||
? clampActiveChunkIndex()
|
||||
: activeChunkIndex;
|
||||
const deliveredStreamTextAfterStop = stream.lastDeliveredText?.();
|
||||
const retainedOriginalActiveChunkAfterStop =
|
||||
activeChunkIndexAfterStop > activeChunkIndex &&
|
||||
deliveredStreamTextAfterStop === activeChunk.trimEnd();
|
||||
// `activeChunkIndex` is advanced by retained preview callbacks. If callbacks
|
||||
// outrun the stream's delivered text, trust the delivered text and replay the gap.
|
||||
const effectiveActiveChunkIndexAfterStop = retainedOriginalActiveChunkAfterStop
|
||||
? activeChunkIndex
|
||||
: activeChunkIndexAfterStop;
|
||||
const activeChunkAfterStop = chunks[effectiveActiveChunkIndexAfterStop] ?? activeChunk;
|
||||
const remainingChunksAfterStop = chunks.slice(effectiveActiveChunkIndexAfterStop + 1);
|
||||
const activeChunkIndexAfterStop = useFinalTextRecovery ? clampActiveChunkIndex() : activeChunkIndex;
|
||||
const activeChunkAfterStop = chunks[activeChunkIndexAfterStop] ?? activeChunk;
|
||||
const remainingChunksAfterStop = chunks.slice(activeChunkIndexAfterStop + 1);
|
||||
|
||||
const messageId = stream.messageId();
|
||||
if (typeof messageId !== "number") {
|
||||
@@ -477,12 +460,16 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deliveredStreamTextAfterStop = stream.lastDeliveredText?.();
|
||||
const activeChunkTextAfterStop = activeChunkAfterStop.trimEnd();
|
||||
const retainedActiveChunkAfterStop =
|
||||
activeChunkIndexAfterStop !== activeChunkIndex &&
|
||||
deliveredStreamTextAfterStop === activeChunk.trimEnd();
|
||||
if (
|
||||
finalizePreview &&
|
||||
deliveredStreamTextAfterStop !== undefined &&
|
||||
deliveredStreamTextAfterStop !== activeChunkTextAfterStop &&
|
||||
!retainedOriginalActiveChunkAfterStop
|
||||
!retainedActiveChunkAfterStop
|
||||
) {
|
||||
if (
|
||||
useFinalTextRecovery &&
|
||||
|
||||
@@ -228,7 +228,10 @@ describe("createLaneTextDeliverer", () => {
|
||||
expect(answer.update).toHaveBeenCalledWith(previousBlock);
|
||||
expect(answer.update).not.toHaveBeenCalledWith(nextAssistantBlock);
|
||||
expect(harness.clearDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: previousBlock }, { durable: false });
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
{ text: previousBlock },
|
||||
{ durable: false },
|
||||
);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalledWith(
|
||||
{ text: nextAssistantBlock },
|
||||
expect.anything(),
|
||||
@@ -921,7 +924,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends chunks after the reported delivered text when stop advances the retained index", async () => {
|
||||
it("does not resend chunks retained while stopping a long streamed final", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
@@ -937,35 +940,11 @@ describe("createLaneTextDeliverer", () => {
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe("Hello world again");
|
||||
expect(delivery.promptContextContent).toBe("Hello");
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(2);
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(1, { text: " world" });
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(2, { text: " again" });
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " again" });
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not skip middle chunks when stop advances past the reported delivered chunk", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
draftMaxChars: 6,
|
||||
splitFinalTextForStream: () => ["chunk0", "chunk1", "chunk2", "chunk3"],
|
||||
});
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
answer.stop.mockImplementation(async () => {
|
||||
harness.lanes.answer.activeChunkIndex = 2;
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "chunk0chunk1chunk2chunk3");
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.promptContextContent).toBe("chunk0");
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(3);
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(1, { text: "chunk1" });
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(2, { text: "chunk2" });
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(3, { text: "chunk3" });
|
||||
});
|
||||
|
||||
it("compares retained delivered prefixes against the full final text", async () => {
|
||||
let deliveredText = "Hello";
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
@@ -1016,15 +995,11 @@ describe("createLaneTextDeliverer", () => {
|
||||
expect(harness.editStreamMessage).toHaveBeenCalledWith({
|
||||
laneName: "answer",
|
||||
messageId: 999,
|
||||
text: "Hello",
|
||||
text: " world",
|
||||
buttons,
|
||||
});
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(2);
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(1, {
|
||||
text: " world",
|
||||
channelData: { telegram: { buttons } },
|
||||
});
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(2, {
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({
|
||||
text: " again",
|
||||
channelData: { telegram: { buttons } },
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
@@ -60,6 +61,8 @@ async function resolveTelegramSendContext(params: {
|
||||
deps?: OutboundSendDeps;
|
||||
accountId?: string | null;
|
||||
replyToId?: string | null;
|
||||
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
|
||||
replyToMode?: TelegramSendOpts["replyToMode"];
|
||||
threadId?: string | number | null;
|
||||
formatting?: OutboundDeliveryFormattingOptions;
|
||||
silent?: boolean;
|
||||
@@ -74,6 +77,8 @@ async function resolveTelegramSendContext(params: {
|
||||
tableMode?: OutboundDeliveryFormattingOptions["tableMode"];
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
replyToIdSource?: TelegramSendOpts["replyToIdSource"];
|
||||
replyToMode?: TelegramSendOpts["replyToMode"];
|
||||
accountId?: string;
|
||||
silent?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
@@ -87,6 +92,8 @@ async function resolveTelegramSendContext(params: {
|
||||
cfg: params.cfg,
|
||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||
...(params.replyToIdSource !== undefined ? { replyToIdSource: params.replyToIdSource } : {}),
|
||||
...(params.replyToMode !== undefined ? { replyToMode: params.replyToMode } : {}),
|
||||
accountId: params.accountId ?? undefined,
|
||||
silent: params.silent,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
@@ -151,6 +158,19 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
quoteText,
|
||||
...(params.payload.audioAsVoice === true ? { asVoice: true } : {}),
|
||||
};
|
||||
const shouldConsumeImplicitReplyTarget =
|
||||
payloadOpts.replyToIdSource === "implicit" &&
|
||||
payloadOpts.replyToMode !== undefined &&
|
||||
isSingleUseReplyToMode(payloadOpts.replyToMode);
|
||||
const consumedImplicitReplyPayloadOpts = shouldConsumeImplicitReplyTarget
|
||||
? {
|
||||
...payloadOpts,
|
||||
replyToMessageId: undefined,
|
||||
replyToIdSource: undefined,
|
||||
replyToMode: undefined,
|
||||
}
|
||||
: payloadOpts;
|
||||
let implicitReplyTargetAvailable = true;
|
||||
if (reactionEmoji) {
|
||||
if (typeof replyToMessageId !== "number") {
|
||||
throw new Error("Telegram reaction requires a reply target");
|
||||
@@ -179,12 +199,18 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
...payloadOpts,
|
||||
buttons,
|
||||
}),
|
||||
send: async ({ text: textLocal, mediaUrl, isFirst }) =>
|
||||
await params.send(params.to, textLocal, {
|
||||
...payloadOpts,
|
||||
send: async ({ text: textLocal, mediaUrl, isFirst }) => {
|
||||
const mediaPayloadOpts =
|
||||
shouldConsumeImplicitReplyTarget && !implicitReplyTargetAvailable
|
||||
? consumedImplicitReplyPayloadOpts
|
||||
: payloadOpts;
|
||||
implicitReplyTargetAvailable = false;
|
||||
return await params.send(params.to, textLocal, {
|
||||
...mediaPayloadOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons } : {}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1650,6 +1650,49 @@ describe("sendMessageTelegram", () => {
|
||||
expect(res.messageId).toBe("71");
|
||||
});
|
||||
|
||||
it("does not reuse first-mode reply-to on media caption follow-up text", async () => {
|
||||
const chatId = "123";
|
||||
const longText = "A".repeat(1100);
|
||||
|
||||
const sendPhoto = vi.fn().mockResolvedValue({
|
||||
message_id: 70,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 71,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendPhoto, sendMessage } as unknown as {
|
||||
sendPhoto: typeof sendPhoto;
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
mockLoadedMedia({
|
||||
buffer: Buffer.from("fake-image"),
|
||||
contentType: "image/jpeg",
|
||||
fileName: "photo.jpg",
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, longText, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
replyToMessageId: 500,
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
||||
caption: undefined,
|
||||
reply_to_message_id: 500,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
it("chunks long default markdown media follow-up text", async () => {
|
||||
const chatId = "123";
|
||||
const longText = `**${"A".repeat(5000)}**`;
|
||||
@@ -1658,7 +1701,10 @@ describe("sendMessageTelegram", () => {
|
||||
message_id: 72,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 74, chat: { id: chatId } });
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 73, chat: { id: chatId } })
|
||||
.mockResolvedValueOnce({ message_id: 74, chat: { id: chatId } });
|
||||
const api = { sendPhoto, sendMessage } as unknown as {
|
||||
sendPhoto: typeof sendPhoto;
|
||||
sendMessage: typeof sendMessage;
|
||||
@@ -1684,6 +1730,9 @@ describe("sendMessageTelegram", () => {
|
||||
expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true);
|
||||
expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("A");
|
||||
expect(res.messageId).toBe("74");
|
||||
expect(res.receipt?.primaryPlatformMessageId).toBe("73");
|
||||
expect(res.receipt?.platformMessageIds).toEqual(["73", "74"]);
|
||||
expect(res.receipt?.parts.map((part) => part.kind)).toEqual(["text", "text"]);
|
||||
});
|
||||
|
||||
it("uses caption when text is within 1024 char limit", async () => {
|
||||
@@ -2499,6 +2548,93 @@ describe("sendMessageTelegram", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a multipart receipt and avoids native replies for chunked first-mode text", async () => {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } })
|
||||
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } });
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
const result = await sendMessageTelegram("-1001234567890", `BEGIN ${"A".repeat(4100)} END`, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
replyToMessageId: 500,
|
||||
replyToIdSource: "implicit",
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage.mock.calls[0]?.[2]).toEqual({
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
expect(sendMessage.mock.calls[1]?.[2]).toEqual({
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 271,
|
||||
});
|
||||
expect(result.messageId).toBe("102");
|
||||
expect(result.receipt?.primaryPlatformMessageId).toBe("101");
|
||||
expect(result.receipt?.platformMessageIds).toEqual(["101", "102"]);
|
||||
expect(result.receipt?.threadId).toBe("271");
|
||||
expect(result.receipt?.replyToId).toBeUndefined();
|
||||
expect(
|
||||
result.receipt?.parts.map(({ platformMessageId, kind, index, threadId, replyToId }) => ({
|
||||
platformMessageId,
|
||||
kind,
|
||||
index,
|
||||
threadId,
|
||||
replyToId,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
platformMessageId: "101",
|
||||
kind: "text",
|
||||
index: 0,
|
||||
threadId: "271",
|
||||
replyToId: undefined,
|
||||
},
|
||||
{
|
||||
platformMessageId: "102",
|
||||
kind: "text",
|
||||
index: 1,
|
||||
threadId: "271",
|
||||
replyToId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps explicit native replies for chunked first-mode text", async () => {
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } })
|
||||
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } });
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram("-1001234567890", `BEGIN ${"A".repeat(4100)} END`, {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 500,
|
||||
replyToIdSource: "explicit",
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[2]).toMatchObject({
|
||||
reply_to_message_id: 500,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
expect(sendMessage.mock.calls[1]?.[2]).toMatchObject({
|
||||
reply_to_message_id: 500,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails topic sends instead of retrying without message_thread_id", async () => {
|
||||
const cases = [{ name: "forum", chatId: "-100123", text: "hello forum" }] as const;
|
||||
const threadErr = new Error("400: Bad Request: message thread not found");
|
||||
|
||||
@@ -3,12 +3,17 @@ import * as grammy from "grammy";
|
||||
import { type ApiClientOptions, Bot, HttpError } from "grammy";
|
||||
import type { ReactionType, ReactionTypeEmoji } from "grammy/types";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
|
||||
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
|
||||
import { createTelegramRetryRunner, type RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -84,6 +89,8 @@ type TelegramEditMessageCaptionParams = Parameters<TelegramApi["editMessageCapti
|
||||
type TelegramCreateForumTopicParams = NonNullable<Parameters<TelegramApi["createForumTopic"]>[2]>;
|
||||
type TelegramThreadScopedParams = {
|
||||
message_thread_id?: number;
|
||||
reply_parameters?: { message_id?: number };
|
||||
reply_to_message_id?: number;
|
||||
};
|
||||
const InputFileCtor = grammy.InputFile;
|
||||
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
|
||||
@@ -111,6 +118,10 @@ type TelegramSendOpts = {
|
||||
silent?: boolean;
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Whether replyToMessageId came from ambient context or explicit payload/action input. */
|
||||
replyToIdSource?: "explicit" | "implicit";
|
||||
/** Controls whether replyToMessageId is applied to every internal text chunk. */
|
||||
replyToMode?: ReplyToMode;
|
||||
/** Quote text for Telegram reply_parameters. */
|
||||
quoteText?: string;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
@@ -124,6 +135,7 @@ type TelegramSendOpts = {
|
||||
type TelegramSendResult = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
receipt?: MessageReceipt;
|
||||
};
|
||||
|
||||
type TelegramMessageLike = {
|
||||
@@ -274,6 +286,42 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo
|
||||
sendLogger.info(parts.join(" "));
|
||||
}
|
||||
|
||||
function buildTelegramTextSendReceipt(params: {
|
||||
messageIds: readonly string[];
|
||||
chatId: string;
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
}): MessageReceipt | undefined {
|
||||
if (params.messageIds.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: params.messageIds.map((messageId) => ({
|
||||
messageId,
|
||||
chatId: params.chatId,
|
||||
})),
|
||||
kind: "text",
|
||||
...(typeof params.messageThreadId === "number"
|
||||
? { threadId: String(params.messageThreadId) }
|
||||
: {}),
|
||||
...(typeof params.replyToMessageId === "number"
|
||||
? { replyToId: String(params.replyToMessageId) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAcceptedReplyToMessageId(
|
||||
params: TelegramThreadScopedParams | TelegramRichMessageContextParams | undefined,
|
||||
): number | undefined {
|
||||
if (!params) {
|
||||
return undefined;
|
||||
}
|
||||
if ("reply_to_message_id" in params) {
|
||||
return params.reply_to_message_id;
|
||||
}
|
||||
return params.reply_parameters?.message_id;
|
||||
}
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const MESSAGE_NOT_MODIFIED_RE =
|
||||
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
|
||||
@@ -661,19 +709,26 @@ export async function sendMessageTelegram(
|
||||
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
}),
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
replyQuoteText: opts.quoteText,
|
||||
useReplyIdAsQuoteSource: true,
|
||||
const threadSpec = resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: target.chatType,
|
||||
});
|
||||
const richThreadParams = toTelegramRichMessageContextParams(threadParams);
|
||||
const hasThreadParams = Object.keys(threadParams).length > 0;
|
||||
const hasRichThreadParams = Object.keys(richThreadParams).length > 0;
|
||||
const singleUseReplyTo =
|
||||
opts.replyToIdSource === "implicit" &&
|
||||
opts.replyToMode !== undefined &&
|
||||
isSingleUseReplyToMode(opts.replyToMode);
|
||||
const buildThreadParams = (includeReplyTo: boolean) =>
|
||||
buildTelegramThreadReplyParams({
|
||||
thread: threadSpec,
|
||||
...(includeReplyTo
|
||||
? {
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
replyQuoteText: opts.quoteText,
|
||||
useReplyIdAsQuoteSource: true,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({
|
||||
cfg,
|
||||
account,
|
||||
@@ -746,29 +801,59 @@ export async function sendMessageTelegram(
|
||||
return { result, acceptedParams: params };
|
||||
};
|
||||
|
||||
const buildTextParams = (isLastChunk: boolean) =>
|
||||
hasThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...threadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const shouldIncludeReplyForChunk = (
|
||||
index: number,
|
||||
chunkCount: number,
|
||||
replyToAlreadyUsed: boolean,
|
||||
) =>
|
||||
// Telegram Desktop can render long formatted reply chunks as unsupported messages.
|
||||
// Multi-part `first` replies keep chat/topic routing but avoid hiding chunk text.
|
||||
!replyToAlreadyUsed && (!singleUseReplyTo || (chunkCount === 1 && index === 0));
|
||||
|
||||
const buildRichTextParams = (isLastChunk: boolean) =>
|
||||
hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
const buildTextParams = (
|
||||
index: number,
|
||||
chunkCount: number,
|
||||
isLastChunk: boolean,
|
||||
replyToAlreadyUsed: boolean,
|
||||
) => {
|
||||
const params = buildThreadParams(
|
||||
shouldIncludeReplyForChunk(index, chunkCount, replyToAlreadyUsed),
|
||||
);
|
||||
return Object.keys(params).length > 0 || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...params,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const buildRichTextParams = (
|
||||
index: number,
|
||||
chunkCount: number,
|
||||
isLastChunk: boolean,
|
||||
replyToAlreadyUsed: boolean,
|
||||
) => {
|
||||
const params = toTelegramRichMessageContextParams(
|
||||
buildThreadParams(shouldIncludeReplyForChunk(index, chunkCount, replyToAlreadyUsed)),
|
||||
);
|
||||
return Object.keys(params).length > 0 || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...params,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const sendTelegramTextChunks = async (
|
||||
chunks: TelegramTextChunk[],
|
||||
context: string,
|
||||
): Promise<{ messageId: string; chatId: string }> => {
|
||||
options: { replyToAlreadyUsed?: boolean } = {},
|
||||
): Promise<TelegramSendResult> => {
|
||||
let lastMessageId = "";
|
||||
let lastChatId = chatId;
|
||||
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
|
||||
let acceptedReplyToMessageId: number | undefined;
|
||||
const messageIds: string[] = [];
|
||||
let sentChunkCount = 0;
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
@@ -777,7 +862,12 @@ export async function sendMessageTelegram(
|
||||
}
|
||||
const { result: res, acceptedParams } = await sendTelegramTextChunk(
|
||||
chunk,
|
||||
buildTextParams(index === chunks.length - 1),
|
||||
buildTextParams(
|
||||
index,
|
||||
chunks.length,
|
||||
index === chunks.length - 1,
|
||||
options.replyToAlreadyUsed === true,
|
||||
),
|
||||
);
|
||||
const messageId = resolveTelegramMessageIdOrThrow(res, context);
|
||||
recordSentMessage(chatId, messageId, cfg);
|
||||
@@ -795,6 +885,8 @@ export async function sendMessageTelegram(
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(res?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
acceptedReplyToMessageId ??= resolveAcceptedReplyToMessageId(acceptedParams);
|
||||
messageIds.push(lastMessageId);
|
||||
sentChunkCount += 1;
|
||||
}
|
||||
if (lastMessageId) {
|
||||
@@ -810,7 +902,17 @@ export async function sendMessageTelegram(
|
||||
chunkCount: sentChunkCount,
|
||||
});
|
||||
}
|
||||
return { messageId: lastMessageId, chatId: lastChatId };
|
||||
const receipt = buildTelegramTextSendReceipt({
|
||||
messageIds,
|
||||
chatId: lastChatId,
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: acceptedReplyToMessageId,
|
||||
});
|
||||
return {
|
||||
messageId: lastMessageId,
|
||||
chatId: lastChatId,
|
||||
...(receipt ? { receipt } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
|
||||
@@ -841,10 +943,14 @@ export async function sendMessageTelegram(
|
||||
}));
|
||||
};
|
||||
|
||||
const sendChunkedText = async (rawText: string, context: string) =>
|
||||
const sendChunkedText = async (
|
||||
rawText: string,
|
||||
context: string,
|
||||
options: { replyToAlreadyUsed?: boolean } = {},
|
||||
) =>
|
||||
useRichMessages
|
||||
? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context)
|
||||
: await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context);
|
||||
? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context, options)
|
||||
: await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context, options);
|
||||
|
||||
const buildRichTextPlan = (rawText: string): TelegramRichTextChunk[] => {
|
||||
const textLimit = Math.min(
|
||||
@@ -866,18 +972,26 @@ export async function sendMessageTelegram(
|
||||
const sendTelegramRichTextChunks = async (
|
||||
chunks: TelegramRichTextChunk[],
|
||||
context: string,
|
||||
): Promise<{ messageId: string; chatId: string }> => {
|
||||
options: { replyToAlreadyUsed?: boolean } = {},
|
||||
): Promise<TelegramSendResult> => {
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
let lastMessageId = "";
|
||||
let lastChatId = chatId;
|
||||
let lastAcceptedParams: TelegramRichMessageContextParams | undefined;
|
||||
let acceptedReplyToMessageId: number | undefined;
|
||||
const messageIds: string[] = [];
|
||||
let sentChunkCount = 0;
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const acceptedParams = buildRichTextParams(index === chunks.length - 1);
|
||||
const acceptedParams = buildRichTextParams(
|
||||
index,
|
||||
chunks.length,
|
||||
index === chunks.length - 1,
|
||||
options.replyToAlreadyUsed === true,
|
||||
);
|
||||
const result = await requestWithChatNotFound(
|
||||
() =>
|
||||
richRawApi.sendRichMessage({
|
||||
@@ -907,6 +1021,8 @@ export async function sendMessageTelegram(
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(result?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
acceptedReplyToMessageId ??= resolveAcceptedReplyToMessageId(acceptedParams);
|
||||
messageIds.push(lastMessageId);
|
||||
sentChunkCount += 1;
|
||||
}
|
||||
if (lastMessageId) {
|
||||
@@ -922,7 +1038,17 @@ export async function sendMessageTelegram(
|
||||
chunkCount: sentChunkCount,
|
||||
});
|
||||
}
|
||||
return { messageId: lastMessageId, chatId: lastChatId };
|
||||
const receipt = buildTelegramTextSendReceipt({
|
||||
messageIds,
|
||||
chatId: lastChatId,
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: acceptedReplyToMessageId,
|
||||
});
|
||||
return {
|
||||
messageId: lastMessageId,
|
||||
chatId: lastChatId,
|
||||
...(receipt ? { receipt } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
|
||||
@@ -1001,8 +1127,10 @@ export async function sendMessageTelegram(
|
||||
const needsSeparateText = Boolean(followUpText);
|
||||
// When splitting, put reply_markup only on the follow-up text (the "main" content),
|
||||
// not on the media message.
|
||||
const mediaThreadParams = buildThreadParams(true);
|
||||
const mediaUsedReplyTo = resolveAcceptedReplyToMessageId(mediaThreadParams) !== undefined;
|
||||
const baseMediaParams = {
|
||||
...(hasThreadParams ? threadParams : {}),
|
||||
...mediaThreadParams,
|
||||
...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
};
|
||||
const videoDimensions =
|
||||
@@ -1145,8 +1273,13 @@ export async function sendMessageTelegram(
|
||||
// If text was too long for a caption, send it as a separate follow-up message.
|
||||
// Use HTML conversion so markdown renders like captions.
|
||||
if (needsSeparateText && followUpText) {
|
||||
const textResult = await sendChunkedText(followUpText, "text follow-up send");
|
||||
return { messageId: textResult.messageId, chatId: resolvedChatId };
|
||||
const textResult = await sendChunkedText(followUpText, "text follow-up send", {
|
||||
replyToAlreadyUsed: singleUseReplyTo && mediaUsedReplyTo,
|
||||
});
|
||||
return {
|
||||
...textResult,
|
||||
chatId: resolvedChatId,
|
||||
};
|
||||
}
|
||||
|
||||
return { messageId: String(mediaMessageId), chatId: resolvedChatId };
|
||||
|
||||
@@ -91,7 +91,6 @@ describe("lazy protocol validators", () => {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -32,7 +32,6 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
sessionKey: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
||||
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 500_000 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
title: Native command active session target evidence
|
||||
|
||||
scenario:
|
||||
id: native-command-session-target
|
||||
surface: channel-framework
|
||||
category: channel-framework.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.native-command-session-target
|
||||
objective: Link native command target-session e2e coverage to channel framework maturity accounting.
|
||||
successCriteria:
|
||||
- Native `/stop` commands use the active target session key instead of the slash-command session.
|
||||
- The target embedded agent run is aborted.
|
||||
- Queued follow-up work for the target session is cleared.
|
||||
docsRefs:
|
||||
- docs/channels/qa-channel.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts
|
||||
summary: Vitest e2e coverage for native command active-session targeting.
|
||||
@@ -1,23 +0,0 @@
|
||||
title: CLI channel picker evidence
|
||||
|
||||
scenario:
|
||||
id: cli-channel-picker
|
||||
surface: cli-install-update-onboard-doctor
|
||||
category: cli-install-update-onboard-doctor.plugin-and-channel-setup
|
||||
coverage:
|
||||
primary:
|
||||
- cli.channel-picker
|
||||
objective: Link channel onboarding picker e2e coverage to CLI maturity accounting.
|
||||
successCriteria:
|
||||
- The channel picker remains usable with broken sibling registry diagnostics.
|
||||
- Hidden setup channels are omitted from selectable choices.
|
||||
- Configured and disabled channel states are presented with the expected actions.
|
||||
docsRefs:
|
||||
- docs/channels/qa-channel.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/commands/onboard-channels.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/commands/onboard-channels.e2e.test.ts
|
||||
summary: Vitest e2e coverage for channel onboarding picker behavior.
|
||||
@@ -1,22 +0,0 @@
|
||||
title: ClawHub skill install evidence
|
||||
|
||||
scenario:
|
||||
id: clawhub-skill-installs
|
||||
surface: clawhub-and-external-plugin-distribution
|
||||
category: clawhub-and-external-plugin-distribution.plugin-lifecycle-and-health
|
||||
coverage:
|
||||
primary:
|
||||
- clawhub.skill-installs
|
||||
objective: Link ClawHub-backed skill install e2e coverage to ClawHub maturity accounting.
|
||||
successCriteria:
|
||||
- The CLI resolves a ClawHub skill install descriptor.
|
||||
- The GitHub-backed skill archive is downloaded and installed into the state directory.
|
||||
- Install telemetry reports the installed skill slug and version.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/cli/skills-cli.clawhub-install.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/cli/skills-cli.clawhub-install.e2e.test.ts
|
||||
summary: Vitest e2e coverage for ClawHub-backed skill installs.
|
||||
@@ -1,23 +0,0 @@
|
||||
title: Docker Compose setup evidence
|
||||
|
||||
scenario:
|
||||
id: docker-compose-setup
|
||||
surface: docker-podman-hosting
|
||||
category: docker-podman-hosting.container-operations
|
||||
coverage:
|
||||
primary:
|
||||
- docker.compose
|
||||
objective: Link Docker Compose setup e2e coverage to Docker maturity accounting.
|
||||
successCriteria:
|
||||
- Docker Compose gateway and CLI service command shape stays in sync.
|
||||
- Compose service env, token, auth-profile, timezone, and optional env-file defaults stay aligned.
|
||||
- Container-side state, config, and workspace paths override host `.env` values.
|
||||
docsRefs:
|
||||
- docs/install/docker.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/docker-setup.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/docker-setup.e2e.test.ts
|
||||
summary: Vitest e2e coverage for Docker Compose setup and mount/env contracts.
|
||||
@@ -1,23 +0,0 @@
|
||||
title: Heartbeat active-hours scheduling evidence
|
||||
|
||||
scenario:
|
||||
id: heartbeat-active-hours
|
||||
surface: automation-cron-hooks-tasks-polling
|
||||
category: automation-cron-hooks-tasks-polling.heartbeat
|
||||
coverage:
|
||||
primary:
|
||||
- automation.active-hours
|
||||
- automation.heartbeat-scheduling
|
||||
objective: Link active-hours heartbeat scheduler e2e coverage to automation maturity accounting.
|
||||
successCriteria:
|
||||
- Heartbeat timers skip quiet-hours phase slots.
|
||||
- In-window phase slots fire without duplicate scheduling loops.
|
||||
- Hot reload recomputes active-hours and timezone schedule changes.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts
|
||||
summary: Vitest e2e coverage for active-hours-aware heartbeat scheduling.
|
||||
@@ -1,23 +0,0 @@
|
||||
title: Browser realtime Talk start-stop evidence
|
||||
|
||||
scenario:
|
||||
id: browser-talk-start-stop
|
||||
surface: browser-control-ui-and-webchat
|
||||
category: browser-control-ui-and-webchat.browser-realtime-talk
|
||||
coverage:
|
||||
primary:
|
||||
- ui.browser-talk-start-stop
|
||||
objective: Link deterministic browser realtime Talk transport start/stop coverage to UI maturity accounting.
|
||||
successCriteria:
|
||||
- Browser Talk starts the Google Live WebSocket transport and reaches listening state.
|
||||
- Stopped transports ignore late WebSocket events.
|
||||
- Active consult shutdown aborts the active run without reviving listening state.
|
||||
docsRefs:
|
||||
- docs/web/control-ui.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- ui/src/ui/realtime-talk-google-live.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: ui/src/ui/realtime-talk-google-live.test.ts
|
||||
summary: Vitest coverage for browser realtime Talk transport start and stop behavior.
|
||||
@@ -1,23 +0,0 @@
|
||||
title: Control UI assistant media ticket evidence
|
||||
|
||||
scenario:
|
||||
id: control-ui-assistant-media-tickets
|
||||
surface: browser-control-ui-and-webchat
|
||||
category: browser-control-ui-and-webchat.webchat-conversations
|
||||
coverage:
|
||||
primary:
|
||||
- ui.assistant-media-tickets
|
||||
objective: Link scoped assistant media ticket e2e coverage to Control UI maturity accounting.
|
||||
successCriteria:
|
||||
- Gateway metadata requests mint scoped assistant media tickets.
|
||||
- Assistant media fetches without a ticket are rejected.
|
||||
- Tickets cannot be replayed for a different source file.
|
||||
docsRefs:
|
||||
- docs/web/control-ui.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/gateway/control-ui-assistant-media.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: src/gateway/control-ui-assistant-media.e2e.test.ts
|
||||
summary: Vitest e2e coverage for scoped Control UI assistant media tickets.
|
||||
@@ -3312,9 +3312,6 @@ if (canonicalProvider === "blacksmith-testbox") {
|
||||
console.error(
|
||||
`[crabbox] provider=blacksmith-testbox ${source}; if Testbox is queued or down, ${fallback}`,
|
||||
);
|
||||
console.error(
|
||||
"[crabbox] delegated Testbox proof uses the wrapper exitCode and timing JSON; the linked Actions run can show cancelled during external lease cleanup",
|
||||
);
|
||||
enforceCrabboxOwnedBlacksmithLease(normalizedArgs);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
|
||||
["SessionsCompactParams", ["agentId"]],
|
||||
["SessionsResolveParams", ["allowMissing"]],
|
||||
["SessionsUsageParams", ["agentId", "agentScope"]],
|
||||
["ChatHistoryParams", ["agentId", "offset"]],
|
||||
["ChatHistoryParams", ["agentId"]],
|
||||
["ChatSendParams", ["agentId"]],
|
||||
["ChatAbortParams", ["agentId"]],
|
||||
["ChatInjectParams", ["agentId"]],
|
||||
@@ -74,7 +74,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
|
||||
["ChatDeltaEvent", ["agentId"]],
|
||||
["ChatErrorEvent", ["agentId"]],
|
||||
["ChatFinalEvent", ["agentId"]],
|
||||
["ChatHistoryParams", ["agentId", "offset"]],
|
||||
["ChatHistoryParams", ["agentId"]],
|
||||
["ChatInjectParams", ["agentId"]],
|
||||
["ChatSendParams", ["agentId"]],
|
||||
["MessageActionParams", ["inboundTurnKind", "requesterAccountId"]],
|
||||
|
||||
@@ -514,7 +514,6 @@ export async function compactEmbeddedAgentSessionDirect(
|
||||
agentId: fallbackAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: fallbackSessionKey,
|
||||
abortSignal: params.abortSignal,
|
||||
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
config: params.config,
|
||||
|
||||
@@ -2761,8 +2761,6 @@ describe("runWithModelFallback", () => {
|
||||
|
||||
it("does not fall back on user aborts", async () => {
|
||||
const cfg = makeCfg();
|
||||
const controller = new AbortController();
|
||||
controller.abort(Object.assign(new Error("timeout"), { name: "TimeoutError" }));
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
|
||||
@@ -2773,7 +2771,6 @@ describe("runWithModelFallback", () => {
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
abortSignal: controller.signal,
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("aborted");
|
||||
@@ -2800,94 +2797,6 @@ describe("runWithModelFallback", () => {
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fall back when user cancels with AbortError reason", async () => {
|
||||
const cfg = makeCfg();
|
||||
const controller = new AbortController();
|
||||
controller.abort(Object.assign(new Error("cancelled"), { name: "AbortError" }));
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
|
||||
.mockResolvedValueOnce("should not run");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
abortSignal: controller.signal,
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("aborted");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fall back when caller cancellation uses a string reason", async () => {
|
||||
const cfg = makeCfg();
|
||||
const controller = new AbortController();
|
||||
controller.abort("Cancelled by operator.");
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
|
||||
.mockResolvedValueOnce("should not run");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
abortSignal: controller.signal,
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("aborted");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fall back when caller cancellation throws a plain error", async () => {
|
||||
const cfg = makeCfg();
|
||||
const controller = new AbortController();
|
||||
controller.abort("Cancelled by operator.");
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Cancelled by operator."))
|
||||
.mockResolvedValueOnce("should not run");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
abortSignal: controller.signal,
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("Cancelled by operator.");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when AbortError comes from the LLM provider (no external signal)", async () => {
|
||||
const cfg = makeProviderFallbackCfg("openai");
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("This operation was aborted"), { name: "AbortError" }),
|
||||
)
|
||||
.mockResolvedValueOnce({ payloads: [{ text: "fallback ok" }] });
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toEqual({ payloads: [{ text: "fallback ok" }] });
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(result.attempts[0]?.provider).toBe("openai");
|
||||
expect(result.attempts[0]?.error).toBe("This operation was aborted");
|
||||
});
|
||||
|
||||
it("does not fall back when the caller abort signal timed out", async () => {
|
||||
const cfg = makeCfg();
|
||||
const timeoutReason = new Error("chat run timed out");
|
||||
@@ -2945,37 +2854,6 @@ describe("runWithModelFallback", () => {
|
||||
expect(classifyResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fall back when a user AbortError is classified from the result", async () => {
|
||||
const cfg = makeProviderFallbackCfg("openai");
|
||||
const abortReason = new Error("chat run cancelled");
|
||||
abortReason.name = "AbortError";
|
||||
const controller = new AbortController();
|
||||
controller.abort(abortReason);
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ payloads: [] })
|
||||
.mockResolvedValueOnce({ payloads: [{ text: "fallback should not run" }] });
|
||||
const classifyResult = vi.fn(() => ({
|
||||
message: "This operation was aborted",
|
||||
reason: "timeout" as const,
|
||||
code: "terminal_abort",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "m1",
|
||||
abortSignal: controller.signal,
|
||||
run,
|
||||
classifyResult,
|
||||
}),
|
||||
).rejects.toThrow("This operation was aborted");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(classifyResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fall back when a restart abort is classified from the result", async () => {
|
||||
const cfg = makeProviderFallbackCfg("openai");
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
describeFailoverError,
|
||||
isFailoverError,
|
||||
isNonProviderRuntimeCoordinationError,
|
||||
isTimeoutError,
|
||||
} from "./failover-error.js";
|
||||
import {
|
||||
shouldAllowCooldownProbeForReason,
|
||||
@@ -188,6 +189,25 @@ type ModelFallbackRunFn<T> = (
|
||||
options?: ModelFallbackRunOptions,
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Fallback abort check. Only treats explicit AbortError names as user aborts.
|
||||
* Message-based checks (e.g., "aborted") can mask timeouts and skip fallback.
|
||||
*/
|
||||
function isFallbackAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (isFailoverError(err)) {
|
||||
return false;
|
||||
}
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
return name === "AbortError";
|
||||
}
|
||||
|
||||
function shouldRethrowAbort(err: unknown): boolean {
|
||||
return isFallbackAbortError(err) && !isTimeoutError(err);
|
||||
}
|
||||
|
||||
function isTerminalAbort(signal: AbortSignal | undefined): boolean {
|
||||
if (!signal?.aborted) {
|
||||
return false;
|
||||
@@ -205,10 +225,6 @@ function isTerminalAbort(signal: AbortSignal | undefined): boolean {
|
||||
return reason.name === "ClientDisconnectError";
|
||||
}
|
||||
|
||||
function isCallerAbortSignal(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
function createModelCandidateCollector(allowlist: Set<string> | null | undefined): {
|
||||
candidates: ModelCandidate[];
|
||||
addExplicitCandidate: (candidate: ModelCandidate) => void;
|
||||
@@ -351,10 +367,7 @@ async function runFallbackCandidate<T>(params: {
|
||||
if (isNonProviderRuntimeCoordinationError(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) {
|
||||
throw err;
|
||||
}
|
||||
if (isAgentRunRestartAbortReason(err)) {
|
||||
if (isTerminalAbort(params.abortSignal)) {
|
||||
throw err;
|
||||
}
|
||||
// Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED)
|
||||
@@ -365,6 +378,9 @@ async function runFallbackCandidate<T>(params: {
|
||||
sessionId: params.attribution?.sessionId,
|
||||
lane: params.attribution?.lane,
|
||||
});
|
||||
if (shouldRethrowAbort(err) && !normalizedFailover) {
|
||||
throw err;
|
||||
}
|
||||
return { ok: false, error: normalizedFailover ?? err };
|
||||
}
|
||||
}
|
||||
@@ -414,7 +430,7 @@ async function runFallbackAttempt<T>(params: {
|
||||
attribution: params.attribution,
|
||||
});
|
||||
if (classifiedError) {
|
||||
if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) {
|
||||
if (isTerminalAbort(params.abortSignal)) {
|
||||
throw toErrorObject(classifiedError, "Non-Error thrown");
|
||||
}
|
||||
const preserveResultOnExhaustion =
|
||||
@@ -1307,8 +1323,7 @@ function shouldDiscardDeferredSessionSuspension(params: {
|
||||
}): boolean {
|
||||
return (
|
||||
isTerminalAbort(params.abortSignal) ||
|
||||
isCallerAbortSignal(params.abortSignal) ||
|
||||
isAgentRunRestartAbortReason(params.error) ||
|
||||
shouldRethrowAbort(params.error) ||
|
||||
isCommandLaneTaskTimeoutError(params.error) ||
|
||||
isNonProviderRuntimeCoordinationError(params.error) ||
|
||||
isLikelyContextOverflowError(formatErrorMessage(params.error))
|
||||
|
||||
@@ -23,7 +23,7 @@ export function describeSessionsListTool(): string {
|
||||
export function describeSessionsHistoryTool(): string {
|
||||
return [
|
||||
"Fetch sanitized history for visible session.",
|
||||
"Use before replying, debugging, resuming; supports limit, offset pagination, and tool-message inclusion.",
|
||||
"Use before replying, debugging, resuming; supports limits/tool messages.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
export { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
export { getRuntimeConfig } from "../../config/config.js";
|
||||
export {
|
||||
dropPreSessionStartAnnouncePairs,
|
||||
projectChatDisplayMessages,
|
||||
projectRecentChatDisplayMessages,
|
||||
resolveEffectiveChatHistoryMaxChars,
|
||||
} from "../../gateway/chat-display-projection.js";
|
||||
@@ -22,8 +20,6 @@ export {
|
||||
} from "../../gateway/server-methods/chat.js";
|
||||
export {
|
||||
capArrayByJsonBytes,
|
||||
readRecentSessionMessagesWithStatsAsync,
|
||||
readSessionMessagesPageWithStatsAsync,
|
||||
readSessionMessagesAsync,
|
||||
} from "../../gateway/session-transcript-readers.js";
|
||||
export {
|
||||
|
||||
@@ -14,20 +14,10 @@ const runtime = vi.hoisted(() => ({
|
||||
})),
|
||||
resolveSessionModelRef: vi.fn(() => ({ provider: "openai" })),
|
||||
readSessionMessagesAsync: vi.fn(async (): Promise<unknown[]> => []),
|
||||
readRecentSessionMessagesWithStatsAsync: vi.fn(async () => ({
|
||||
messages: [] as unknown[],
|
||||
totalMessages: 0,
|
||||
})),
|
||||
readSessionMessagesPageWithStatsAsync: vi.fn(async () => ({
|
||||
messages: [] as unknown[],
|
||||
totalMessages: 0,
|
||||
})),
|
||||
augmentChatHistoryWithCliSessionImports: vi.fn(
|
||||
({ localMessages }: { localMessages?: unknown[] }) => localMessages ?? [],
|
||||
),
|
||||
resolveEffectiveChatHistoryMaxChars: vi.fn(() => 100_000),
|
||||
dropPreSessionStartAnnouncePairs: vi.fn((messages: unknown[]) => messages),
|
||||
projectChatDisplayMessages: vi.fn((messages: unknown[]): unknown[] => messages),
|
||||
projectRecentChatDisplayMessages: vi.fn((messages: unknown[]): unknown[] => messages),
|
||||
augmentChatHistoryWithCanvasBlocks: vi.fn((messages: unknown[]) => messages),
|
||||
getMaxChatHistoryMessagesBytes: vi.fn(() => 100_000),
|
||||
@@ -50,13 +40,8 @@ describe("embedded gateway stub", () => {
|
||||
beforeEach(() => {
|
||||
runtime.getRuntimeConfig.mockClear();
|
||||
runtime.resolveSessionKeyFromResolveParams.mockReset();
|
||||
runtime.augmentChatHistoryWithCliSessionImports.mockClear();
|
||||
runtime.projectChatDisplayMessages.mockClear();
|
||||
runtime.projectRecentChatDisplayMessages.mockClear();
|
||||
runtime.dropPreSessionStartAnnouncePairs.mockClear();
|
||||
runtime.readSessionMessagesAsync.mockClear();
|
||||
runtime.readRecentSessionMessagesWithStatsAsync.mockClear();
|
||||
runtime.readSessionMessagesPageWithStatsAsync.mockClear();
|
||||
runtime.loadSessionEntry.mockClear();
|
||||
runtime.resolveSessionAgentId.mockClear();
|
||||
runtime.loadCombinedSessionStoreForGateway.mockClear();
|
||||
@@ -125,7 +110,7 @@ describe("embedded gateway stub", () => {
|
||||
{ role: "assistant", content: "hi" },
|
||||
];
|
||||
const projectedMessages = [{ role: "assistant", content: "hi" }];
|
||||
runtime.readSessionMessagesAsync.mockImplementationOnce(async () => rawMessages);
|
||||
runtime.readSessionMessagesAsync.mockResolvedValueOnce(rawMessages);
|
||||
runtime.projectRecentChatDisplayMessages.mockReturnValueOnce(projectedMessages);
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
@@ -193,7 +178,7 @@ describe("embedded gateway stub", () => {
|
||||
{ role: "user", content: "visible older" },
|
||||
{ role: "assistant", content: "hidden newer" },
|
||||
];
|
||||
runtime.readSessionMessagesAsync.mockImplementationOnce(async () => rawMessages);
|
||||
runtime.readSessionMessagesAsync.mockResolvedValueOnce(rawMessages);
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
await callGateway<{ messages: unknown[] }>({
|
||||
@@ -222,221 +207,6 @@ describe("embedded gateway stub", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a bounded page read for offset chat history pages", async () => {
|
||||
const rawMessages = [
|
||||
{ role: "user", content: "oldest" },
|
||||
{ role: "assistant", content: "older" },
|
||||
{ role: "user", content: "newer" },
|
||||
{ role: "assistant", content: "latest" },
|
||||
];
|
||||
runtime.readSessionMessagesPageWithStatsAsync.mockImplementationOnce(async () => ({
|
||||
messages: rawMessages.slice(0, 2),
|
||||
totalMessages: rawMessages.length,
|
||||
}));
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
const result = await callGateway<{
|
||||
messages: unknown[];
|
||||
offset?: number;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
}>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", limit: 2, offset: 2 },
|
||||
});
|
||||
|
||||
expect(runtime.readSessionMessagesAsync).not.toHaveBeenCalled();
|
||||
expect(runtime.readSessionMessagesPageWithStatsAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionEntry: { sessionId: "sess-main" },
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
},
|
||||
{
|
||||
offset: 2,
|
||||
maxMessages: 3,
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
);
|
||||
expect(runtime.projectChatDisplayMessages).toHaveBeenCalledWith(rawMessages.slice(0, 2), {
|
||||
maxChars: 100_000,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
messages: rawMessages.slice(0, 2),
|
||||
offset: 2,
|
||||
hasMore: false,
|
||||
totalMessages: 4,
|
||||
});
|
||||
expect(result.nextOffset).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps projected offset chat history pages to the requested limit", async () => {
|
||||
const rawMessages = [
|
||||
{ role: "assistant", content: "overread", __openclaw: { seq: 1 } },
|
||||
{ role: "assistant", content: "page anchor", __openclaw: { seq: 2 } },
|
||||
];
|
||||
const projectedMessages = [
|
||||
{ role: "assistant", content: "projected one", __openclaw: { seq: 2 } },
|
||||
{ role: "assistant", content: "projected two", __openclaw: { seq: 3 } },
|
||||
];
|
||||
runtime.readSessionMessagesPageWithStatsAsync.mockImplementationOnce(async () => ({
|
||||
messages: rawMessages,
|
||||
totalMessages: 4,
|
||||
}));
|
||||
runtime.projectChatDisplayMessages.mockReturnValueOnce(projectedMessages);
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
const result = await callGateway<{
|
||||
messages: unknown[];
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
}>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", limit: 1, offset: 1 },
|
||||
});
|
||||
|
||||
expect(runtime.projectChatDisplayMessages).toHaveBeenCalledWith([rawMessages[1]], {
|
||||
maxChars: 100_000,
|
||||
});
|
||||
expect(result.messages).toEqual([projectedMessages[1]]);
|
||||
expect(result.nextOffset).toBe(2);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("filters offset chat history pages at the session start boundary", async () => {
|
||||
const rawMessages = [
|
||||
{ role: "user", content: "stale announce", __openclaw: { seq: 1 } },
|
||||
{ role: "assistant", content: "stale reply", __openclaw: { seq: 2 } },
|
||||
];
|
||||
const filteredMessages: unknown[] = [];
|
||||
runtime.loadSessionEntry.mockReturnValueOnce({
|
||||
cfg: {},
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
entry: { sessionId: "sess-main", sessionStartedAt: 1234 } as {
|
||||
sessionId: string;
|
||||
sessionStartedAt: number;
|
||||
},
|
||||
});
|
||||
runtime.readSessionMessagesPageWithStatsAsync.mockImplementationOnce(async () => ({
|
||||
messages: rawMessages,
|
||||
totalMessages: 2,
|
||||
}));
|
||||
runtime.dropPreSessionStartAnnouncePairs.mockReturnValueOnce(filteredMessages);
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
const result = await callGateway<{ messages: unknown[] }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", limit: 1, offset: 1 },
|
||||
});
|
||||
|
||||
expect(runtime.dropPreSessionStartAnnouncePairs).toHaveBeenCalledWith(rawMessages, 1234);
|
||||
expect(runtime.projectChatDisplayMessages).toHaveBeenCalledWith(filteredMessages, {
|
||||
maxChars: 100_000,
|
||||
});
|
||||
expect(result.messages).toEqual(filteredMessages);
|
||||
});
|
||||
|
||||
it("does not merge full CLI imports into explicit offset chat history pages", async () => {
|
||||
const rawMessages = [{ role: "assistant", content: "local page", __openclaw: { seq: 2 } }];
|
||||
runtime.readSessionMessagesPageWithStatsAsync.mockImplementationOnce(async () => ({
|
||||
messages: rawMessages,
|
||||
totalMessages: 2,
|
||||
}));
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
const result = await callGateway<{ messages: unknown[] }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", limit: 1, offset: 1 },
|
||||
});
|
||||
|
||||
expect(runtime.augmentChatHistoryWithCliSessionImports).not.toHaveBeenCalled();
|
||||
expect(result.messages).toEqual(rawMessages);
|
||||
});
|
||||
|
||||
it("overreads bounded recent history for the first offset page", async () => {
|
||||
const rawMessages = [
|
||||
{ role: "user", content: "visible older", __openclaw: { seq: 6 } },
|
||||
{ role: "assistant", content: "hidden control", __openclaw: { seq: 7 } },
|
||||
{ role: "assistant", content: "visible latest", __openclaw: { seq: 8 } },
|
||||
];
|
||||
const projectedMessages = [rawMessages[0], rawMessages[2]];
|
||||
runtime.readRecentSessionMessagesWithStatsAsync.mockImplementationOnce(async () => ({
|
||||
messages: rawMessages,
|
||||
totalMessages: 10,
|
||||
}));
|
||||
runtime.projectRecentChatDisplayMessages.mockReturnValueOnce(projectedMessages);
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
const result = await callGateway<{
|
||||
messages: unknown[];
|
||||
offset?: number;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
}>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", limit: 2, offset: 0 },
|
||||
});
|
||||
|
||||
expect(runtime.readSessionMessagesAsync).not.toHaveBeenCalled();
|
||||
expect(runtime.readRecentSessionMessagesWithStatsAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionEntry: { sessionId: "sess-main" },
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
},
|
||||
{
|
||||
maxMessages: 61,
|
||||
maxBytes: 1024 * 1024,
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
);
|
||||
expect(runtime.projectRecentChatDisplayMessages).toHaveBeenCalledWith(rawMessages, {
|
||||
maxChars: 100_000,
|
||||
maxMessages: 2,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
messages: projectedMessages,
|
||||
offset: 0,
|
||||
nextOffset: 5,
|
||||
hasMore: true,
|
||||
totalMessages: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("computes offset continuation from the final budgeted chat history page", async () => {
|
||||
const rawMessages = [
|
||||
{ role: "user", content: "visible older", __openclaw: { seq: 6 } },
|
||||
{ role: "assistant", content: "visible newer", __openclaw: { seq: 7 } },
|
||||
{ role: "assistant", content: "visible latest", __openclaw: { seq: 8 } },
|
||||
];
|
||||
const returnedMessages = [rawMessages[2]];
|
||||
runtime.readRecentSessionMessagesWithStatsAsync.mockImplementationOnce(async () => ({
|
||||
messages: rawMessages,
|
||||
totalMessages: 10,
|
||||
}));
|
||||
runtime.enforceChatHistoryFinalBudget.mockReturnValueOnce({ messages: returnedMessages });
|
||||
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
const result = await callGateway<{
|
||||
messages: unknown[];
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
}>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", limit: 3, offset: 0 },
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual(returnedMessages);
|
||||
expect(result.nextOffset).toBe(3);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes string chat history limits before projection", async () => {
|
||||
const rawMessages = [
|
||||
{ role: "user", content: "older" },
|
||||
@@ -488,22 +258,4 @@ describe("embedded gateway stub", () => {
|
||||
).rejects.toThrow("limit must be a positive integer");
|
||||
expect(runtime.readSessionMessagesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed chat history offsets before reading session files", async () => {
|
||||
const callGateway = createEmbeddedCallGateway();
|
||||
|
||||
await expect(
|
||||
callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", offset: -1 },
|
||||
}),
|
||||
).rejects.toThrow("offset must be a non-negative integer");
|
||||
await expect(
|
||||
callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "agent:main:main", offset: 1.5 },
|
||||
}),
|
||||
).rejects.toThrow("offset must be a non-negative integer");
|
||||
expect(runtime.readSessionMessagesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*
|
||||
* Implements only the Gateway calls needed by session tools and rejects unsupported methods.
|
||||
*/
|
||||
import { normalizeFastMode, type FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
SessionsListParams,
|
||||
SessionsResolveParams,
|
||||
@@ -16,8 +15,12 @@ import type {
|
||||
} from "../../gateway/session-transcript-readers.js";
|
||||
import type { SessionsListResult } from "../../gateway/session-utils.types.js";
|
||||
import type { SessionsResolveResult } from "../../gateway/sessions-resolve.js";
|
||||
import {
|
||||
normalizeFastMode,
|
||||
type FastMode,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { readNumberParam, readPositiveIntegerParam } from "./common.js";
|
||||
import { readPositiveIntegerParam } from "./common.js";
|
||||
|
||||
type EmbeddedCallGateway = <T = Record<string, unknown>>(opts: CallGatewayOptions) => Promise<T>;
|
||||
|
||||
@@ -44,11 +47,6 @@ interface EmbeddedGatewayRuntime {
|
||||
maxSingleMessageBytes: number;
|
||||
}) => { messages: unknown[] };
|
||||
resolveEffectiveChatHistoryMaxChars: (cfg: OpenClawConfig) => number;
|
||||
dropPreSessionStartAnnouncePairs: (
|
||||
messages: unknown[],
|
||||
sessionStartedAt: number | undefined,
|
||||
) => unknown[];
|
||||
projectChatDisplayMessages: (msgs: unknown[], opts?: { maxChars?: number }) => unknown[];
|
||||
projectRecentChatDisplayMessages: (
|
||||
msgs: unknown[],
|
||||
opts?: { maxChars?: number; maxMessages?: number },
|
||||
@@ -83,14 +81,6 @@ interface EmbeddedGatewayRuntime {
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: ReadSessionMessagesAsyncOptions,
|
||||
) => Promise<unknown[]>;
|
||||
readRecentSessionMessagesWithStatsAsync: (
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: { maxMessages: number; maxBytes?: number; allowResetArchiveFallback?: boolean },
|
||||
) => Promise<{ messages: unknown[]; totalMessages: number }>;
|
||||
readSessionMessagesPageWithStatsAsync: (
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: { offset: number; maxMessages: number; allowResetArchiveFallback?: boolean },
|
||||
) => Promise<{ messages: unknown[]; totalMessages: number }>;
|
||||
resolveSessionModelRef: (
|
||||
cfg: OpenClawConfig,
|
||||
entry: unknown,
|
||||
@@ -108,76 +98,6 @@ async function getRuntime(): Promise<EmbeddedGatewayRuntime> {
|
||||
return runtimeMod;
|
||||
}
|
||||
|
||||
function readOffsetParam(params: Record<string, unknown>): number | undefined {
|
||||
const offset = readNumberParam(params, "offset", {
|
||||
integer: true,
|
||||
nonNegativeInteger: true,
|
||||
});
|
||||
if (params.offset !== undefined && offset === undefined) {
|
||||
throw new Error("offset must be a non-negative integer");
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
function readChatHistoryMessageSeq(message: unknown): number | undefined {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata = (message as Record<string, unknown>)["__openclaw"];
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined;
|
||||
}
|
||||
const seq = (metadata as Record<string, unknown>).seq;
|
||||
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
|
||||
}
|
||||
|
||||
function resolveChatHistoryNextOffset(params: {
|
||||
messages: unknown[];
|
||||
totalMessages: number;
|
||||
offset: number;
|
||||
rawPageMessages: number;
|
||||
}): number {
|
||||
const oldestSeq = params.messages
|
||||
.map((message) => readChatHistoryMessageSeq(message))
|
||||
.find((seq): seq is number => typeof seq === "number");
|
||||
if (oldestSeq !== undefined) {
|
||||
return Math.max(params.offset, params.totalMessages - oldestSeq + 1);
|
||||
}
|
||||
return params.offset + params.rawPageMessages;
|
||||
}
|
||||
|
||||
function capOffsetChatHistoryProjectedMessages(messages: unknown[], max: number): unknown[] {
|
||||
if (messages.length <= max) {
|
||||
return messages;
|
||||
}
|
||||
const start = Math.max(0, messages.length - max);
|
||||
const boundarySeq = readChatHistoryMessageSeq(messages[start]);
|
||||
if (boundarySeq === undefined) {
|
||||
return messages.slice(start);
|
||||
}
|
||||
// Offset cursors can only resume at transcript-record boundaries.
|
||||
// Keep boundary rows with the same seq together so projection mirrors are not stranded.
|
||||
let safeStart = start;
|
||||
while (safeStart > 0 && readChatHistoryMessageSeq(messages[safeStart - 1]) === boundarySeq) {
|
||||
safeStart--;
|
||||
}
|
||||
return messages.slice(safeStart);
|
||||
}
|
||||
|
||||
function dropChatHistoryOverreadContextMessage(
|
||||
messages: unknown[],
|
||||
contextMessage: unknown,
|
||||
): unknown[] {
|
||||
if (contextMessage === undefined) {
|
||||
return messages;
|
||||
}
|
||||
const index = messages.indexOf(contextMessage);
|
||||
if (index < 0) {
|
||||
return messages;
|
||||
}
|
||||
return [...messages.slice(0, index), ...messages.slice(index + 1)];
|
||||
}
|
||||
|
||||
async function handleSessionsList(params: Record<string, unknown>) {
|
||||
const rt = await getRuntime();
|
||||
const cfg = rt.getRuntimeConfig();
|
||||
@@ -213,10 +133,6 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
sessionKey: string;
|
||||
sessionId: string | undefined;
|
||||
messages: unknown[];
|
||||
offset?: number;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
thinkingLevel?: string;
|
||||
fastMode?: FastMode;
|
||||
verboseLevel?: string;
|
||||
@@ -228,7 +144,6 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
const parsedAgentId = parseAgentSessionKey(sessionKey)?.agentId;
|
||||
const requestedAgentId = agentId ?? parsedAgentId;
|
||||
const limit = readPositiveIntegerParam(params, "limit");
|
||||
const offset = readOffsetParam(params) ?? 0;
|
||||
|
||||
const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined;
|
||||
const { cfg, storePath, entry } = rt.loadSessionEntry(sessionKey, sessionLoadOptions);
|
||||
@@ -243,7 +158,6 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
const defaultLimit = 200;
|
||||
const requested = typeof limit === "number" ? limit : defaultLimit;
|
||||
const max = Math.min(hardMax, requested);
|
||||
const rawHistoryWindowMessages = max * 20 + 20;
|
||||
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
|
||||
const sessionEntry =
|
||||
typeof entry?.sessionId === "string"
|
||||
@@ -254,7 +168,7 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
: undefined;
|
||||
|
||||
const localMessages =
|
||||
params.offset === undefined && sessionId && storePath
|
||||
sessionId && storePath
|
||||
? await rt.readSessionMessagesAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
@@ -263,105 +177,30 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
params.offset === undefined
|
||||
? {
|
||||
mode: "recent",
|
||||
maxMessages: max,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
allowResetArchiveFallback: true,
|
||||
}
|
||||
: {
|
||||
mode: "full",
|
||||
reason: "chat.history offset pagination",
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
{
|
||||
mode: "recent",
|
||||
maxMessages: max,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
)
|
||||
: [];
|
||||
const offsetPage =
|
||||
params.offset !== undefined && sessionId && storePath
|
||||
? offset === 0
|
||||
? await rt.readRecentSessionMessagesWithStatsAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionEntry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
maxMessages: rawHistoryWindowMessages + 1,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
)
|
||||
: await rt.readSessionMessagesPageWithStatsAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionEntry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
offset,
|
||||
maxMessages: max + 1,
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const sessionStartedAt =
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined;
|
||||
const offsetPageOverreadContextMessage =
|
||||
offsetPage !== undefined
|
||||
? offset === 0
|
||||
? offsetPage.messages.length > rawHistoryWindowMessages
|
||||
? offsetPage.messages[0]
|
||||
: undefined
|
||||
: offsetPage.messages.length > max
|
||||
? offsetPage.messages[0]
|
||||
: undefined
|
||||
: undefined;
|
||||
const localMessagesForHistory =
|
||||
offsetPage !== undefined
|
||||
? dropChatHistoryOverreadContextMessage(
|
||||
rt.dropPreSessionStartAnnouncePairs(offsetPage.messages, sessionStartedAt),
|
||||
offsetPageOverreadContextMessage,
|
||||
)
|
||||
: localMessages;
|
||||
const rawMessages =
|
||||
params.offset === undefined
|
||||
? rt.augmentChatHistoryWithCliSessionImports({
|
||||
entry,
|
||||
provider: resolvedSessionModel.provider,
|
||||
localMessages: localMessagesForHistory,
|
||||
})
|
||||
: localMessagesForHistory;
|
||||
const recencyFilteredMessages = rt.dropPreSessionStartAnnouncePairs(
|
||||
rawMessages,
|
||||
sessionStartedAt,
|
||||
);
|
||||
const rawMessages = rt.augmentChatHistoryWithCliSessionImports({
|
||||
entry,
|
||||
provider: resolvedSessionModel.provider,
|
||||
localMessages,
|
||||
});
|
||||
|
||||
const effectiveMaxChars = rt.resolveEffectiveChatHistoryMaxChars(cfg);
|
||||
|
||||
// Mirror Gateway chat.history trimming so embedded mode has the same byte ceilings.
|
||||
const projected =
|
||||
params.offset === undefined
|
||||
? rt.projectRecentChatDisplayMessages(recencyFilteredMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
maxMessages: max,
|
||||
})
|
||||
: offset === 0
|
||||
? rt.projectRecentChatDisplayMessages(recencyFilteredMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
maxMessages: max,
|
||||
})
|
||||
: rt.projectChatDisplayMessages(recencyFilteredMessages, { maxChars: effectiveMaxChars });
|
||||
const windowed =
|
||||
params.offset === undefined || offset === 0
|
||||
? projected
|
||||
: capOffsetChatHistoryProjectedMessages(projected, max);
|
||||
const normalized = rt.augmentChatHistoryWithCanvasBlocks(windowed);
|
||||
const normalized = rt.augmentChatHistoryWithCanvasBlocks(
|
||||
rt.projectRecentChatDisplayMessages(rawMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
maxMessages: max,
|
||||
}),
|
||||
);
|
||||
|
||||
const perMessageHardCap = Math.min(rt.CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
|
||||
const replaced = rt.replaceOversizedChatHistoryMessages({
|
||||
@@ -370,28 +209,11 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
});
|
||||
const capped = rt.capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
|
||||
const bounded = rt.enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
|
||||
const nextOffset =
|
||||
offsetPage !== undefined
|
||||
? resolveChatHistoryNextOffset({
|
||||
messages: bounded.messages,
|
||||
totalMessages: offsetPage.totalMessages,
|
||||
offset,
|
||||
rawPageMessages:
|
||||
offset === 0
|
||||
? offsetPage.messages.length
|
||||
: Math.min(max, Math.max(0, offsetPage.totalMessages - offset)),
|
||||
})
|
||||
: 0;
|
||||
const hasMore = offsetPage !== undefined ? nextOffset < offsetPage.totalMessages : false;
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
sessionId,
|
||||
messages: bounded.messages,
|
||||
...(params.offset !== undefined
|
||||
? { offset, hasMore, totalMessages: offsetPage?.totalMessages ?? projected.length }
|
||||
: {}),
|
||||
...(hasMore && offsetPage !== undefined ? { nextOffset } : {}),
|
||||
thinkingLevel: entry?.thinkingLevel as string | undefined,
|
||||
fastMode: normalizeFastMode(entry?.fastMode),
|
||||
verboseLevel: entry?.verboseLevel as string | undefined,
|
||||
|
||||
@@ -8,11 +8,6 @@ import type { callGateway as gatewayCall } from "../../gateway/call.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
||||
|
||||
type CallGatewayRequest = Parameters<typeof gatewayCall>[0];
|
||||
type HistoryMessage = {
|
||||
role: string;
|
||||
content: string;
|
||||
__openclaw: { seq: number };
|
||||
};
|
||||
|
||||
let createSessionsHistoryTool: typeof import("./sessions-history-tool.js").createSessionsHistoryTool;
|
||||
let previousConfigPath: string | undefined;
|
||||
@@ -46,22 +41,6 @@ function createHistoryToolWithMessage(content: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function readHistoryDetails(result: { details: unknown }) {
|
||||
return result.details as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readMessageSeq(message: unknown): number | undefined {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return undefined;
|
||||
}
|
||||
const meta = (message as Record<string, unknown>)["__openclaw"];
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
return undefined;
|
||||
}
|
||||
const seq = (meta as Record<string, unknown>).seq;
|
||||
return typeof seq === "number" ? seq : undefined;
|
||||
}
|
||||
|
||||
describe("sessions_history redaction", () => {
|
||||
beforeAll(async () => {
|
||||
previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
@@ -117,136 +96,4 @@ describe("sessions_history redaction", () => {
|
||||
"limit must be a positive integer",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([-1, 1.5])("rejects invalid offset value %s", async (offset) => {
|
||||
const tool = createHistoryToolWithMessage("hello");
|
||||
|
||||
await expect(tool.execute("call-1", { sessionKey: "main", offset })).rejects.toThrow(
|
||||
"offset must be a non-negative integer",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the bounded default history request", async () => {
|
||||
const requests: CallGatewayRequest[] = [];
|
||||
const tool = createSessionsHistoryTool({
|
||||
config: {},
|
||||
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
|
||||
requests.push(request);
|
||||
return { messages: [{ role: "assistant", content: "latest" }] } as T;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-1", { sessionKey: "main", limit: 2 });
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "main", limit: 2 },
|
||||
});
|
||||
expect((requests[0].params as Record<string, unknown>).offset).toBeUndefined();
|
||||
expect((result.details as Record<string, unknown>).offset).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requests explicit offset pages and returns continuation metadata", async () => {
|
||||
const requests: CallGatewayRequest[] = [];
|
||||
const tool = createSessionsHistoryTool({
|
||||
config: {},
|
||||
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
|
||||
requests.push(request);
|
||||
return {
|
||||
messages: [
|
||||
{ role: "user", content: "newer" },
|
||||
{ role: "assistant", content: "latest" },
|
||||
],
|
||||
offset: 0,
|
||||
nextOffset: 2,
|
||||
hasMore: true,
|
||||
totalMessages: 4,
|
||||
} as T;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-1", { sessionKey: "main", limit: 2, offset: 0 });
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: "main", limit: 2, offset: 0 },
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
offset: 0,
|
||||
nextOffset: 2,
|
||||
hasMore: true,
|
||||
totalMessages: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("recomputes pagination after the tool byte cap drops older returned messages", async () => {
|
||||
const messages: HistoryMessage[] = Array.from({ length: 30 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
content: `message-${index + 1} ${"x".repeat(10_000)}`,
|
||||
__openclaw: { seq: index + 1 },
|
||||
}));
|
||||
const tool = createSessionsHistoryTool({
|
||||
config: {},
|
||||
callGateway: async <T = Record<string, unknown>>(): Promise<T> =>
|
||||
({
|
||||
messages,
|
||||
offset: 0,
|
||||
nextOffset: 30,
|
||||
hasMore: false,
|
||||
totalMessages: 30,
|
||||
}) as T,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-1", { sessionKey: "main", offset: 0 });
|
||||
const details = readHistoryDetails(result);
|
||||
const returnedMessages = details.messages as unknown[];
|
||||
const oldestReturnedSeq = readMessageSeq(returnedMessages[0]);
|
||||
|
||||
expect(returnedMessages.length).toBeGreaterThan(0);
|
||||
expect(returnedMessages.length).toBeLessThan(messages.length);
|
||||
expect(typeof oldestReturnedSeq).toBe("number");
|
||||
const expectedNextOffset = 30 - oldestReturnedSeq! + 1;
|
||||
expect(oldestReturnedSeq).toBeGreaterThan(1);
|
||||
expect(details).toMatchObject({
|
||||
offset: 0,
|
||||
nextOffset: expectedNextOffset,
|
||||
hasMore: true,
|
||||
totalMessages: 30,
|
||||
truncated: true,
|
||||
droppedMessages: true,
|
||||
});
|
||||
expect(details.nextOffset).not.toBe(30);
|
||||
});
|
||||
|
||||
it("uses the oldest visible message for pagination after tool messages are filtered", async () => {
|
||||
const tool = createSessionsHistoryTool({
|
||||
config: {},
|
||||
callGateway: async <T = Record<string, unknown>>(): Promise<T> =>
|
||||
({
|
||||
messages: [
|
||||
{ role: "tool", content: "hidden", __openclaw: { seq: 6 } },
|
||||
{ role: "assistant", content: "visible", __openclaw: { seq: 7 } },
|
||||
{ role: "assistant", content: "latest", __openclaw: { seq: 8 } },
|
||||
],
|
||||
offset: 0,
|
||||
nextOffset: 5,
|
||||
hasMore: true,
|
||||
totalMessages: 10,
|
||||
}) as T,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-1", { sessionKey: "main", offset: 0 });
|
||||
const details = readHistoryDetails(result);
|
||||
|
||||
expect(details.messages).toEqual([
|
||||
{ role: "assistant", content: "visible", __openclaw: { seq: 7 } },
|
||||
{ role: "assistant", content: "latest", __openclaw: { seq: 8 } },
|
||||
]);
|
||||
expect(details).toMatchObject({
|
||||
offset: 0,
|
||||
nextOffset: 4,
|
||||
hasMore: true,
|
||||
totalMessages: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,13 +19,7 @@ import {
|
||||
} from "../tool-description-presets.js";
|
||||
import { stripToolMessages } from "./chat-history-text.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
ToolInputError,
|
||||
} from "./common.js";
|
||||
import { jsonResult, readPositiveIntegerParam, readStringParam } from "./common.js";
|
||||
import {
|
||||
createSessionVisibilityGuard,
|
||||
createAgentToAgentPolicy,
|
||||
@@ -38,30 +32,12 @@ import {
|
||||
const SessionsHistoryToolSchema = Type.Object({
|
||||
sessionKey: Type.String(),
|
||||
limit: optionalPositiveIntegerSchema(),
|
||||
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
includeTools: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SESSIONS_HISTORY_MAX_BYTES = 80 * 1024;
|
||||
const SESSIONS_HISTORY_TEXT_MAX_CHARS = 4000;
|
||||
type GatewayCaller = typeof callGateway;
|
||||
type ChatHistoryPaginationMetadata = {
|
||||
offset?: number;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
};
|
||||
|
||||
function readOffsetParam(params: Record<string, unknown>): number | undefined {
|
||||
const offset = readNumberParam(params, "offset", {
|
||||
integer: true,
|
||||
nonNegativeInteger: true,
|
||||
});
|
||||
if (params.offset !== undefined && offset === undefined) {
|
||||
throw new ToolInputError("offset must be a non-negative integer");
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// sandbox policy handling is shared with sessions-list-tool via sessions-helpers.ts
|
||||
|
||||
@@ -198,82 +174,15 @@ function enforceSessionsHistoryHardCap(params: {
|
||||
return { items: lastOnly, bytes: lastBytes, hardCapped: true };
|
||||
}
|
||||
|
||||
const placeholder = [buildSessionsHistoryOmittedPlaceholder(last)];
|
||||
const placeholder = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "[sessions_history omitted: message too large]",
|
||||
},
|
||||
];
|
||||
return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true };
|
||||
}
|
||||
|
||||
function readHistoryMessageSeq(message: unknown): number | undefined {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return undefined;
|
||||
}
|
||||
const meta = (message as Record<string, unknown>)["__openclaw"];
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
return undefined;
|
||||
}
|
||||
const seq = (meta as Record<string, unknown>).seq;
|
||||
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
|
||||
}
|
||||
|
||||
function buildSessionsHistoryOmittedPlaceholder(source: unknown): Record<string, unknown> {
|
||||
const seq = readHistoryMessageSeq(source);
|
||||
return {
|
||||
role: "assistant",
|
||||
content: "[sessions_history omitted: message too large]",
|
||||
...(seq !== undefined ? { __openclaw: { seq } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionsHistoryPaginationMetadata(params: {
|
||||
messages: unknown[];
|
||||
result: ChatHistoryPaginationMetadata | undefined;
|
||||
requestedOffset: number | undefined;
|
||||
}): ChatHistoryPaginationMetadata {
|
||||
const result = params.result;
|
||||
const offset =
|
||||
typeof result?.offset === "number"
|
||||
? result.offset
|
||||
: params.requestedOffset !== undefined
|
||||
? params.requestedOffset
|
||||
: undefined;
|
||||
if (offset === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const totalMessages =
|
||||
typeof result?.totalMessages === "number" ? result.totalMessages : undefined;
|
||||
if (totalMessages === undefined) {
|
||||
return {
|
||||
offset,
|
||||
...(typeof result?.nextOffset === "number" ? { nextOffset: result.nextOffset } : {}),
|
||||
...(typeof result?.hasMore === "boolean" ? { hasMore: result.hasMore } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Gateway offsets count newest transcript rows already returned. Recompute
|
||||
// from the oldest surviving seq after this tool's own filter/cap passes.
|
||||
const oldestSeq = params.messages
|
||||
.map((message) => readHistoryMessageSeq(message))
|
||||
.find((seq): seq is number => typeof seq === "number");
|
||||
const nextOffset =
|
||||
oldestSeq !== undefined
|
||||
? Math.max(offset, totalMessages - oldestSeq + 1)
|
||||
: typeof result?.nextOffset === "number"
|
||||
? result.nextOffset
|
||||
: undefined;
|
||||
const hasMore =
|
||||
nextOffset !== undefined
|
||||
? nextOffset < totalMessages
|
||||
: typeof result?.hasMore === "boolean"
|
||||
? result.hasMore
|
||||
: undefined;
|
||||
return {
|
||||
offset,
|
||||
...(hasMore === true && nextOffset !== undefined ? { nextOffset } : {}),
|
||||
...(hasMore !== undefined ? { hasMore } : {}),
|
||||
totalMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionsHistoryTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
@@ -345,21 +254,10 @@ export function createSessionsHistoryTool(opts?: {
|
||||
}
|
||||
|
||||
const limit = readPositiveIntegerParam(params, "limit");
|
||||
const offset = readOffsetParam(params);
|
||||
const includeTools = Boolean(params.includeTools);
|
||||
const result = await gatewayCall<{
|
||||
messages: Array<unknown>;
|
||||
offset?: number;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
}>({
|
||||
const result = await gatewayCall<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: {
|
||||
sessionKey: resolvedKey,
|
||||
limit,
|
||||
...(offset !== undefined ? { offset } : {}),
|
||||
},
|
||||
params: { sessionKey: resolvedKey, limit },
|
||||
});
|
||||
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
|
||||
const selectedMessages = includeTools ? rawMessages : stripToolMessages(rawMessages);
|
||||
@@ -376,11 +274,6 @@ export function createSessionsHistoryTool(opts?: {
|
||||
bytes: cappedMessages.bytes,
|
||||
maxBytes: SESSIONS_HISTORY_MAX_BYTES,
|
||||
});
|
||||
const pagination = resolveSessionsHistoryPaginationMetadata({
|
||||
messages: hardened.items,
|
||||
result,
|
||||
requestedOffset: offset,
|
||||
});
|
||||
return jsonResult({
|
||||
sessionKey: displayKey,
|
||||
messages: hardened.items,
|
||||
@@ -389,7 +282,6 @@ export function createSessionsHistoryTool(opts?: {
|
||||
contentTruncated,
|
||||
contentRedacted,
|
||||
bytes: hardened.bytes,
|
||||
...pagination,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,7 +87,6 @@ import { CommandLaneClearedError, GatewayDrainingError } from "../../process/com
|
||||
import { CommandLane } from "../../process/lanes.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
|
||||
import { truncateUtf16Safe } from "../../shared/utf16-slice.js";
|
||||
import {
|
||||
isMarkdownCapableMessageChannel,
|
||||
resolveMessageChannel,
|
||||
@@ -754,7 +753,7 @@ function extractCodexUsageLimitMessage(text: string): string | undefined {
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
return message.length > 500 ? `${truncateUtf16Safe(message, 497)}...` : message;
|
||||
return message.length > 500 ? `${message.slice(0, 497)}...` : message;
|
||||
}
|
||||
|
||||
function isPureTransientRateLimitSummary(err: unknown): boolean {
|
||||
|
||||
@@ -95,7 +95,6 @@ import { isAcpSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import { resolveSilentReplyPolicyFromPolicies } from "../../shared/silent-reply-policy.js";
|
||||
import { truncateUtf16Safe } from "../../shared/utf16-slice.js";
|
||||
import { createTtsDirectiveTextStreamCleaner } from "../../tts/directives.js";
|
||||
import {
|
||||
normalizeTtsAutoMode,
|
||||
@@ -2566,7 +2565,7 @@ export async function dispatchReplyFromConfig(
|
||||
if (collapsed.length <= 80) {
|
||||
return collapsed;
|
||||
}
|
||||
return `${truncateUtf16Safe(collapsed, 77).trimEnd()}...`;
|
||||
return `${collapsed.slice(0, 77).trimEnd()}...`;
|
||||
};
|
||||
const formatPlanUpdateText = (payload: { explanation?: string; steps?: string[] }) => {
|
||||
const explanation = payload.explanation?.replace(/\s+/g, " ").trim();
|
||||
|
||||
@@ -320,167 +320,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a missing configured plugin install to a structured finding and dry-run effect", async () => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "matrix",
|
||||
pluginId: "matrix",
|
||||
meta: { label: "Matrix" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/plugin-matrix@1.2.3",
|
||||
expectedIntegrity: "sha512-test",
|
||||
},
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
configuredPluginInstallIssueToHealthFinding,
|
||||
configuredPluginInstallIssueToRepairEffect,
|
||||
detectConfiguredPluginInstallHealthIssues,
|
||||
} = await import("./missing-configured-plugin-install.js");
|
||||
const [issue] = await detectConfiguredPluginInstallHealthIssues({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: { enabled: true, homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(issue).toEqual({
|
||||
kind: "missing-install-record",
|
||||
pluginId: "matrix",
|
||||
installSpec: "@openclaw/plugin-matrix@1.2.3",
|
||||
});
|
||||
expect(configuredPluginInstallIssueToHealthFinding(issue)).toMatchObject({
|
||||
checkId: "core/doctor/configured-plugin-installs",
|
||||
severity: "warning",
|
||||
target: "matrix",
|
||||
fixHint: "Run `openclaw doctor --fix` to install @openclaw/plugin-matrix@1.2.3.",
|
||||
});
|
||||
expect(configuredPluginInstallIssueToRepairEffect(issue)).toEqual({
|
||||
kind: "package",
|
||||
action: "would-install-configured-plugin",
|
||||
target: "matrix",
|
||||
dryRunSafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps package-update deferrals to structured findings without installing packages", async () => {
|
||||
const missingDiscordPath = path.resolve("/missing/discord");
|
||||
const records = {
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/discord",
|
||||
installPath: missingDiscordPath,
|
||||
},
|
||||
};
|
||||
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "discord",
|
||||
pluginId: "discord",
|
||||
meta: { label: "Discord" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/discord",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
configuredPluginInstallIssueToHealthFinding,
|
||||
configuredPluginInstallIssueToRepairEffect,
|
||||
detectConfiguredPluginInstallHealthIssues,
|
||||
} = await import("./missing-configured-plugin-install.js");
|
||||
const [issue] = await detectConfiguredPluginInstallHealthIssues({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
discord: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: { enabled: true },
|
||||
},
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
|
||||
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(issue).toEqual({
|
||||
kind: "deferred-package-manager-repair",
|
||||
pluginId: "discord",
|
||||
installPath: missingDiscordPath,
|
||||
});
|
||||
expect(configuredPluginInstallIssueToHealthFinding(issue)).toMatchObject({
|
||||
checkId: "core/doctor/configured-plugin-installs",
|
||||
severity: "warning",
|
||||
path: missingDiscordPath,
|
||||
target: "discord",
|
||||
});
|
||||
expect(configuredPluginInstallIssueToRepairEffect(issue)).toEqual({
|
||||
kind: "package",
|
||||
action: "would-defer-configured-plugin-install-repair",
|
||||
target: "discord",
|
||||
dryRunSafe: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports one finding when a configured plugin record points at a missing package", async () => {
|
||||
const missingDiscordPath = path.resolve("/missing/discord");
|
||||
const records = {
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/discord",
|
||||
installPath: missingDiscordPath,
|
||||
},
|
||||
};
|
||||
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "discord",
|
||||
pluginId: "discord",
|
||||
meta: { label: "Discord" },
|
||||
install: {
|
||||
npmSpec: "@openclaw/discord",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { detectConfiguredPluginInstallHealthIssues } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const issues = await detectConfiguredPluginInstallHealthIssues({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
discord: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: { enabled: true },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(issues).toEqual([
|
||||
{
|
||||
kind: "missing-installed-payload",
|
||||
pluginId: "discord",
|
||||
installPath: missingDiscordPath,
|
||||
installSpec: "@openclaw/discord",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("installs a missing configured OpenClaw channel plugin from npm by default", async () => {
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { listRawChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../../../flows/health-checks.js";
|
||||
import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js";
|
||||
import {
|
||||
compareOpenClawReleaseVersions,
|
||||
@@ -101,7 +100,6 @@ type BundledPluginPackageDescriptor = {
|
||||
packageName?: string;
|
||||
};
|
||||
|
||||
const CONFIGURED_PLUGIN_INSTALLS_CHECK_ID = "core/doctor/configured-plugin-installs";
|
||||
const MISSING_CHANNEL_CONFIG_DESCRIPTOR_DIAGNOSTIC = "without channelConfigs metadata";
|
||||
const REPAIRABLE_PACKAGE_ENTRY_DIAGNOSTIC_MARKERS = [
|
||||
"extension entry escapes package directory",
|
||||
@@ -995,41 +993,6 @@ function recordClawHubPackageName(value: string | undefined): string | undefined
|
||||
|
||||
type InstallCandidateRepairReason = "stale-version-bound-runtime";
|
||||
|
||||
export type ConfiguredPluginInstallHealthIssue =
|
||||
| {
|
||||
kind: "missing-install-record";
|
||||
pluginId: string;
|
||||
installSpec: string;
|
||||
}
|
||||
| {
|
||||
kind: "missing-installed-payload";
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
installSpec?: string;
|
||||
}
|
||||
| {
|
||||
kind: "repairable-installed-plugin";
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
installSpec?: string;
|
||||
}
|
||||
| {
|
||||
kind: "stale-version-bound-runtime";
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
installSpec?: string;
|
||||
}
|
||||
| {
|
||||
kind: "stale-channel-config-descriptor";
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
}
|
||||
| {
|
||||
kind: "deferred-package-manager-repair";
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
};
|
||||
|
||||
function formatInstalledConfiguredPluginChange(params: {
|
||||
pluginId: string;
|
||||
installSpec: string;
|
||||
@@ -1350,416 +1313,6 @@ async function adoptExistingNpmPackage(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCandidateInstallSpec(params: {
|
||||
candidate: DownloadableInstallCandidate;
|
||||
updateChannel: UpdateChannel;
|
||||
}): string | undefined {
|
||||
if (params.candidate.defaultChoice !== "npm" && params.candidate.clawhubSpec) {
|
||||
return resolveClawHubInstallSpecsForUpdateChannel({
|
||||
spec: params.candidate.clawhubSpec,
|
||||
updateChannel: params.updateChannel,
|
||||
}).installSpec;
|
||||
}
|
||||
if (params.candidate.npmSpec) {
|
||||
return resolveNpmInstallSpecsForUpdateChannel({
|
||||
spec: params.candidate.npmSpec,
|
||||
updateChannel: params.updateChannel,
|
||||
}).installSpec;
|
||||
}
|
||||
if (params.candidate.clawhubSpec) {
|
||||
return resolveClawHubInstallSpecsForUpdateChannel({
|
||||
spec: params.candidate.clawhubSpec,
|
||||
updateChannel: params.updateChannel,
|
||||
}).installSpec;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRecordInstallPath(
|
||||
record: PluginInstallRecord | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
const installPath = record?.installPath?.trim();
|
||||
return installPath ? resolveUserPath(installPath, env) : undefined;
|
||||
}
|
||||
|
||||
function missingRecordedPluginIssueKind(params: {
|
||||
pluginId: string;
|
||||
staleVersionBoundRuntimePluginIds: ReadonlySet<string>;
|
||||
repairablePackageDiagnosticPluginIds: ReadonlySet<string>;
|
||||
staleDescriptorPluginIds: ReadonlySet<string>;
|
||||
}):
|
||||
| "missing-installed-payload"
|
||||
| "repairable-installed-plugin"
|
||||
| "stale-channel-config-descriptor"
|
||||
| "stale-version-bound-runtime" {
|
||||
if (params.staleVersionBoundRuntimePluginIds.has(params.pluginId)) {
|
||||
return "stale-version-bound-runtime";
|
||||
}
|
||||
if (params.repairablePackageDiagnosticPluginIds.has(params.pluginId)) {
|
||||
return "repairable-installed-plugin";
|
||||
}
|
||||
if (params.staleDescriptorPluginIds.has(params.pluginId)) {
|
||||
return "stale-channel-config-descriptor";
|
||||
}
|
||||
return "missing-installed-payload";
|
||||
}
|
||||
|
||||
/** Detect configured plugin installs that Doctor can repair without mutating package state. */
|
||||
export async function detectConfiguredPluginInstallHealthIssues(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
baselineRecords?: Record<string, PluginInstallRecord>;
|
||||
}): Promise<ConfiguredPluginInstallHealthIssue[]> {
|
||||
const env = params.env ?? process.env;
|
||||
const pluginIds = collectConfiguredPluginIds(params.cfg, env);
|
||||
const channelIds = collectConfiguredChannelIds(params.cfg, env);
|
||||
const blockedPluginIds = collectBlockedPluginIds(params.cfg);
|
||||
const snapshot = loadManifestMetadataSnapshot({
|
||||
config: params.cfg,
|
||||
env,
|
||||
});
|
||||
const currentBundledPlugins = loadInstalledPluginIndex({
|
||||
config: params.cfg,
|
||||
env,
|
||||
installRecords: {},
|
||||
}).plugins.filter((plugin) => plugin.origin === "bundled");
|
||||
const knownIds = new Set([
|
||||
...snapshot.plugins.map((plugin) => plugin.id),
|
||||
...currentBundledPlugins.map((plugin) => plugin.pluginId),
|
||||
]);
|
||||
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
snapshot,
|
||||
configuredChannelIds: channelIds,
|
||||
});
|
||||
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
|
||||
...snapshot.plugins
|
||||
.filter((plugin) => plugin.origin === "bundled")
|
||||
.map((plugin) => [plugin.id, plugin] as const),
|
||||
...currentBundledPlugins.map(
|
||||
(plugin) =>
|
||||
[
|
||||
plugin.pluginId,
|
||||
{
|
||||
packageName: plugin.packageName,
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
]);
|
||||
const staleDescriptorPluginIds = collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
|
||||
snapshot,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
});
|
||||
const records = params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env }));
|
||||
const updateChannel = resolveRegistryUpdateChannel({
|
||||
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
|
||||
currentVersion: VERSION,
|
||||
});
|
||||
const repairablePackageDiagnosticPluginIds =
|
||||
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
});
|
||||
const staleVersionBoundRuntimePluginIds =
|
||||
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
|
||||
snapshot,
|
||||
installRecords: records,
|
||||
configuredPluginIds: pluginIds,
|
||||
updateChannel,
|
||||
});
|
||||
const repairableInstalledPluginIds = new Set([
|
||||
...repairablePackageDiagnosticPluginIds,
|
||||
...staleVersionBoundRuntimePluginIds,
|
||||
]);
|
||||
const officialReplacementInstallCandidates = collectOfficialReplacementInstallCandidates({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
repairablePluginIds: repairableInstalledPluginIds,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds,
|
||||
});
|
||||
const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys());
|
||||
const deferredPluginIds = new Set<string>();
|
||||
const reportedPluginIds = new Set<string>();
|
||||
const issues: ConfiguredPluginInstallHealthIssue[] = [];
|
||||
|
||||
if (shouldDeferConfiguredPluginInstallRepair(env)) {
|
||||
for (const pluginId of collectUpdateDeferredPluginIds({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds,
|
||||
})) {
|
||||
deferredPluginIds.add(pluginId);
|
||||
const record = records[pluginId];
|
||||
if (!record || !isInstalledRecordMissingOnDisk(record, env)) {
|
||||
continue;
|
||||
}
|
||||
issues.push({
|
||||
kind: "deferred-package-manager-repair",
|
||||
pluginId,
|
||||
...(resolveRecordInstallPath(record, env)
|
||||
? { installPath: resolveRecordInstallPath(record, env) }
|
||||
: {}),
|
||||
});
|
||||
reportedPluginIds.add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
const missingRecordedPluginIds = Object.keys(records).filter(
|
||||
(pluginId) =>
|
||||
!deferredPluginIds.has(pluginId) &&
|
||||
!officialReplacementPluginIds.has(pluginId) &&
|
||||
!bundledPluginsById.has(pluginId) &&
|
||||
((pluginIds.has(pluginId) &&
|
||||
(!knownIds.has(pluginId) || isInstalledRecordMissingOnDisk(records[pluginId], env))) ||
|
||||
staleDescriptorPluginIds.has(pluginId) ||
|
||||
repairableInstalledPluginIds.has(pluginId)),
|
||||
);
|
||||
|
||||
for (const pluginId of missingRecordedPluginIds) {
|
||||
const record = records[pluginId];
|
||||
const kind = missingRecordedPluginIssueKind({
|
||||
pluginId,
|
||||
staleVersionBoundRuntimePluginIds,
|
||||
repairablePackageDiagnosticPluginIds,
|
||||
staleDescriptorPluginIds,
|
||||
});
|
||||
const installPath = resolveRecordInstallPath(record, env);
|
||||
if (kind === "stale-channel-config-descriptor") {
|
||||
issues.push({
|
||||
kind,
|
||||
pluginId,
|
||||
...(installPath ? { installPath } : {}),
|
||||
});
|
||||
reportedPluginIds.add(pluginId);
|
||||
continue;
|
||||
}
|
||||
issues.push({
|
||||
kind,
|
||||
pluginId,
|
||||
...(installPath ? { installPath } : {}),
|
||||
...(record?.spec ? { installSpec: record.spec } : {}),
|
||||
});
|
||||
reportedPluginIds.add(pluginId);
|
||||
}
|
||||
|
||||
const missingPluginIds = new Set(
|
||||
[...pluginIds].filter((pluginId) => {
|
||||
if (deferredPluginIds.has(pluginId)) {
|
||||
return false;
|
||||
}
|
||||
const hasRecord = Object.hasOwn(records, pluginId);
|
||||
return (
|
||||
(!knownIds.has(pluginId) && !hasRecord && !bundledPluginsById.has(pluginId)) ||
|
||||
(hasRecord &&
|
||||
!bundledPluginsById.has(pluginId) &&
|
||||
isInstalledRecordMissingOnDisk(records[pluginId], env))
|
||||
);
|
||||
}),
|
||||
);
|
||||
const installCandidatePluginIds = new Set([...missingPluginIds, ...officialReplacementPluginIds]);
|
||||
for (const candidate of collectDownloadableInstallCandidates({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
missingPluginIds: installCandidatePluginIds,
|
||||
configuredPluginIds: pluginIds,
|
||||
configuredChannelIds: channelIds,
|
||||
configuredChannelOwnerPluginIds,
|
||||
blockedPluginIds:
|
||||
deferredPluginIds.size > 0
|
||||
? new Set([...blockedPluginIds, ...deferredPluginIds])
|
||||
: blockedPluginIds,
|
||||
})) {
|
||||
if (bundledPluginsById.has(candidate.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
if (reportedPluginIds.has(candidate.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const shouldReplaceBrokenOfficialInstall = officialReplacementPluginIds.has(candidate.pluginId);
|
||||
if (shouldReplaceBrokenOfficialInstall && !candidate.trustedSourceLinkedOfficialInstall) {
|
||||
continue;
|
||||
}
|
||||
const record = records[candidate.pluginId];
|
||||
if (
|
||||
shouldReplaceBrokenOfficialInstall &&
|
||||
!isTrustedOfficialInstallRecordForCandidate({ record, candidate })
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const hasUsableRecord =
|
||||
Object.hasOwn(records, candidate.pluginId) &&
|
||||
!isInstalledRecordMissingOnDisk(records[candidate.pluginId], env);
|
||||
if (
|
||||
!shouldReplaceBrokenOfficialInstall &&
|
||||
knownIds.has(candidate.pluginId) &&
|
||||
hasUsableRecord
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldReplaceBrokenOfficialInstall && hasUsableRecord) {
|
||||
continue;
|
||||
}
|
||||
const installSpec = resolveCandidateInstallSpec({ candidate, updateChannel });
|
||||
if (shouldReplaceBrokenOfficialInstall) {
|
||||
const installPath = resolveRecordInstallPath(record, env);
|
||||
if (staleVersionBoundRuntimePluginIds.has(candidate.pluginId)) {
|
||||
issues.push({
|
||||
kind: "stale-version-bound-runtime",
|
||||
pluginId: candidate.pluginId,
|
||||
...(installPath ? { installPath } : {}),
|
||||
...(installSpec ? { installSpec } : {}),
|
||||
});
|
||||
} else {
|
||||
issues.push({
|
||||
kind: "repairable-installed-plugin",
|
||||
pluginId: candidate.pluginId,
|
||||
...(installPath ? { installPath } : {}),
|
||||
...(installSpec ? { installSpec } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (record) {
|
||||
const installPath = resolveRecordInstallPath(record, env);
|
||||
issues.push({
|
||||
kind: "missing-installed-payload",
|
||||
pluginId: candidate.pluginId,
|
||||
...(installPath ? { installPath } : {}),
|
||||
...(installSpec ? { installSpec } : {}),
|
||||
});
|
||||
} else if (installSpec) {
|
||||
issues.push({
|
||||
kind: "missing-install-record",
|
||||
pluginId: candidate.pluginId,
|
||||
installSpec,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||
}
|
||||
|
||||
export function configuredPluginInstallIssueToHealthFinding(
|
||||
issue: ConfiguredPluginInstallHealthIssue,
|
||||
): HealthFinding {
|
||||
const target = issue.pluginId;
|
||||
switch (issue.kind) {
|
||||
case "missing-install-record":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} is not installed.`,
|
||||
target,
|
||||
fixHint: `Run \`openclaw doctor --fix\` to install ${issue.installSpec}.`,
|
||||
};
|
||||
case "missing-installed-payload":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} has an install record but its package payload is missing.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to reinstall the configured plugin package.",
|
||||
};
|
||||
case "repairable-installed-plugin":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} has a repairable package install problem.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin package.",
|
||||
};
|
||||
case "stale-version-bound-runtime":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured runtime plugin ${issue.pluginId} is older than this OpenClaw version.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to refresh the configured runtime plugin.",
|
||||
};
|
||||
case "stale-channel-config-descriptor":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} has stale channel config metadata.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin install metadata.",
|
||||
};
|
||||
case "deferred-package-manager-repair":
|
||||
return {
|
||||
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Configured plugin ${issue.pluginId} package repair is deferred until the package update finishes.`,
|
||||
target,
|
||||
...(issue.installPath ? { path: issue.installPath } : {}),
|
||||
fixHint: "Rerun `openclaw doctor --fix` after the package update completes.",
|
||||
};
|
||||
}
|
||||
return assertNeverConfiguredPluginInstallIssue(issue);
|
||||
}
|
||||
|
||||
export function configuredPluginInstallIssueToRepairEffect(
|
||||
issue: ConfiguredPluginInstallHealthIssue,
|
||||
): HealthRepairEffect {
|
||||
switch (issue.kind) {
|
||||
case "missing-install-record":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-install-configured-plugin",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "missing-installed-payload":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-reinstall-configured-plugin",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "repairable-installed-plugin":
|
||||
case "stale-channel-config-descriptor":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-repair-configured-plugin-install",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "stale-version-bound-runtime":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-refresh-configured-runtime-plugin",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "deferred-package-manager-repair":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-defer-configured-plugin-install-repair",
|
||||
target: issue.pluginId,
|
||||
dryRunSafe: true,
|
||||
};
|
||||
}
|
||||
return assertNeverConfiguredPluginInstallIssue(issue);
|
||||
}
|
||||
|
||||
function assertNeverConfiguredPluginInstallIssue(issue: never): never {
|
||||
throw new Error(
|
||||
`Unhandled configured plugin install issue kind: ${String((issue as { kind?: unknown }).kind)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type RepairMissingPluginInstallsResult = {
|
||||
/** User-facing repair notes for installed or recovered plugin records. */
|
||||
changes: string[];
|
||||
|
||||
@@ -283,7 +283,6 @@ export function createCronPromptExecutor(params: {
|
||||
agentDir: params.agentDir,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.runSessionKey,
|
||||
abortSignal: params.abortSignal,
|
||||
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
config: params.cfgWithAgentDefaults,
|
||||
|
||||
@@ -665,21 +665,4 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)",
|
||||
|
||||
expect(capturedFallbacksOverride).toEqual(["openai/gpt-4o"]);
|
||||
});
|
||||
|
||||
it("forwards the cron abort signal into runWithModelFallback", async () => {
|
||||
const controller = new AbortController();
|
||||
let capturedAbortSignal: AbortSignal | undefined;
|
||||
runWithModelFallbackMock.mockImplementation(
|
||||
async (params: { provider: string; model: string; abortSignal?: AbortSignal }) => {
|
||||
capturedAbortSignal = params.abortSignal;
|
||||
return makeSuccessfulRunResult();
|
||||
},
|
||||
);
|
||||
|
||||
controller.abort(new Error("cron: job execution timed out"));
|
||||
|
||||
await runCronIsolatedAgentTurn(makeParams({ abortSignal: controller.signal }));
|
||||
|
||||
expect(capturedAbortSignal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,6 @@ const mocks = vi.hoisted(() => ({
|
||||
disposeBundleRuntime: vi.fn(),
|
||||
loadModelCatalog: vi.fn(async (): Promise<Array<Record<string, unknown>>> => []),
|
||||
normalizeProviderToolSchemasWithPlugin: vi.fn(),
|
||||
buildGatewayProbeConnectionDetails: vi.fn(),
|
||||
probeGatewayStatus: vi.fn(),
|
||||
readGatewayServiceState: vi.fn(),
|
||||
resolveGatewayService: vi.fn(() => ({ label: "openclaw-gateway" })),
|
||||
resolvePluginProviders: vi.fn((): Array<Record<string, unknown>> => []),
|
||||
resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5.5" })),
|
||||
}));
|
||||
@@ -39,19 +35,6 @@ vi.mock("../agents/agent-tools.js", () => ({
|
||||
createOpenClawCodingTools: mocks.createOpenClawCodingTools,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
buildGatewayProbeConnectionDetails: mocks.buildGatewayProbeConnectionDetails,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/daemon-cli/probe.js", () => ({
|
||||
probeGatewayStatus: mocks.probeGatewayStatus,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", () => ({
|
||||
readGatewayServiceState: mocks.readGatewayServiceState,
|
||||
resolveGatewayService: mocks.resolveGatewayService,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
inspectProviderToolSchemasWithPlugin: () => [],
|
||||
normalizeProviderToolSchemasWithPlugin: mocks.normalizeProviderToolSchemasWithPlugin,
|
||||
@@ -65,12 +48,8 @@ vi.mock("../plugins/providers.runtime.js", () => ({
|
||||
resolvePluginProviders: mocks.resolvePluginProviders,
|
||||
}));
|
||||
|
||||
const {
|
||||
collectGatewayDaemonFindings,
|
||||
collectGatewayHealthFindings,
|
||||
collectProviderCatalogProjectionFindings,
|
||||
collectRuntimeToolSchemaFindings,
|
||||
} = await import("./doctor-core-checks.runtime.js");
|
||||
const { collectProviderCatalogProjectionFindings, collectRuntimeToolSchemaFindings } =
|
||||
await import("./doctor-core-checks.runtime.js");
|
||||
|
||||
function tool(name: string, parameters: unknown): AnyAgentTool {
|
||||
return {
|
||||
@@ -100,22 +79,6 @@ describe("doctor runtime tool schema checks", () => {
|
||||
mocks.normalizeProviderToolSchemasWithPlugin
|
||||
.mockReset()
|
||||
.mockImplementation(({ context }) => context.tools);
|
||||
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
|
||||
url: "http://127.0.0.1:5829",
|
||||
});
|
||||
mocks.probeGatewayStatus.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
server: { version: "2026.6.26" },
|
||||
});
|
||||
mocks.readGatewayServiceState.mockReset().mockResolvedValue({
|
||||
installed: true,
|
||||
loaded: true,
|
||||
running: true,
|
||||
env: {},
|
||||
command: { programArguments: ["openclaw", "gateway"], sourcePath: "/tmp/gateway.service" },
|
||||
runtime: { status: "running" },
|
||||
});
|
||||
mocks.resolveGatewayService.mockClear();
|
||||
mocks.resolvePluginProviders.mockReset().mockReturnValue([]);
|
||||
mocks.resolveDefaultModelForAgent.mockClear();
|
||||
});
|
||||
@@ -540,100 +503,6 @@ describe("doctor runtime tool schema checks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor gateway runtime checks", () => {
|
||||
beforeEach(() => {
|
||||
mocks.buildGatewayProbeConnectionDetails.mockReset().mockResolvedValue({
|
||||
url: "http://127.0.0.1:5829",
|
||||
});
|
||||
mocks.probeGatewayStatus.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
server: { version: "2026.6.26" },
|
||||
});
|
||||
mocks.readGatewayServiceState.mockReset().mockResolvedValue({
|
||||
installed: true,
|
||||
loaded: true,
|
||||
running: true,
|
||||
env: {},
|
||||
command: { programArguments: ["openclaw", "gateway"], sourcePath: "/tmp/gateway.service" },
|
||||
runtime: { status: "running" },
|
||||
});
|
||||
mocks.resolveGatewayService.mockReset().mockReturnValue({ label: "openclaw-gateway" });
|
||||
});
|
||||
|
||||
it("reports unreachable gateway health probes", async () => {
|
||||
mocks.probeGatewayStatus.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "connect ECONNREFUSED 127.0.0.1:5829",
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectGatewayHealthFindings({ cfg: { gateway: { mode: "local" } } }),
|
||||
).resolves.toContainEqual({
|
||||
checkId: "core/doctor/gateway-health",
|
||||
severity: "warning",
|
||||
message: "Gateway is not reachable: connect ECONNREFUSED 127.0.0.1:5829",
|
||||
path: "gateway.mode",
|
||||
target: "http://127.0.0.1:5829",
|
||||
fixHint:
|
||||
"Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts sensitive remote gateway URLs from health finding targets", async () => {
|
||||
mocks.buildGatewayProbeConnectionDetails.mockResolvedValueOnce({
|
||||
url: "wss://user:pass@gateway.example.test/rpc?token=secret&safe=value",
|
||||
});
|
||||
mocks.probeGatewayStatus.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "remote gateway did not answer",
|
||||
});
|
||||
|
||||
const findings = await collectGatewayHealthFindings({
|
||||
cfg: { gateway: { mode: "remote", remote: { url: "wss://gateway.example.test/rpc" } } },
|
||||
});
|
||||
|
||||
expect(findings).toContainEqual({
|
||||
checkId: "core/doctor/gateway-health",
|
||||
severity: "warning",
|
||||
message: "Gateway is not reachable: remote gateway did not answer",
|
||||
path: "gateway.remote.url",
|
||||
target: "wss://***:***@gateway.example.test/rpc?token=***&safe=value",
|
||||
fixHint: "Verify the remote Gateway URL, network path, TLS settings, and credentials.",
|
||||
});
|
||||
expect(JSON.stringify(findings)).not.toContain("user:pass");
|
||||
expect(JSON.stringify(findings)).not.toContain("token=secret");
|
||||
});
|
||||
|
||||
it("reports missing local gateway daemon service", async () => {
|
||||
mocks.readGatewayServiceState.mockResolvedValueOnce({
|
||||
installed: false,
|
||||
loaded: false,
|
||||
running: false,
|
||||
env: {},
|
||||
command: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectGatewayDaemonFindings({ cfg: { gateway: { mode: "local" } } }),
|
||||
).resolves.toContainEqual({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: "Gateway service is not installed.",
|
||||
path: "gateway.mode",
|
||||
target: "openclaw-gateway",
|
||||
fixHint: "Run `openclaw doctor --fix` or `openclaw gateway install` to install it.",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips daemon findings for remote gateway mode", async () => {
|
||||
await expect(
|
||||
collectGatewayDaemonFindings({ cfg: { gateway: { mode: "remote" } } }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.readGatewayServiceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor provider catalog projection checks", () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolvePluginProviders.mockReset().mockReturnValue([]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Doctor runtime checks inspect tool names, browser residue, and runtime state.
|
||||
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
|
||||
import { TOOL_NAME_SEPARATOR } from "../agents/agent-bundle-mcp-names.js";
|
||||
import {
|
||||
type McpToolCatalogDiagnostic,
|
||||
@@ -31,32 +30,20 @@ import {
|
||||
type RuntimeToolSchemaDiagnostic,
|
||||
} from "../agents/tool-schema-projection.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
|
||||
import { collectUnavailableAgentSkills } from "../commands/doctor-skills-core.js";
|
||||
import { gatewayProbeResultSawGateway } from "../commands/gateway-health-auth-diagnostic.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
getSystemdCgroupHygieneSummary,
|
||||
type GatewayServiceRuntime,
|
||||
} from "../daemon/service-runtime.js";
|
||||
import { resolveGatewayService, readGatewayServiceState } from "../daemon/service.js";
|
||||
import { buildGatewayProbeConnectionDetails } from "../gateway/call.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
||||
import { getPluginToolMeta, setPluginToolMeta } from "../plugins/tools.js";
|
||||
import type { ProviderCatalogOrder, ProviderPlugin } from "../plugins/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../skills/discovery/status.js";
|
||||
import type { HealthCheckContext, HealthFinding } from "./health-checks.js";
|
||||
import type { HealthFinding } from "./health-checks.js";
|
||||
|
||||
type BundleMcpToolRuntime = Awaited<ReturnType<typeof createBundleMcpToolRuntime>>;
|
||||
const PROVIDER_CATALOG_ORDERS = ["simple", "profile", "paired", "late"] as const;
|
||||
const PROVIDER_CATALOG_ORDER_SET = new Set<ProviderCatalogOrder>(PROVIDER_CATALOG_ORDERS);
|
||||
|
||||
function formatGatewayHealthTarget(url: string): string {
|
||||
return redactSensitiveUrlLikeString(url);
|
||||
}
|
||||
|
||||
export function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] {
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
@@ -67,136 +54,6 @@ export function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[]
|
||||
return collectUnavailableAgentSkills(report);
|
||||
}
|
||||
|
||||
export async function collectGatewayHealthFindings(
|
||||
ctx: Pick<HealthCheckContext, "cfg" | "configPath">,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
let probeDetails: Awaited<ReturnType<typeof buildGatewayProbeConnectionDetails>>;
|
||||
try {
|
||||
probeDetails = await buildGatewayProbeConnectionDetails({
|
||||
config: ctx.cfg,
|
||||
...(ctx.configPath ? { configPath: ctx.configPath } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
checkId: "core/doctor/gateway-health",
|
||||
severity: "warning",
|
||||
message: `Gateway health probe could not be prepared: ${formatErrorMessage(error)}`,
|
||||
path: ctx.cfg.gateway?.mode === "remote" ? "gateway.remote.url" : "gateway",
|
||||
fixHint:
|
||||
"Fix Gateway connection configuration, then rerun `openclaw doctor --lint --only core/doctor/gateway-health`.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const probe = await probeGatewayStatus({
|
||||
url: probeDetails.url,
|
||||
timeoutMs: 3000,
|
||||
tlsFingerprint: probeDetails.tlsFingerprint,
|
||||
preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs,
|
||||
config: ctx.cfg,
|
||||
json: true,
|
||||
});
|
||||
if (gatewayProbeResultSawGateway(probe)) {
|
||||
return [];
|
||||
}
|
||||
const mode = ctx.cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
return [
|
||||
{
|
||||
checkId: "core/doctor/gateway-health",
|
||||
severity: "warning",
|
||||
message: `Gateway is not reachable: ${probe.error ?? "status probe failed"}`,
|
||||
path: mode === "remote" ? "gateway.remote.url" : "gateway.mode",
|
||||
target: formatGatewayHealthTarget(probeDetails.url),
|
||||
fixHint:
|
||||
mode === "remote"
|
||||
? "Verify the remote Gateway URL, network path, TLS settings, and credentials."
|
||||
: "Start the Gateway service or run `openclaw doctor --fix` for service repair prompts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function gatewayRuntimeStatus(runtime: GatewayServiceRuntime | undefined): string | undefined {
|
||||
return runtime?.status ?? runtime?.state ?? runtime?.subState;
|
||||
}
|
||||
|
||||
export async function collectGatewayDaemonFindings(
|
||||
ctx: Pick<HealthCheckContext, "cfg">,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
if (ctx.cfg.gateway?.mode === "remote") {
|
||||
return [];
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
const state = await readGatewayServiceState(service, { env: process.env });
|
||||
const findings: HealthFinding[] = [];
|
||||
if (!state.installed) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: "Gateway service is not installed.",
|
||||
path: "gateway.mode",
|
||||
target: service.label,
|
||||
fixHint: "Run `openclaw doctor --fix` or `openclaw gateway install` to install it.",
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
if (!state.loaded) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: "Gateway service is installed but not loaded.",
|
||||
path: state.command?.sourcePath,
|
||||
target: service.label,
|
||||
fixHint: "Run `openclaw doctor --fix` or `openclaw gateway start` to load it.",
|
||||
});
|
||||
}
|
||||
const status = gatewayRuntimeStatus(state.runtime);
|
||||
if (state.loaded && !state.running) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: status
|
||||
? `Gateway service runtime is ${status}, not running.`
|
||||
: "Gateway service is loaded but runtime status could not confirm it is running.",
|
||||
path: state.command?.sourcePath,
|
||||
target: service.label,
|
||||
fixHint: "Run `openclaw gateway status --deep` or `openclaw doctor --fix` for repair hints.",
|
||||
});
|
||||
}
|
||||
if (state.runtime?.missingGuiSession) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: "Gateway service cannot attach to the user GUI session.",
|
||||
path: state.command?.sourcePath,
|
||||
target: service.label,
|
||||
fixHint: state.runtime.detail ?? "Log into a GUI session, then rerun doctor.",
|
||||
});
|
||||
}
|
||||
if (state.runtime?.missingSupervision || state.runtime?.missingUnit) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: "Gateway service supervision metadata is missing.",
|
||||
path: state.command?.sourcePath,
|
||||
target: service.label,
|
||||
fixHint: state.runtime.detail ?? "Reinstall or reload the Gateway service.",
|
||||
});
|
||||
}
|
||||
const hygiene = getSystemdCgroupHygieneSummary(state.runtime?.systemd);
|
||||
if (hygiene) {
|
||||
findings.push({
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: `Gateway systemd service has risky ${hygiene}.`,
|
||||
path: state.command?.sourcePath,
|
||||
target: service.label,
|
||||
fixHint: "Repair the systemd unit so stale child processes are cleaned up reliably.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function providerCatalogPath(pluginId: string | undefined): string | undefined {
|
||||
return pluginId ? `plugins.entries.${pluginId}` : undefined;
|
||||
}
|
||||
|
||||
@@ -95,12 +95,6 @@ function createDeps(overrides: Partial<CoreHealthCheckDeps> = {}): CoreHealthChe
|
||||
async collectProviderCatalogProjectionFindings() {
|
||||
return [];
|
||||
},
|
||||
async collectGatewayHealthFindings() {
|
||||
return [];
|
||||
},
|
||||
async collectGatewayDaemonFindings() {
|
||||
return [];
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -237,64 +231,6 @@ describe("CORE_HEALTH_CHECKS", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes gateway health findings as an opt-in structured check", async () => {
|
||||
const findings: HealthFinding[] = [
|
||||
{
|
||||
checkId: "core/doctor/gateway-health",
|
||||
severity: "warning",
|
||||
message: "Gateway is not reachable.",
|
||||
},
|
||||
];
|
||||
const collectGatewayHealthFindings = vi.fn(async () => findings);
|
||||
const check = getCheck(
|
||||
createCoreHealthChecks(
|
||||
createDeps({
|
||||
collectGatewayHealthFindings,
|
||||
}),
|
||||
),
|
||||
"core/doctor/gateway-health",
|
||||
);
|
||||
const ctx = {
|
||||
mode: "lint" as const,
|
||||
runtime,
|
||||
cfg: { gateway: { mode: "local" as const } },
|
||||
};
|
||||
|
||||
await expect(check.detect(ctx)).resolves.toBe(findings);
|
||||
|
||||
expect(collectGatewayHealthFindings).toHaveBeenCalledWith(ctx);
|
||||
expect((check as { defaultEnabled?: boolean }).defaultEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes gateway daemon findings as an opt-in structured check", async () => {
|
||||
const findings: HealthFinding[] = [
|
||||
{
|
||||
checkId: "core/doctor/gateway-daemon",
|
||||
severity: "warning",
|
||||
message: "Gateway service is not installed.",
|
||||
},
|
||||
];
|
||||
const collectGatewayDaemonFindings = vi.fn(async () => findings);
|
||||
const check = getCheck(
|
||||
createCoreHealthChecks(
|
||||
createDeps({
|
||||
collectGatewayDaemonFindings,
|
||||
}),
|
||||
),
|
||||
"core/doctor/gateway-daemon",
|
||||
);
|
||||
const ctx = {
|
||||
mode: "lint" as const,
|
||||
runtime,
|
||||
cfg: { gateway: { mode: "local" as const } },
|
||||
};
|
||||
|
||||
await expect(check.detect(ctx)).resolves.toBe(findings);
|
||||
|
||||
expect(collectGatewayDaemonFindings).toHaveBeenCalledWith(ctx);
|
||||
expect((check as { defaultEnabled?: boolean }).defaultEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("converts unavailable skills into repair-capable health findings", async () => {
|
||||
const unavailableSkill = createSkill();
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -47,8 +47,6 @@ import type {
|
||||
const BROWSER_CLAWD_PROFILE_RESIDUE_CHECK_ID = "core/doctor/browser-clawd-profile-residue";
|
||||
const CODEX_SESSION_ROUTES_CHECK_ID = "core/doctor/codex-session-routes";
|
||||
const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation";
|
||||
const GATEWAY_DAEMON_CHECK_ID = "core/doctor/gateway-daemon";
|
||||
const GATEWAY_HEALTH_CHECK_ID = "core/doctor/gateway-health";
|
||||
const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra";
|
||||
const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks";
|
||||
|
||||
@@ -74,12 +72,6 @@ export type CoreHealthCheckDeps = {
|
||||
readonly collectProviderCatalogProjectionFindings: (
|
||||
ctx: HealthCheckContext,
|
||||
) => Promise<readonly HealthFinding[]>;
|
||||
readonly collectGatewayHealthFindings: (
|
||||
ctx: HealthCheckContext,
|
||||
) => Promise<readonly HealthFinding[]>;
|
||||
readonly collectGatewayDaemonFindings: (
|
||||
ctx: HealthCheckContext,
|
||||
) => Promise<readonly HealthFinding[]>;
|
||||
};
|
||||
|
||||
async function detectUnavailableSkillsWithRuntime(
|
||||
@@ -124,28 +116,12 @@ async function collectProviderCatalogProjectionFindingsWithRuntime(
|
||||
return runtime.collectProviderCatalogProjectionFindings(ctx.cfg);
|
||||
}
|
||||
|
||||
async function collectGatewayHealthFindingsWithRuntime(
|
||||
ctx: HealthCheckContext,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
const runtime = await loadDoctorCoreChecksRuntimeModule();
|
||||
return runtime.collectGatewayHealthFindings(ctx);
|
||||
}
|
||||
|
||||
async function collectGatewayDaemonFindingsWithRuntime(
|
||||
ctx: HealthCheckContext,
|
||||
): Promise<readonly HealthFinding[]> {
|
||||
const runtime = await loadDoctorCoreChecksRuntimeModule();
|
||||
return runtime.collectGatewayDaemonFindings(ctx);
|
||||
}
|
||||
|
||||
const defaultCoreHealthCheckDeps: CoreHealthCheckDeps = {
|
||||
detectUnavailableSkills: detectUnavailableSkillsWithRuntime,
|
||||
collectSecurityWarnings: collectSecurityWarningsWithRuntime,
|
||||
collectWorkspaceSuggestionNotes: collectWorkspaceSuggestionNotesWithRuntime,
|
||||
collectRuntimeToolSchemaFindings: collectRuntimeToolSchemaFindingsWithRuntime,
|
||||
collectProviderCatalogProjectionFindings: collectProviderCatalogProjectionFindingsWithRuntime,
|
||||
collectGatewayHealthFindings: collectGatewayHealthFindingsWithRuntime,
|
||||
collectGatewayDaemonFindings: collectGatewayDaemonFindingsWithRuntime,
|
||||
};
|
||||
|
||||
export function configValidationIssuesToHealthFindings(
|
||||
@@ -760,32 +736,6 @@ const gatewayPlatformNotesCheck: HealthCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
function createGatewayHealthCheck(deps: CoreHealthCheckDeps): SplitHealthCheckInput {
|
||||
return {
|
||||
id: GATEWAY_HEALTH_CHECK_ID,
|
||||
kind: "core",
|
||||
description: "Gateway reachability is represented as structured findings.",
|
||||
source: "doctor",
|
||||
defaultEnabled: false,
|
||||
async detect(ctx) {
|
||||
return deps.collectGatewayHealthFindings(ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayDaemonCheck(deps: CoreHealthCheckDeps): SplitHealthCheckInput {
|
||||
return {
|
||||
id: GATEWAY_DAEMON_CHECK_ID,
|
||||
kind: "core",
|
||||
description: "Local Gateway daemon service state is represented as structured findings.",
|
||||
source: "doctor",
|
||||
defaultEnabled: false,
|
||||
async detect(ctx) {
|
||||
return deps.collectGatewayDaemonFindings(ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sessionLocksCheck: SplitHealthCheckInput = {
|
||||
id: SESSION_LOCKS_CHECK_ID,
|
||||
kind: "core",
|
||||
@@ -1072,8 +1022,6 @@ function createConvertedWorkflowChecks(
|
||||
uiProtocolFreshnessCheck,
|
||||
gatewayServicesExtraCheck,
|
||||
gatewayPlatformNotesCheck,
|
||||
createGatewayHealthCheck(deps),
|
||||
createGatewayDaemonCheck(deps),
|
||||
createSecurityCheck(deps),
|
||||
browserCheck,
|
||||
openAIOAuthTlsCheck,
|
||||
|
||||
@@ -1052,7 +1052,6 @@ describe("doctor health contributions", () => {
|
||||
expect(contributionIds).toContain("core/doctor/session-transcripts");
|
||||
expect(contributionIds).toContain("core/doctor/session-snapshots");
|
||||
expect(contributionIds).toContain("core/doctor/plugin-registry");
|
||||
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
|
||||
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
|
||||
});
|
||||
|
||||
|
||||
@@ -1261,44 +1261,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:release-configured-plugin-installs",
|
||||
label: "Configured plugin repair",
|
||||
healthChecks: {
|
||||
id: "core/doctor/configured-plugin-installs",
|
||||
description: "Configured plugin install records and package payloads are repairable.",
|
||||
defaultEnabled: false,
|
||||
async detect(ctx) {
|
||||
const {
|
||||
detectConfiguredPluginInstallHealthIssues,
|
||||
configuredPluginInstallIssueToHealthFinding,
|
||||
} = await import("../commands/doctor/shared/missing-configured-plugin-install.js");
|
||||
return (
|
||||
await detectConfiguredPluginInstallHealthIssues({
|
||||
cfg: ctx.cfg,
|
||||
env: process.env,
|
||||
})
|
||||
).map(configuredPluginInstallIssueToHealthFinding);
|
||||
},
|
||||
async repair(ctx) {
|
||||
const {
|
||||
detectConfiguredPluginInstallHealthIssues,
|
||||
configuredPluginInstallIssueToRepairEffect,
|
||||
} = await import("../commands/doctor/shared/missing-configured-plugin-install.js");
|
||||
const effects = (
|
||||
await detectConfiguredPluginInstallHealthIssues({
|
||||
cfg: ctx.cfg,
|
||||
env: process.env,
|
||||
})
|
||||
).map(configuredPluginInstallIssueToRepairEffect);
|
||||
if (ctx.dryRun === true) {
|
||||
return { status: "repaired", changes: [], effects };
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "legacy doctor configured plugin install repair owns package mutation",
|
||||
changes: [],
|
||||
effects,
|
||||
};
|
||||
},
|
||||
},
|
||||
run: runReleaseConfiguredPluginInstallsHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
@@ -1634,7 +1596,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:gateway-health",
|
||||
label: "Gateway health",
|
||||
healthCheckIds: ["core/doctor/gateway-health"],
|
||||
run: runGatewayHealthChecks,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
@@ -1655,7 +1616,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:gateway-daemon",
|
||||
label: "Gateway daemon",
|
||||
healthCheckIds: ["core/doctor/gateway-daemon"],
|
||||
run: runGatewayDaemonHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
|
||||
@@ -137,7 +137,6 @@ import {
|
||||
import {
|
||||
augmentChatHistoryWithCanvasBlocks,
|
||||
dropPreSessionStartAnnouncePairs,
|
||||
projectChatDisplayMessages,
|
||||
projectChatDisplayMessage,
|
||||
projectRecentChatDisplayMessages,
|
||||
resolveEffectiveChatHistoryMaxChars,
|
||||
@@ -159,10 +158,8 @@ import { persistGatewaySessionLifecycleEvent } from "../session-lifecycle-state.
|
||||
import { readSessionTranscriptIndex } from "../session-transcript-index.fs.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
readRecentSessionMessagesWithStatsAsync,
|
||||
readSessionMessageByIdAsync,
|
||||
readRecentSessionMessagesAsync,
|
||||
readSessionMessagesPageWithStatsAsync,
|
||||
readSessionMessagesAsync,
|
||||
} from "../session-transcript-readers.js";
|
||||
import {
|
||||
@@ -241,12 +238,6 @@ type PreRegisteredAgentRun = {
|
||||
};
|
||||
|
||||
type ChatHistoryMethod = "chat.history" | "chat.startup";
|
||||
type ChatHistoryPage = {
|
||||
messages: unknown[];
|
||||
offset?: number;
|
||||
totalMessages?: number;
|
||||
rawPageMessages?: number;
|
||||
};
|
||||
|
||||
type ChatMetadataResult = {
|
||||
commands?: unknown[];
|
||||
@@ -2622,45 +2613,6 @@ function readChatHistoryMessageId(message: unknown): string | undefined {
|
||||
return typeof metadata?.id === "string" ? metadata.id : undefined;
|
||||
}
|
||||
|
||||
function readChatHistoryMessageSeq(message: unknown): number | undefined {
|
||||
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
|
||||
const seq = metadata?.seq;
|
||||
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
|
||||
}
|
||||
|
||||
function resolveChatHistoryNextOffset(params: {
|
||||
messages: unknown[];
|
||||
totalMessages: number;
|
||||
offset: number;
|
||||
rawPageMessages: number;
|
||||
}): number {
|
||||
const oldestSeq = params.messages
|
||||
.map((message) => readChatHistoryMessageSeq(message))
|
||||
.find((seq): seq is number => typeof seq === "number");
|
||||
if (oldestSeq !== undefined) {
|
||||
return Math.max(params.offset, params.totalMessages - oldestSeq + 1);
|
||||
}
|
||||
return params.offset + params.rawPageMessages;
|
||||
}
|
||||
|
||||
function capOffsetChatHistoryProjectedMessages(messages: unknown[], max: number): unknown[] {
|
||||
if (messages.length <= max) {
|
||||
return messages;
|
||||
}
|
||||
const start = Math.max(0, messages.length - max);
|
||||
const boundarySeq = readChatHistoryMessageSeq(messages[start]);
|
||||
if (boundarySeq === undefined) {
|
||||
return messages.slice(start);
|
||||
}
|
||||
// Offset cursors can only resume at transcript-record boundaries.
|
||||
// Keep boundary rows with the same seq together so projection mirrors are not stranded.
|
||||
let safeStart = start;
|
||||
while (safeStart > 0 && readChatHistoryMessageSeq(messages[safeStart - 1]) === boundarySeq) {
|
||||
safeStart--;
|
||||
}
|
||||
return messages.slice(safeStart);
|
||||
}
|
||||
|
||||
async function isChatMessageIdVisibleAfterHistoryFilters(params: {
|
||||
sessionId: string;
|
||||
storePath: string | undefined;
|
||||
@@ -2707,151 +2659,6 @@ function dropLocalHistoryOverreadContextMessage(
|
||||
return [...messages.slice(0, index), ...messages.slice(index + 1)];
|
||||
}
|
||||
|
||||
async function readChatHistoryPage(params: {
|
||||
entry: ReturnType<typeof loadSessionEntry>["entry"];
|
||||
provider: string | undefined;
|
||||
sessionId: string | undefined;
|
||||
storePath: string | undefined;
|
||||
sessionAgentId: string;
|
||||
canonicalKey: string;
|
||||
max: number;
|
||||
maxHistoryBytes: number;
|
||||
effectiveMaxChars: number;
|
||||
offset: number | undefined;
|
||||
}): Promise<ChatHistoryPage> {
|
||||
const {
|
||||
entry,
|
||||
provider,
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionAgentId,
|
||||
canonicalKey,
|
||||
max,
|
||||
maxHistoryBytes,
|
||||
effectiveMaxChars,
|
||||
offset,
|
||||
} = params;
|
||||
if (!sessionId || !storePath) {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
const readScope = {
|
||||
agentId: sessionAgentId,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
};
|
||||
if (offset !== undefined) {
|
||||
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
|
||||
const readPage =
|
||||
offset === 0
|
||||
? await readRecentSessionMessagesWithStatsAsync(readScope, {
|
||||
maxMessages: rawHistoryWindow.maxMessages + 1,
|
||||
maxLines: rawHistoryWindow.maxLines + 1,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
allowResetArchiveFallback: true,
|
||||
})
|
||||
: await readSessionMessagesPageWithStatsAsync(readScope, {
|
||||
offset,
|
||||
maxMessages: max + 1,
|
||||
allowResetArchiveFallback: true,
|
||||
});
|
||||
const overreadContextMessage =
|
||||
offset === 0
|
||||
? readPage.messages.length > rawHistoryWindow.maxMessages
|
||||
? readPage.messages[0]
|
||||
: undefined
|
||||
: readPage.messages.length > max
|
||||
? readPage.messages[0]
|
||||
: undefined;
|
||||
const localMessages =
|
||||
offset === 0
|
||||
? dropLocalHistoryOverreadContextMessage(
|
||||
dropPreSessionStartAnnouncePairs(
|
||||
readPage.messages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
),
|
||||
overreadContextMessage,
|
||||
)
|
||||
: dropLocalHistoryOverreadContextMessage(
|
||||
dropPreSessionStartAnnouncePairs(
|
||||
readPage.messages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
),
|
||||
overreadContextMessage,
|
||||
);
|
||||
const rawPageMessages =
|
||||
offset === 0
|
||||
? readPage.messages.length
|
||||
: Math.min(max, Math.max(0, readPage.totalMessages - offset));
|
||||
const rawMessages = localMessages;
|
||||
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
|
||||
rawMessages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
);
|
||||
const projected =
|
||||
offset === 0
|
||||
? projectRecentChatDisplayMessages(recencyFilteredMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
maxMessages: max,
|
||||
})
|
||||
: projectChatDisplayMessages(recencyFilteredMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
});
|
||||
const windowed =
|
||||
offset === 0 ? projected : capOffsetChatHistoryProjectedMessages(projected, max);
|
||||
const normalized = augmentChatHistoryWithCanvasBlocks(windowed);
|
||||
return {
|
||||
messages: normalized,
|
||||
offset,
|
||||
totalMessages: readPage.totalMessages,
|
||||
rawPageMessages,
|
||||
};
|
||||
}
|
||||
|
||||
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
|
||||
const localHistoryReadOptions = {
|
||||
maxMessages: rawHistoryWindow.maxMessages + 1,
|
||||
maxLines: rawHistoryWindow.maxLines + 1,
|
||||
};
|
||||
const localMessages = await readRecentSessionMessagesAsync(readScope, {
|
||||
...localHistoryReadOptions,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
allowResetArchiveFallback: true,
|
||||
});
|
||||
const overreadContextMessage =
|
||||
localMessages.length > rawHistoryWindow.maxMessages ? localMessages[0] : undefined;
|
||||
const localMessagesWithBoundaryFilter = dropLocalHistoryOverreadContextMessage(
|
||||
dropPreSessionStartAnnouncePairs(
|
||||
localMessages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
),
|
||||
overreadContextMessage,
|
||||
);
|
||||
const rawMessages = augmentChatHistoryWithCliSessionImports({
|
||||
entry,
|
||||
provider,
|
||||
localMessages: localMessagesWithBoundaryFilter,
|
||||
});
|
||||
// Drop subagent_announce pairs (user inter-session announce + adjacent
|
||||
// assistant) whose record timestamp predates the current session's
|
||||
// sessionStartedAt. Run after CLI history imports too, because those
|
||||
// timestamped messages share the same chat.history response surface.
|
||||
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
|
||||
rawMessages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
);
|
||||
return {
|
||||
messages: augmentChatHistoryWithCanvasBlocks(
|
||||
projectRecentChatDisplayMessages(recencyFilteredMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
maxMessages: max,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleChatHistoryRequest({
|
||||
params,
|
||||
respond,
|
||||
@@ -2875,11 +2682,10 @@ async function handleChatHistoryRequest({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey, limit, offset, maxChars } = params as {
|
||||
const { sessionKey, limit, maxChars } = params as {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
maxChars?: number;
|
||||
};
|
||||
const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId);
|
||||
@@ -2934,20 +2740,57 @@ async function handleChatHistoryRequest({
|
||||
const requested = typeof limit === "number" ? limit : defaultLimit;
|
||||
const max = Math.min(hardMax, requested);
|
||||
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
|
||||
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
|
||||
const historyPage = await readChatHistoryPage({
|
||||
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
|
||||
const localHistoryReadOptions = {
|
||||
maxMessages: rawHistoryWindow.maxMessages + 1,
|
||||
maxLines: rawHistoryWindow.maxLines + 1,
|
||||
};
|
||||
const localMessages =
|
||||
sessionId && storePath
|
||||
? await readRecentSessionMessagesAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
...localHistoryReadOptions,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
allowResetArchiveFallback: true,
|
||||
},
|
||||
)
|
||||
: [];
|
||||
const overreadContextMessage =
|
||||
localMessages.length > rawHistoryWindow.maxMessages ? localMessages[0] : undefined;
|
||||
const localMessagesWithBoundaryFilter = dropLocalHistoryOverreadContextMessage(
|
||||
dropPreSessionStartAnnouncePairs(
|
||||
localMessages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
),
|
||||
overreadContextMessage,
|
||||
);
|
||||
const rawMessages = augmentChatHistoryWithCliSessionImports({
|
||||
entry,
|
||||
provider: resolvedSessionModel.provider,
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionAgentId,
|
||||
canonicalKey,
|
||||
max,
|
||||
maxHistoryBytes,
|
||||
effectiveMaxChars,
|
||||
offset,
|
||||
localMessages: localMessagesWithBoundaryFilter,
|
||||
});
|
||||
const normalized = historyPage.messages;
|
||||
// Drop subagent_announce pairs (user inter-session announce + adjacent
|
||||
// assistant) whose record timestamp predates the current session's
|
||||
// sessionStartedAt. Run after CLI history imports too, because those
|
||||
// timestamped messages share the same chat.history response surface.
|
||||
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
|
||||
rawMessages,
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
);
|
||||
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
|
||||
const normalized = augmentChatHistoryWithCanvasBlocks(
|
||||
projectRecentChatDisplayMessages(recencyFilteredMessages, {
|
||||
maxChars: effectiveMaxChars,
|
||||
maxMessages: max,
|
||||
}),
|
||||
);
|
||||
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
|
||||
const replaced = replaceOversizedChatHistoryMessages({
|
||||
messages: normalized,
|
||||
@@ -2960,19 +2803,6 @@ async function handleChatHistoryRequest({
|
||||
});
|
||||
const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
|
||||
const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
|
||||
const nextOffset =
|
||||
historyPage.offset !== undefined && historyPage.totalMessages !== undefined
|
||||
? resolveChatHistoryNextOffset({
|
||||
messages: bounded.messages,
|
||||
totalMessages: historyPage.totalMessages,
|
||||
offset: historyPage.offset,
|
||||
rawPageMessages: historyPage.rawPageMessages ?? bounded.messages.length,
|
||||
})
|
||||
: undefined;
|
||||
const hasMore =
|
||||
nextOffset !== undefined && historyPage.totalMessages !== undefined
|
||||
? nextOffset < historyPage.totalMessages
|
||||
: undefined;
|
||||
reportOmittedChatHistory({
|
||||
originalMessages: normalized,
|
||||
finalMessages: bounded.messages,
|
||||
@@ -3032,12 +2862,6 @@ async function handleChatHistoryRequest({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
messages: bounded.messages,
|
||||
...(historyPage.offset !== undefined ? { offset: historyPage.offset } : {}),
|
||||
...(hasMore ? { nextOffset } : {}),
|
||||
...(hasMore !== undefined ? { hasMore } : {}),
|
||||
...(historyPage.totalMessages !== undefined
|
||||
? { totalMessages: historyPage.totalMessages }
|
||||
: {}),
|
||||
defaults,
|
||||
sessionInfo,
|
||||
thinkingLevel,
|
||||
|
||||
@@ -107,18 +107,6 @@ function futureFixtureUpdatedAt(): number {
|
||||
return Date.now() + 60_000;
|
||||
}
|
||||
|
||||
function readOpenClawSeq(message: unknown): number | undefined {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata = (message as Record<string, unknown>)["__openclaw"];
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined;
|
||||
}
|
||||
const seq = (metadata as Record<string, unknown>).seq;
|
||||
return typeof seq === "number" ? seq : undefined;
|
||||
}
|
||||
|
||||
async function writeGatewayConfig(config: Record<string, unknown>) {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
@@ -2200,76 +2188,6 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.history offset pages overread context before filtering stale announce replies", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
await connectOk(ws);
|
||||
const sessionDir = await createSessionDir();
|
||||
const sessionStartedAt = Date.parse("2026-05-23T04:02:30.000Z");
|
||||
const announce = {
|
||||
kind: "inter_session",
|
||||
sourceSessionKey: "agent:main:subagent:child",
|
||||
sourceTool: "subagent_announce",
|
||||
};
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
sessionStartedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-23T04:03:10.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "older visible turn" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-16T16:00:31.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"[Inter-session message] sourceSession=agent:main:subagent:child sourceChannel=internal sourceTool=subagent_announce",
|
||||
provenance: announce,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-16T16:00:33.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "stale announce reply" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-23T04:03:20.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "latest visible reply" }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await rpcReq<{
|
||||
messages?: Array<{ __openclaw?: { seq?: number } }>;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
}>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 1,
|
||||
offset: 1,
|
||||
maxChars: 100,
|
||||
});
|
||||
expect(page.ok).toBe(true);
|
||||
expect(page.payload?.messages).toEqual([]);
|
||||
expect(JSON.stringify(page.payload)).not.toContain("stale announce reply");
|
||||
expect(page.payload?.nextOffset).toBe(2);
|
||||
expect(page.payload?.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("smoke: caps history payload and preserves routing metadata", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const historyMaxBytes = 64 * 1024;
|
||||
@@ -3168,132 +3086,6 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.history offset pagination advances from the projected first-page boundary", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "oldest question" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "oldest answer" }],
|
||||
timestamp: Date.now() + 1,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "visible boundary" }],
|
||||
timestamp: Date.now() + 2,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
timestamp: Date.now() + 3,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "visible latest" }],
|
||||
timestamp: Date.now() + 4,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const firstPage = await rpcReq<{
|
||||
messages?: Array<{ __openclaw?: { seq?: number } }>;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
}>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
maxChars: 100,
|
||||
});
|
||||
expect(firstPage.ok).toBe(true);
|
||||
expect(firstPage.payload?.messages?.map(readOpenClawSeq)).toEqual([3, 5]);
|
||||
expect(firstPage.payload?.nextOffset).toBe(3);
|
||||
expect(firstPage.payload?.hasMore).toBe(true);
|
||||
expect(firstPage.payload?.totalMessages).toBe(5);
|
||||
|
||||
const secondPage = await rpcReq<{
|
||||
messages?: Array<{ __openclaw?: { seq?: number } }>;
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number;
|
||||
}>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 2,
|
||||
offset: firstPage.payload?.nextOffset,
|
||||
maxChars: 100,
|
||||
});
|
||||
expect(secondPage.ok).toBe(true);
|
||||
expect(secondPage.payload?.messages?.map(readOpenClawSeq)).toEqual([1, 2]);
|
||||
expect(JSON.stringify(secondPage.payload?.messages)).not.toContain("visible boundary");
|
||||
expect(secondPage.payload?.hasMore).toBe(false);
|
||||
expect(secondPage.payload?.nextOffset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.history offset pagination advances from the final budgeted page", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({
|
||||
ws,
|
||||
createSessionDir,
|
||||
historyMaxBytes: 250,
|
||||
});
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "older question" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "older answer" }],
|
||||
timestamp: Date.now() + 1,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "latest" }],
|
||||
timestamp: Date.now() + 2,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const firstPage = await rpcReq<{
|
||||
messages?: Array<{ __openclaw?: { seq?: number } }>;
|
||||
nextOffset?: number;
|
||||
hasMore?: boolean;
|
||||
totalMessages?: number;
|
||||
}>(ws, "chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 3,
|
||||
offset: 0,
|
||||
maxChars: 1_000,
|
||||
});
|
||||
expect(firstPage.ok).toBe(true);
|
||||
expect(firstPage.payload?.messages?.map(readOpenClawSeq)).toEqual([2, 3]);
|
||||
expect(firstPage.payload?.nextOffset).toBe(2);
|
||||
expect(firstPage.payload?.hasMore).toBe(true);
|
||||
expect(firstPage.payload?.totalMessages).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
test("smoke: supports abort and idempotent completion", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const spy = getReplyFromConfig;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
readRecentSessionMessagesWithStats as readRecentSessionMessagesWithStatsFile,
|
||||
readRecentSessionMessagesWithStatsAsync as readRecentSessionMessagesWithStatsAsyncFile,
|
||||
readRecentSessionTranscriptLines as readRecentSessionTranscriptLinesFile,
|
||||
readSessionMessagesPageWithStatsAsync as readSessionMessagesPageWithStatsAsyncFile,
|
||||
readRecentSessionUsageFromTranscript as readRecentSessionUsageFromTranscriptFile,
|
||||
readRecentSessionUsageFromTranscriptAsync as readRecentSessionUsageFromTranscriptAsyncFile,
|
||||
readSessionMessageByIdAsync as readSessionMessageByIdAsyncFile,
|
||||
@@ -269,21 +268,6 @@ export async function readRecentSessionMessagesWithStatsAsync(
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads one offset page with total-count metadata through the reader seam. */
|
||||
export async function readSessionMessagesPageWithStatsAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: { offset: number; maxMessages: number; allowResetArchiveFallback?: boolean },
|
||||
): Promise<ReadRecentSessionMessagesResult> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readSessionMessagesPageWithStatsAsyncFile(
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads a bounded transcript tail for compaction and diagnostics through the reader seam. */
|
||||
export function readRecentSessionTranscriptLines(
|
||||
params: SessionTranscriptReadScope & {
|
||||
|
||||
@@ -180,12 +180,6 @@ export type ReadRecentSessionMessagesOptions = {
|
||||
allowResetArchiveFallback?: boolean;
|
||||
};
|
||||
|
||||
export type ReadSessionMessagesPageOptions = {
|
||||
offset: number;
|
||||
maxMessages: number;
|
||||
allowResetArchiveFallback?: boolean;
|
||||
};
|
||||
|
||||
export type ReadSessionMessagesAsyncOptions =
|
||||
| {
|
||||
mode: "full";
|
||||
@@ -758,38 +752,6 @@ export async function readRecentSessionMessagesWithStatsAsync(
|
||||
return { messages: messagesWithSeq, totalMessages, transcriptPath: filePath };
|
||||
}
|
||||
|
||||
export async function readSessionMessagesPageWithStatsAsync(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile: string | undefined,
|
||||
opts: ReadSessionMessagesPageOptions,
|
||||
agentId?: string,
|
||||
): Promise<ReadRecentSessionMessagesResult> {
|
||||
const filePath =
|
||||
opts.allowResetArchiveFallback === true
|
||||
? await findExistingTranscriptHistoryPathAsync(sessionId, storePath, sessionFile, agentId)
|
||||
: findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId);
|
||||
if (!filePath) {
|
||||
return { messages: [], totalMessages: 0 };
|
||||
}
|
||||
const index = await readSessionTranscriptIndex(filePath);
|
||||
if (!index) {
|
||||
return { messages: [], totalMessages: 0, transcriptPath: filePath };
|
||||
}
|
||||
const totalMessages = index.entries.length;
|
||||
const offset = Math.min(resolveNonNegativeIntegerOption(opts.offset, 0), totalMessages);
|
||||
const maxMessages = resolveNonNegativeIntegerOption(opts.maxMessages, 0);
|
||||
const endExclusive = Math.max(0, totalMessages - offset);
|
||||
const start = Math.max(0, endExclusive - maxMessages);
|
||||
return {
|
||||
messages: index.entries
|
||||
.slice(start, endExclusive)
|
||||
.flatMap((entry) => indexedTranscriptEntryToMessages(entry)),
|
||||
totalMessages,
|
||||
transcriptPath: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function readRecentSessionTranscriptLines(params: {
|
||||
sessionId: string;
|
||||
storePath: string | undefined;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/** Persists hosted official external plugin catalog snapshots in OpenClaw state. */
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import type {
|
||||
HostedOfficialExternalPluginCatalogMetadata,
|
||||
HostedOfficialExternalPluginCatalogSnapshot,
|
||||
HostedOfficialExternalPluginCatalogSnapshotStore,
|
||||
} from "./official-external-plugin-catalog.js";
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogSnapshotStoreOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
stateDatabasePath?: string;
|
||||
};
|
||||
|
||||
type HostedCatalogSnapshotRow = {
|
||||
feed_url: string;
|
||||
body: string;
|
||||
status: number | bigint;
|
||||
etag: string | null;
|
||||
last_modified: string | null;
|
||||
checksum: string;
|
||||
saved_at: string;
|
||||
};
|
||||
|
||||
type HostedCatalogSnapshotDatabase = Pick<
|
||||
OpenClawStateKyselyDatabase,
|
||||
"official_external_plugin_catalog_snapshots"
|
||||
>;
|
||||
|
||||
function resolveStoreEnv(
|
||||
options: HostedOfficialExternalPluginCatalogSnapshotStoreOptions,
|
||||
): NodeJS.ProcessEnv | undefined {
|
||||
if (!options.stateDir) {
|
||||
return options.env;
|
||||
}
|
||||
return {
|
||||
...(options.env ?? process.env),
|
||||
OPENCLAW_STATE_DIR: options.stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStateDatabaseOptions(
|
||||
options: HostedOfficialExternalPluginCatalogSnapshotStoreOptions,
|
||||
): OpenClawStateDatabaseOptions {
|
||||
const env = resolveStoreEnv(options);
|
||||
return {
|
||||
...(env ? { env } : {}),
|
||||
...(options.stateDatabasePath ? { path: options.stateDatabasePath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStateDatabasePath(
|
||||
options: HostedOfficialExternalPluginCatalogSnapshotStoreOptions,
|
||||
): string {
|
||||
if (options.stateDatabasePath) {
|
||||
return options.stateDatabasePath;
|
||||
}
|
||||
return resolveOpenClawStateSqlitePath(resolveStoreEnv(options) ?? process.env);
|
||||
}
|
||||
|
||||
function rowToSnapshot(
|
||||
row: HostedCatalogSnapshotRow | undefined,
|
||||
): HostedOfficialExternalPluginCatalogSnapshot | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const metadata: HostedOfficialExternalPluginCatalogMetadata = {
|
||||
url: row.feed_url,
|
||||
status: Number(row.status),
|
||||
checksum: row.checksum,
|
||||
...(row.etag ? { etag: row.etag } : {}),
|
||||
...(row.last_modified ? { lastModified: row.last_modified } : {}),
|
||||
};
|
||||
return {
|
||||
body: row.body,
|
||||
metadata,
|
||||
savedAt: row.saved_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a snapshot store backed by the shared `state/openclaw.sqlite` database. */
|
||||
export function createSqliteHostedOfficialExternalPluginCatalogSnapshotStore(
|
||||
options: HostedOfficialExternalPluginCatalogSnapshotStoreOptions = {},
|
||||
): HostedOfficialExternalPluginCatalogSnapshotStore {
|
||||
return {
|
||||
async read(url) {
|
||||
const pathname = resolveStateDatabasePath(options);
|
||||
if (!existsSync(pathname)) {
|
||||
return null;
|
||||
}
|
||||
const database = openOpenClawStateDatabase(resolveStateDatabaseOptions(options));
|
||||
const stateDb = getNodeSqliteKysely<HostedCatalogSnapshotDatabase>(database.db);
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
database.db,
|
||||
stateDb
|
||||
.selectFrom("official_external_plugin_catalog_snapshots")
|
||||
.select(["feed_url", "body", "status", "etag", "last_modified", "checksum", "saved_at"])
|
||||
.where("feed_url", "=", url),
|
||||
) as HostedCatalogSnapshotRow | undefined;
|
||||
return rowToSnapshot(row);
|
||||
},
|
||||
async write(snapshot) {
|
||||
const now = Date.now();
|
||||
runOpenClawStateWriteTransaction((database) => {
|
||||
const stateDb = getNodeSqliteKysely<HostedCatalogSnapshotDatabase>(database.db);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
stateDb
|
||||
.insertInto("official_external_plugin_catalog_snapshots")
|
||||
.values({
|
||||
feed_url: snapshot.metadata.url,
|
||||
body: snapshot.body,
|
||||
status: snapshot.metadata.status,
|
||||
etag: snapshot.metadata.etag ?? null,
|
||||
last_modified: snapshot.metadata.lastModified ?? null,
|
||||
checksum: snapshot.metadata.checksum,
|
||||
saved_at: snapshot.savedAt,
|
||||
updated_at_ms: now,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("feed_url").doUpdateSet({
|
||||
body: snapshot.body,
|
||||
status: snapshot.metadata.status,
|
||||
etag: snapshot.metadata.etag ?? null,
|
||||
last_modified: snapshot.metadata.lastModified ?? null,
|
||||
checksum: snapshot.metadata.checksum,
|
||||
saved_at: snapshot.savedAt,
|
||||
updated_at_ms: now,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, resolveStateDatabaseOptions(options));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" };
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { createSqliteHostedOfficialExternalPluginCatalogSnapshotStore } from "./official-external-plugin-catalog-snapshot-store.js";
|
||||
import {
|
||||
type OfficialExternalPluginCatalogEntry,
|
||||
DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL,
|
||||
createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore,
|
||||
getOfficialExternalPluginCatalogEntry,
|
||||
isOfficialExternalPluginCatalogFeed,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
loadHostedOfficialExternalPluginCatalogEntries,
|
||||
parseOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalProviderContractPluginIds,
|
||||
resolveOfficialExternalProviderPluginIds,
|
||||
@@ -31,16 +23,6 @@ function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
|
||||
}
|
||||
|
||||
describe("official external plugin catalog", () => {
|
||||
it("keeps hosted fetch guard loading lazy for bundled catalog import paths", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./official-external-plugin-catalog.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toMatch(/from ["']\.\.\/infra\/net\/fetch-guard\.js["']/);
|
||||
expect(source).toContain('await import("../infra/net/fetch-guard.js")');
|
||||
});
|
||||
|
||||
it("ships the official plugin catalog as a feed-shaped bundled fallback", () => {
|
||||
expect(isOfficialExternalPluginCatalogFeed(officialExternalPluginCatalog)).toBe(true);
|
||||
expect(officialExternalPluginCatalog).toMatchObject({
|
||||
@@ -63,7 +45,7 @@ describe("official external plugin catalog", () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOfficialExternalPluginCatalogFeed({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 2,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 1,
|
||||
@@ -72,135 +54,10 @@ describe("official external plugin catalog", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the live ClawHub feed schema version", () => {
|
||||
expect(
|
||||
isOfficialExternalPluginCatalogFeed({
|
||||
schemaVersion: 2,
|
||||
id: "clawhub-official",
|
||||
generatedAt: "2026-06-25T01:19:39.629Z",
|
||||
sequence: 11,
|
||||
entries: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps live ClawHub marketplace entries as metadata-only feed entries", () => {
|
||||
const [entry] = parseOfficialExternalPluginCatalogEntries({
|
||||
schemaVersion: 2,
|
||||
id: "clawhub-official",
|
||||
generatedAt: "2026-06-25T01:19:39.629Z",
|
||||
sequence: 11,
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@expediagroup/expedia-openclaw",
|
||||
title: "Expedia Travel",
|
||||
version: "1.0.4",
|
||||
state: "available",
|
||||
publisher: {
|
||||
id: "expediagroup",
|
||||
trust: "official",
|
||||
},
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@expediagroup/expedia-openclaw",
|
||||
version: "1.0.4",
|
||||
integrity:
|
||||
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (entry === undefined) {
|
||||
throw new Error("Expected hosted ClawHub feed entry to parse");
|
||||
}
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
id: "@expediagroup/expedia-openclaw",
|
||||
title: "Expedia Travel",
|
||||
version: "1.0.4",
|
||||
});
|
||||
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
|
||||
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not synthesize trusted installs for unavailable or untrusted hosted entries", () => {
|
||||
const entries = parseOfficialExternalPluginCatalogEntries({
|
||||
schemaVersion: 2,
|
||||
id: "clawhub-official",
|
||||
generatedAt: "2026-06-25T01:19:39.629Z",
|
||||
sequence: 11,
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/unavailable",
|
||||
title: "Unavailable",
|
||||
version: "1.0.0",
|
||||
state: "disabled",
|
||||
publisher: { id: "example", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@example/unavailable",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/community",
|
||||
title: "Community",
|
||||
version: "1.0.0",
|
||||
state: "available",
|
||||
publisher: { id: "example", trust: "community" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@example/community",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@example/private-source",
|
||||
title: "Private Source",
|
||||
version: "1.0.0",
|
||||
state: "available",
|
||||
publisher: { id: "example", trust: "official" },
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "private-feed",
|
||||
package: "@example/private-source",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(3);
|
||||
for (const entry of entries) {
|
||||
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
|
||||
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unsupported versioned feed wrappers out of legacy catalog parsing", () => {
|
||||
expect(
|
||||
parseOfficialExternalPluginCatalogEntries({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 2,
|
||||
id: "future-feed",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 1,
|
||||
@@ -214,589 +71,6 @@ describe("official external plugin catalog", () => {
|
||||
).toEqual([{ name: "legacy-catalog-entry" }]);
|
||||
});
|
||||
|
||||
it("loads a hosted feed with conditional headers and checksum metadata", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 2,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/hosted-proof",
|
||||
kind: "plugin",
|
||||
openclaw: {
|
||||
plugin: { id: "hosted-proof", label: "Hosted Proof" },
|
||||
install: { npmSpec: "@openclaw/hosted-proof", defaultChoice: "npm" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetchImpl = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("if-none-match")).toBe('"old"');
|
||||
expect(headers.get("if-modified-since")).toBe("Mon, 22 Jun 2026 00:00:00 GMT");
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
etag: '"next"',
|
||||
"last-modified": "Mon, 22 Jun 2026 01:00:00 GMT",
|
||||
"content-length": String(new TextEncoder().encode(body).byteLength),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
fetchImpl,
|
||||
ifNoneMatch: '"old"',
|
||||
ifModifiedSince: "Mon, 22 Jun 2026 00:00:00 GMT",
|
||||
snapshotStore: null,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/hosted-proof"]);
|
||||
if (result.source === "hosted") {
|
||||
expect(result.feed.sequence).toBe(2);
|
||||
expect(result.metadata).toMatchObject({
|
||||
status: 200,
|
||||
etag: '"next"',
|
||||
lastModified: "Mon, 22 Jun 2026 01:00:00 GMT",
|
||||
});
|
||||
expect(result.metadata.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps live ClawHub metadata-only entries after hosted feed loading", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
id: "clawhub-official",
|
||||
generatedAt: "2026-06-25T01:19:39.629Z",
|
||||
sequence: 11,
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "@expediagroup/expedia-openclaw",
|
||||
title: "Expedia Travel",
|
||||
version: "1.0.4",
|
||||
state: "available",
|
||||
publisher: {
|
||||
id: "expediagroup",
|
||||
trust: "official",
|
||||
},
|
||||
install: {
|
||||
candidates: [
|
||||
{
|
||||
sourceRef: "public-clawhub",
|
||||
package: "@expediagroup/expedia-openclaw",
|
||||
version: "1.0.4",
|
||||
integrity:
|
||||
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-length": String(new TextEncoder().encode(body).byteLength),
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0]).toMatchObject({
|
||||
id: "@expediagroup/expedia-openclaw",
|
||||
title: "Expedia Travel",
|
||||
version: "1.0.4",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the bundled catalog when hosted feed validation fails", async () => {
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: null,
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ schemaVersion: 1, id: " ", entries: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
expect(result.entries.length).toBe(listOfficialExternalPluginCatalogEntries().length);
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("supported schema version");
|
||||
expect(result.metadata?.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the bundled catalog on HTTP 304 until a snapshot cache exists", async () => {
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: null,
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 304,
|
||||
headers: { etag: '"same"', "last-modified": "Mon, 22 Jun 2026 01:00:00 GMT" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("HTTP 304");
|
||||
expect(result.metadata).toMatchObject({
|
||||
status: 304,
|
||||
etag: '"same"',
|
||||
lastModified: "Mon, 22 Jun 2026 01:00:00 GMT",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a validated hosted feed snapshot after a successful fetch", async () => {
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore();
|
||||
const writeSpy = vi.spyOn(snapshotStore, "write");
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 3,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/snapshot-write-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "snapshot-write-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
now: () => new Date("2026-06-22T01:02:03.000Z"),
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { etag: '"fresh"' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted");
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
const snapshot = await snapshotStore.read(
|
||||
result.source === "hosted" ? result.metadata.url : "",
|
||||
);
|
||||
expect(snapshot).toMatchObject({
|
||||
body,
|
||||
savedAt: "2026-06-22T01:02:03.000Z",
|
||||
metadata: { etag: '"fresh"' },
|
||||
});
|
||||
expect(snapshot?.metadata.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("persists hosted feed snapshots in OpenClaw state for HTTP 304 reuse", async () => {
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-hosted-catalog-"));
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 7,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/sqlite-snapshot-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "sqlite-snapshot-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
stateDir,
|
||||
now: () => new Date("2026-06-22T02:03:04.000Z"),
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
etag: '"sqlite"',
|
||||
"last-modified": "Mon, 22 Jun 2026 02:00:00 GMT",
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
stateDir,
|
||||
fetchImpl: vi.fn(async () => new Response(null, { status: 304 })),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted-snapshot");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||
"@openclaw/sqlite-snapshot-proof",
|
||||
]);
|
||||
if (result.source === "hosted-snapshot") {
|
||||
expect(result.snapshot.savedAt).toBe("2026-06-22T02:03:04.000Z");
|
||||
expect(result.metadata.checksum).toBe(seeded.metadata.checksum);
|
||||
}
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and updates hosted catalog snapshots in the SQLite store", async () => {
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-hosted-store-"));
|
||||
try {
|
||||
const store = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({ stateDir });
|
||||
const url = "https://register.openclaw.ai/official-external-plugin-catalog.json";
|
||||
|
||||
const firstBody = JSON.stringify({ entries: [] });
|
||||
const secondBody = JSON.stringify({ entries: [{}] });
|
||||
|
||||
await expect(store.read(url)).resolves.toBeNull();
|
||||
await store.write({
|
||||
body: firstBody,
|
||||
metadata: {
|
||||
url,
|
||||
status: 200,
|
||||
etag: '"first"',
|
||||
checksum: "sha256:first",
|
||||
},
|
||||
savedAt: "2026-06-22T02:03:04.000Z",
|
||||
});
|
||||
await store.write({
|
||||
body: secondBody,
|
||||
metadata: {
|
||||
url,
|
||||
status: 200,
|
||||
lastModified: "Mon, 22 Jun 2026 03:00:00 GMT",
|
||||
checksum: "sha256:second",
|
||||
},
|
||||
savedAt: "2026-06-22T03:04:05.000Z",
|
||||
});
|
||||
|
||||
await expect(store.read(url)).resolves.toMatchObject({
|
||||
body: secondBody,
|
||||
metadata: {
|
||||
url,
|
||||
status: 200,
|
||||
lastModified: "Mon, 22 Jun 2026 03:00:00 GMT",
|
||||
checksum: "sha256:second",
|
||||
},
|
||||
savedAt: "2026-06-22T03:04:05.000Z",
|
||||
});
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the last known good snapshot when the hosted feed returns HTTP 304", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 4,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/snapshot-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "snapshot-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { etag: '"snapshot-v1"' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{
|
||||
body,
|
||||
metadata: seeded.metadata,
|
||||
savedAt: "2026-06-22T01:02:03.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
ifNoneMatch: '"snapshot-v1"',
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 304,
|
||||
headers: { etag: '"snapshot-v1"' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted-snapshot");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/snapshot-proof"]);
|
||||
if (result.source === "hosted-snapshot") {
|
||||
expect(result.error).toContain("HTTP 304");
|
||||
expect(result.snapshot.savedAt).toBe("2026-06-22T01:02:03.000Z");
|
||||
expect(result.metadata.checksum).toBe(seeded.metadata.checksum);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use a stale snapshot when HTTP 304 validators do not match", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 4,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/stale-snapshot-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "stale-snapshot-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { etag: '"snapshot-v1"' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{
|
||||
body,
|
||||
metadata: seeded.metadata,
|
||||
savedAt: "2026-06-22T01:02:03.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
ifNoneMatch: '"snapshot-v2"',
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 304,
|
||||
headers: { etag: '"snapshot-v2"' },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("snapshot fallback failed");
|
||||
expect(result.error).toContain("ETag");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a valid snapshot before bundled fallback when hosted validation fails", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 5,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/snapshot-validation-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "snapshot-validation-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
|
||||
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{ body, metadata: seeded.metadata, savedAt: "2026-06-22T01:02:03.000Z" },
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
fetchImpl: vi.fn(async () => new Response("{ nope", { status: 200 })),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted-snapshot");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||
"@openclaw/snapshot-validation-proof",
|
||||
]);
|
||||
if (result.source === "hosted-snapshot") {
|
||||
expect(result.error).toContain("JSON");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use a stale snapshot when hosted validation fails with unmatched validators", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 5,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/stale-validation-snapshot-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "stale-validation-snapshot-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
|
||||
fetchImpl: vi.fn(
|
||||
async () => new Response(body, { status: 200, headers: { etag: '"snapshot-v1"' } }),
|
||||
),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{ body, metadata: seeded.metadata, savedAt: "2026-06-22T01:02:03.000Z" },
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
ifNoneMatch: '"snapshot-v2"',
|
||||
fetchImpl: vi.fn(async () => new Response("{ nope", { status: 200 })),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("snapshot fallback failed");
|
||||
expect(result.error).toContain("ETag");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to bundled entries when the snapshot is invalid", async () => {
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 1,
|
||||
entries: [],
|
||||
}),
|
||||
metadata: {
|
||||
url: DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL,
|
||||
status: 200,
|
||||
checksum: "sha256:not-current",
|
||||
},
|
||||
savedAt: "2026-06-22T01:02:03.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
fetchImpl: vi.fn(async () => new Response(null, { status: 304 })),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("snapshot fallback failed");
|
||||
expect(result.error).toContain("checksum mismatch");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use a snapshot that violates the expected checksum", async () => {
|
||||
const body = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 6,
|
||||
entries: [
|
||||
{
|
||||
name: "@openclaw/snapshot-pin-proof",
|
||||
kind: "plugin",
|
||||
openclaw: { plugin: { id: "snapshot-pin-proof" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
|
||||
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
});
|
||||
if (seeded.source !== "hosted") {
|
||||
throw new Error("expected seeded hosted feed");
|
||||
}
|
||||
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
|
||||
{ body, metadata: seeded.metadata, savedAt: "2026-06-22T01:02:03.000Z" },
|
||||
]);
|
||||
|
||||
const result = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore,
|
||||
expectedSha256: "sha256:not-current",
|
||||
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("bundled-fallback");
|
||||
if (result.source === "bundled-fallback") {
|
||||
expect(result.error).toContain("snapshot fallback failed");
|
||||
expect(result.error).toContain("expected checksum");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the bundled catalog on checksum mismatch and oversized bodies", async () => {
|
||||
const mismatch = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: null,
|
||||
expectedSha256: "sha256:not-current",
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "openclaw-official-external-plugins",
|
||||
generatedAt: "2026-06-22T00:00:00.000Z",
|
||||
sequence: 1,
|
||||
entries: [],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
});
|
||||
expect(mismatch.source).toBe("bundled-fallback");
|
||||
if (mismatch.source === "bundled-fallback") {
|
||||
expect(mismatch.error).toContain("checksum mismatch");
|
||||
expect(mismatch.metadata?.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
}
|
||||
|
||||
const oversized = await loadHostedOfficialExternalPluginCatalogEntries({
|
||||
snapshotStore: null,
|
||||
maxBytes: 4,
|
||||
fetchImpl: vi.fn(async () => new Response("12345", { status: 200 })),
|
||||
});
|
||||
expect(oversized.source).toBe("bundled-fallback");
|
||||
if (oversized.source === "bundled-fallback") {
|
||||
expect(oversized.error).toContain("exceeds 4 bytes");
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the externalized provider and capability plugins with install metadata", () => {
|
||||
const providers = [
|
||||
["arcee", "@openclaw/arcee-provider"],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** Reads official external plugin/channel/provider catalogs into manifest-like metadata. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import officialExternalChannelCatalog from "../../scripts/lib/official-external-channel-catalog.json" with { type: "json" };
|
||||
@@ -78,34 +77,16 @@ export type OfficialExternalPluginCatalogManifest = {
|
||||
|
||||
/** Raw official external catalog entry loaded from generated catalog JSON. */
|
||||
export type OfficialExternalPluginCatalogEntry = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
state?: string;
|
||||
publisher?: {
|
||||
id?: string;
|
||||
trust?: string;
|
||||
};
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
kind?: string;
|
||||
install?: {
|
||||
candidates?: readonly OfficialExternalPluginCatalogInstallCandidate[];
|
||||
};
|
||||
} & Partial<Record<ManifestKey, OfficialExternalPluginCatalogManifest>>;
|
||||
|
||||
export type OfficialExternalPluginCatalogInstallCandidate = {
|
||||
sourceRef?: string;
|
||||
package?: string;
|
||||
version?: string;
|
||||
integrity?: string;
|
||||
};
|
||||
|
||||
/** Feed-shaped wrapper used by the bundled external plugin catalog fallback. */
|
||||
export type OfficialExternalPluginCatalogFeed = {
|
||||
schemaVersion: 1 | 2;
|
||||
schemaVersion: number;
|
||||
id: string;
|
||||
generatedAt: string;
|
||||
sequence: number;
|
||||
@@ -113,51 +94,6 @@ export type OfficialExternalPluginCatalogFeed = {
|
||||
entries: readonly OfficialExternalPluginCatalogEntry[];
|
||||
};
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogMetadata = {
|
||||
url: string;
|
||||
status: number;
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
checksum: string;
|
||||
};
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogSnapshot = {
|
||||
body: string;
|
||||
metadata: HostedOfficialExternalPluginCatalogMetadata;
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogSnapshotStore = {
|
||||
read: (url: string) => Promise<HostedOfficialExternalPluginCatalogSnapshot | null | undefined>;
|
||||
write: (snapshot: HostedOfficialExternalPluginCatalogSnapshot) => Promise<void>;
|
||||
};
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogLoadResult =
|
||||
| {
|
||||
source: "hosted";
|
||||
entries: OfficialExternalPluginCatalogEntry[];
|
||||
feed: OfficialExternalPluginCatalogFeed;
|
||||
metadata: HostedOfficialExternalPluginCatalogMetadata;
|
||||
}
|
||||
| {
|
||||
source: "hosted-snapshot";
|
||||
entries: OfficialExternalPluginCatalogEntry[];
|
||||
feed: OfficialExternalPluginCatalogFeed;
|
||||
metadata: HostedOfficialExternalPluginCatalogMetadata;
|
||||
snapshot: HostedOfficialExternalPluginCatalogSnapshot;
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
source: "bundled-fallback";
|
||||
entries: OfficialExternalPluginCatalogEntry[];
|
||||
error: string;
|
||||
metadata?: Omit<HostedOfficialExternalPluginCatalogMetadata, "checksum"> & {
|
||||
checksum?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
type OfficialExternalProviderContract =
|
||||
| "embeddingProviders"
|
||||
| "mediaUnderstandingProviders"
|
||||
@@ -171,13 +107,7 @@ const OFFICIAL_CATALOG_SOURCES = [
|
||||
officialExternalPluginCatalog,
|
||||
] as const;
|
||||
|
||||
const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS = new Set<unknown>([1, 2]);
|
||||
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL =
|
||||
"https://clawhub.ai/v1/feeds/plugins";
|
||||
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES = 1024 * 1024;
|
||||
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS = 5000;
|
||||
const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST = ["clawhub.ai"];
|
||||
const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSION = 1;
|
||||
|
||||
export function isOfficialExternalPluginCatalogFeed(
|
||||
raw: unknown,
|
||||
@@ -186,9 +116,8 @@ export function isOfficialExternalPluginCatalogFeed(
|
||||
return false;
|
||||
}
|
||||
const sequence = raw.sequence;
|
||||
const entries = raw.entries;
|
||||
return (
|
||||
OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS.has(raw.schemaVersion) &&
|
||||
raw.schemaVersion === OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSION &&
|
||||
typeof raw.id === "string" &&
|
||||
raw.id.trim().length > 0 &&
|
||||
typeof raw.generatedAt === "string" &&
|
||||
@@ -196,7 +125,7 @@ export function isOfficialExternalPluginCatalogFeed(
|
||||
typeof sequence === "number" &&
|
||||
Number.isInteger(sequence) &&
|
||||
sequence >= 0 &&
|
||||
Array.isArray(entries)
|
||||
Array.isArray(raw.entries)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,453 +153,6 @@ export function parseOfficialExternalPluginCatalogEntries(
|
||||
return list.filter((entry): entry is OfficialExternalPluginCatalogEntry => isRecord(entry));
|
||||
}
|
||||
|
||||
function normalizeHostedCatalogHeader(value: string | null): string | undefined {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function resolveHostedCatalogFeedUrl(feedUrl: string | undefined): URL {
|
||||
const raw = feedUrl?.trim() || DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error("hosted catalog feed URL is invalid");
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error("hosted catalog feed URL must use HTTPS");
|
||||
}
|
||||
if (!OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST.includes(parsed.hostname)) {
|
||||
throw new Error("hosted catalog feed URL hostname is not allowed");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseHostedCatalogContentLength(raw: string | null, maxBytes: number): void {
|
||||
const normalized = normalizeOptionalString(raw);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(normalized)) {
|
||||
throw new Error("hosted catalog feed has invalid content-length");
|
||||
}
|
||||
const size = Number(normalized);
|
||||
if (!Number.isSafeInteger(size) || size > maxBytes) {
|
||||
throw new Error(`hosted catalog feed exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasStreamingResponseBody(
|
||||
response: Response,
|
||||
): response is Response & { body: ReadableStream<Uint8Array> } {
|
||||
return Boolean(
|
||||
response.body && typeof (response.body as { getReader?: unknown }).getReader === "function",
|
||||
);
|
||||
}
|
||||
|
||||
async function readHostedCatalogChunkWithTimeout(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
chunkTimeoutMs: number,
|
||||
): Promise<Awaited<ReturnType<typeof reader.read>>> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let timedOut = false;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const clear = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
clear();
|
||||
void reader.cancel().catch(() => undefined);
|
||||
reject(new Error(`hosted catalog feed read timed out after ${chunkTimeoutMs}ms`));
|
||||
}, chunkTimeoutMs);
|
||||
void reader.read().then(
|
||||
(result) => {
|
||||
clear();
|
||||
if (!timedOut) {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
(err: unknown) => {
|
||||
clear();
|
||||
if (!timedOut) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readHostedCatalogResponseText(params: {
|
||||
response: Response;
|
||||
maxBytes: number;
|
||||
chunkTimeoutMs: number;
|
||||
}): Promise<string> {
|
||||
parseHostedCatalogContentLength(params.response.headers.get("content-length"), params.maxBytes);
|
||||
if (!hasStreamingResponseBody(params.response)) {
|
||||
const text = await params.response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > params.maxBytes) {
|
||||
throw new Error(`hosted catalog feed exceeds ${params.maxBytes} bytes`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
const reader = params.response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await readHostedCatalogChunkWithTimeout(reader, params.chunkTimeoutMs);
|
||||
if (chunk.done) {
|
||||
break;
|
||||
}
|
||||
totalBytes += chunk.value.byteLength;
|
||||
if (totalBytes > params.maxBytes) {
|
||||
throw new Error(`hosted catalog feed exceeds ${params.maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch (err) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw err;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const body = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(body);
|
||||
}
|
||||
|
||||
function bundledOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
|
||||
return OFFICIAL_CATALOG_SOURCES.flatMap((source) =>
|
||||
parseOfficialExternalPluginCatalogEntries(source),
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeOfficialExternalPluginCatalogEntries(
|
||||
entries: OfficialExternalPluginCatalogEntry[],
|
||||
): OfficialExternalPluginCatalogEntry[] {
|
||||
const resolved = new Map<string, OfficialExternalPluginCatalogEntry>();
|
||||
for (const entry of entries) {
|
||||
const key = resolveOfficialExternalPluginCatalogEntryKey(entry);
|
||||
if (key && !resolved.has(key)) {
|
||||
resolved.set(key, entry);
|
||||
}
|
||||
}
|
||||
return [...resolved.values()];
|
||||
}
|
||||
|
||||
function resolveOfficialExternalPluginCatalogEntryKey(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
): string | undefined {
|
||||
const pluginId = resolveOfficialExternalPluginId(entry);
|
||||
if (pluginId) {
|
||||
return `${normalizeOptionalString(entry.kind) ?? "plugin"}:${pluginId}`;
|
||||
}
|
||||
const name = normalizeOptionalString(entry.name);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
const id = normalizeOptionalString(entry.id);
|
||||
if (id) {
|
||||
return `${normalizeOptionalString(entry.kind) ?? normalizeOptionalString(entry.type) ?? "plugin"}:${id}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatHostedCatalogError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function bundledFallbackResult(
|
||||
error: unknown,
|
||||
metadata?: HostedOfficialExternalPluginCatalogLoadResult["metadata"],
|
||||
): HostedOfficialExternalPluginCatalogLoadResult {
|
||||
return {
|
||||
source: "bundled-fallback",
|
||||
entries: listOfficialExternalPluginCatalogEntries(),
|
||||
error: formatHostedCatalogError(error),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function loadHostedCatalogSnapshotResult(params: {
|
||||
snapshot: HostedOfficialExternalPluginCatalogSnapshot;
|
||||
error: unknown;
|
||||
expectedSha256?: string;
|
||||
ifNoneMatch?: string;
|
||||
ifModifiedSince?: string;
|
||||
}): HostedOfficialExternalPluginCatalogLoadResult {
|
||||
assertSnapshotMatchesRequestValidators({
|
||||
snapshot: params.snapshot,
|
||||
ifNoneMatch: params.ifNoneMatch,
|
||||
ifModifiedSince: params.ifModifiedSince,
|
||||
});
|
||||
const checksum = sha256Hex(params.snapshot.body);
|
||||
if (checksum !== params.snapshot.metadata.checksum) {
|
||||
throw new Error("hosted catalog snapshot checksum mismatch");
|
||||
}
|
||||
if (params.expectedSha256 && params.expectedSha256 !== checksum) {
|
||||
throw new Error("hosted catalog snapshot checksum did not match expected checksum");
|
||||
}
|
||||
const raw = JSON.parse(params.snapshot.body) as unknown;
|
||||
if (!isOfficialExternalPluginCatalogFeed(raw)) {
|
||||
throw new Error("hosted catalog snapshot did not match schema version 1");
|
||||
}
|
||||
return {
|
||||
source: "hosted-snapshot",
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
),
|
||||
feed: raw,
|
||||
metadata: params.snapshot.metadata,
|
||||
snapshot: params.snapshot,
|
||||
error: formatHostedCatalogError(params.error),
|
||||
};
|
||||
}
|
||||
|
||||
function assertSnapshotMatchesRequestValidators(params: {
|
||||
snapshot: HostedOfficialExternalPluginCatalogSnapshot;
|
||||
ifNoneMatch?: string;
|
||||
ifModifiedSince?: string;
|
||||
}): void {
|
||||
if (params.ifNoneMatch && params.snapshot.metadata.etag !== params.ifNoneMatch) {
|
||||
throw new Error("hosted catalog snapshot ETag did not match request validator");
|
||||
}
|
||||
if (
|
||||
!params.ifNoneMatch &&
|
||||
params.ifModifiedSince &&
|
||||
params.snapshot.metadata.lastModified !== params.ifModifiedSince
|
||||
) {
|
||||
throw new Error("hosted catalog snapshot Last-Modified did not match request validator");
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotOrBundledFallbackResult(params: {
|
||||
error: unknown;
|
||||
snapshotStore?: HostedOfficialExternalPluginCatalogSnapshotStore;
|
||||
url: string;
|
||||
metadata?: HostedOfficialExternalPluginCatalogLoadResult["metadata"];
|
||||
expectedSha256?: string;
|
||||
ifNoneMatch?: string;
|
||||
ifModifiedSince?: string;
|
||||
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
|
||||
if (params.snapshotStore) {
|
||||
try {
|
||||
const snapshot = await params.snapshotStore.read(params.url);
|
||||
if (snapshot) {
|
||||
return loadHostedCatalogSnapshotResult({
|
||||
snapshot,
|
||||
error: params.error,
|
||||
expectedSha256: params.expectedSha256,
|
||||
ifNoneMatch: params.ifNoneMatch,
|
||||
ifModifiedSince: params.ifModifiedSince,
|
||||
});
|
||||
}
|
||||
} catch (snapshotErr) {
|
||||
return bundledFallbackResult(
|
||||
`${formatHostedCatalogError(params.error)}; snapshot fallback failed: ${formatHostedCatalogError(snapshotErr)}`,
|
||||
params.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
return bundledFallbackResult(params.error, params.metadata);
|
||||
}
|
||||
|
||||
export function createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(
|
||||
initialSnapshots: HostedOfficialExternalPluginCatalogSnapshot[] = [],
|
||||
): HostedOfficialExternalPluginCatalogSnapshotStore {
|
||||
const snapshots = new Map<string, HostedOfficialExternalPluginCatalogSnapshot>();
|
||||
for (const snapshot of initialSnapshots) {
|
||||
snapshots.set(snapshot.metadata.url, snapshot);
|
||||
}
|
||||
return {
|
||||
async read(url) {
|
||||
return snapshots.get(url) ?? null;
|
||||
},
|
||||
async write(snapshot) {
|
||||
snapshots.set(snapshot.metadata.url, snapshot);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHostedCatalogSnapshotStore(params: {
|
||||
snapshotStore?: HostedOfficialExternalPluginCatalogSnapshotStore | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
stateDatabasePath?: string;
|
||||
}): Promise<HostedOfficialExternalPluginCatalogSnapshotStore | undefined> {
|
||||
if (params.snapshotStore !== undefined) {
|
||||
return params.snapshotStore ?? undefined;
|
||||
}
|
||||
const { createSqliteHostedOfficialExternalPluginCatalogSnapshotStore } =
|
||||
await import("./official-external-plugin-catalog-snapshot-store.js");
|
||||
return createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
...(params.stateDir ? { stateDir: params.stateDir } : {}),
|
||||
...(params.stateDatabasePath ? { stateDatabasePath: params.stateDatabasePath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
feedUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
maxBytes?: number;
|
||||
chunkTimeoutMs?: number;
|
||||
ifNoneMatch?: string;
|
||||
ifModifiedSince?: string;
|
||||
expectedSha256?: string;
|
||||
snapshotStore?: HostedOfficialExternalPluginCatalogSnapshotStore | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
stateDatabasePath?: string;
|
||||
now?: () => Date;
|
||||
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = resolveHostedCatalogFeedUrl(params?.feedUrl);
|
||||
} catch (err) {
|
||||
return bundledFallbackResult(err);
|
||||
}
|
||||
const snapshotStore = await resolveHostedCatalogSnapshotStore({
|
||||
snapshotStore: params?.snapshotStore,
|
||||
env: params?.env,
|
||||
stateDir: params?.stateDir,
|
||||
stateDatabasePath: params?.stateDatabasePath,
|
||||
});
|
||||
const headers = new Headers();
|
||||
const ifNoneMatch = normalizeOptionalString(params?.ifNoneMatch);
|
||||
const ifModifiedSince = normalizeOptionalString(params?.ifModifiedSince);
|
||||
const expectedSha256 = normalizeOptionalString(params?.expectedSha256);
|
||||
if (ifNoneMatch) {
|
||||
headers.set("if-none-match", ifNoneMatch);
|
||||
}
|
||||
if (ifModifiedSince) {
|
||||
headers.set("if-modified-since", ifModifiedSince);
|
||||
}
|
||||
const metadataBase = (response: Response) => {
|
||||
const etag = normalizeHostedCatalogHeader(response.headers.get("etag"));
|
||||
const lastModified = normalizeHostedCatalogHeader(response.headers.get("last-modified"));
|
||||
return {
|
||||
url: url.href,
|
||||
status: response.status,
|
||||
...(etag ? { etag } : {}),
|
||||
...(lastModified ? { lastModified } : {}),
|
||||
};
|
||||
};
|
||||
let response: Response | undefined;
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const { fetchWithSsrFGuard } = await import("../infra/net/fetch-guard.js");
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url: url.href,
|
||||
fetchImpl: params?.fetchImpl,
|
||||
init: { method: "GET", headers },
|
||||
requireHttps: true,
|
||||
maxRedirects: 2,
|
||||
timeoutMs: params?.timeoutMs ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS,
|
||||
policy: { hostnameAllowlist: OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST },
|
||||
auditContext: "official-external-plugin-catalog-feed",
|
||||
});
|
||||
response = guarded.response;
|
||||
release = guarded.release;
|
||||
const base = metadataBase(response);
|
||||
if (response.status === 304) {
|
||||
return await snapshotOrBundledFallbackResult({
|
||||
error: "hosted catalog feed returned HTTP 304",
|
||||
snapshotStore,
|
||||
url: url.href,
|
||||
metadata: base,
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
return await snapshotOrBundledFallbackResult({
|
||||
error: `hosted catalog feed returned HTTP ${response.status}`,
|
||||
snapshotStore,
|
||||
url: url.href,
|
||||
metadata: base,
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
});
|
||||
}
|
||||
const body = await readHostedCatalogResponseText({
|
||||
response,
|
||||
maxBytes: params?.maxBytes ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES,
|
||||
chunkTimeoutMs:
|
||||
params?.chunkTimeoutMs ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS,
|
||||
});
|
||||
const checksum = sha256Hex(body);
|
||||
const metadata = { ...base, checksum };
|
||||
if (expectedSha256 && expectedSha256 !== checksum) {
|
||||
return await snapshotOrBundledFallbackResult({
|
||||
error: `hosted catalog feed checksum mismatch: expected ${expectedSha256}`,
|
||||
snapshotStore,
|
||||
url: url.href,
|
||||
metadata,
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
});
|
||||
}
|
||||
const raw = JSON.parse(body) as unknown;
|
||||
if (!isOfficialExternalPluginCatalogFeed(raw)) {
|
||||
return await snapshotOrBundledFallbackResult({
|
||||
error: "hosted catalog feed did not match a supported schema version",
|
||||
snapshotStore,
|
||||
url: url.href,
|
||||
metadata,
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
});
|
||||
}
|
||||
await snapshotStore
|
||||
?.write({
|
||||
body,
|
||||
metadata,
|
||||
savedAt: (params?.now?.() ?? new Date()).toISOString(),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return {
|
||||
source: "hosted",
|
||||
entries: dedupeOfficialExternalPluginCatalogEntries(
|
||||
parseOfficialExternalPluginCatalogEntries(raw),
|
||||
),
|
||||
feed: raw,
|
||||
metadata,
|
||||
};
|
||||
} catch (err) {
|
||||
return await snapshotOrBundledFallbackResult({
|
||||
error: err,
|
||||
snapshotStore,
|
||||
url: url.href,
|
||||
expectedSha256,
|
||||
ifNoneMatch,
|
||||
ifModifiedSince,
|
||||
});
|
||||
} finally {
|
||||
if (response?.bodyUsed !== true) {
|
||||
await response?.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
await release?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultChoice"] | undefined {
|
||||
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
|
||||
}
|
||||
@@ -751,7 +233,18 @@ export function resolveOfficialExternalPluginInstall(
|
||||
}
|
||||
|
||||
export function listOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
|
||||
return dedupeOfficialExternalPluginCatalogEntries(bundledOfficialExternalPluginCatalogEntries());
|
||||
const entries = OFFICIAL_CATALOG_SOURCES.flatMap((source) =>
|
||||
parseOfficialExternalPluginCatalogEntries(source),
|
||||
);
|
||||
const resolved = new Map<string, OfficialExternalPluginCatalogEntry>();
|
||||
for (const entry of entries) {
|
||||
const pluginId = resolveOfficialExternalPluginId(entry);
|
||||
const key = pluginId ? `${entry.kind ?? "plugin"}:${pluginId}` : (entry.name ?? "");
|
||||
if (key && !resolved.has(key)) {
|
||||
resolved.set(key, entry);
|
||||
}
|
||||
}
|
||||
return [...resolved.values()];
|
||||
}
|
||||
|
||||
/** Resolves official external plugin owners for configured capability provider ids. */
|
||||
|
||||
12
src/state/openclaw-state-db.generated.d.ts
vendored
12
src/state/openclaw-state-db.generated.d.ts
vendored
@@ -698,17 +698,6 @@ export interface NodePairingPending {
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface OfficialExternalPluginCatalogSnapshots {
|
||||
body: string;
|
||||
checksum: string;
|
||||
etag: string | null;
|
||||
feed_url: string;
|
||||
last_modified: string | null;
|
||||
saved_at: string;
|
||||
status: number;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface PluginBindingApprovals {
|
||||
account_id: string;
|
||||
approved_at: number;
|
||||
@@ -1000,7 +989,6 @@ export interface DB {
|
||||
node_host_config: NodeHostConfig;
|
||||
node_pairing_paired: NodePairingPaired;
|
||||
node_pairing_pending: NodePairingPending;
|
||||
official_external_plugin_catalog_snapshots: OfficialExternalPluginCatalogSnapshots;
|
||||
plugin_binding_approvals: PluginBindingApprovals;
|
||||
plugin_blob_entries: PluginBlobEntries;
|
||||
plugin_state_entries: PluginStateEntries;
|
||||
|
||||
@@ -475,20 +475,6 @@ CREATE TABLE IF NOT EXISTS installed_plugin_index (
|
||||
CREATE INDEX IF NOT EXISTS idx_installed_plugin_index_generated
|
||||
ON installed_plugin_index(generated_at_ms DESC, index_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS official_external_plugin_catalog_snapshots (
|
||||
feed_url TEXT NOT NULL PRIMARY KEY,
|
||||
body TEXT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
checksum TEXT NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_official_external_plugin_catalog_snapshots_updated
|
||||
ON official_external_plugin_catalog_snapshots(updated_at_ms DESC, feed_url);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (
|
||||
sentinel_key TEXT NOT NULL PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
|
||||
@@ -470,20 +470,6 @@ CREATE TABLE IF NOT EXISTS installed_plugin_index (
|
||||
CREATE INDEX IF NOT EXISTS idx_installed_plugin_index_generated
|
||||
ON installed_plugin_index(generated_at_ms DESC, index_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS official_external_plugin_catalog_snapshots (
|
||||
feed_url TEXT NOT NULL PRIMARY KEY,
|
||||
body TEXT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
checksum TEXT NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_official_external_plugin_catalog_snapshots_updated
|
||||
ON official_external_plugin_catalog_snapshots(updated_at_ms DESC, feed_url);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (
|
||||
sentinel_key TEXT NOT NULL PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
|
||||
@@ -1061,7 +1061,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Fetch sanitized history for visible session. Use before replying, debugging, resuming; supports limit, offset pagination, and tool-message inclusion.",
|
||||
"description": "Fetch sanitized history for visible session. Use before replying, debugging, resuming; supports limits/tool messages.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"includeTools": {
|
||||
@@ -1071,10 +1071,6 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"sessionKey": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -1097,7 +1097,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Fetch sanitized history for visible session. Use before replying, debugging, resuming; supports limit, offset pagination, and tool-message inclusion.",
|
||||
"description": "Fetch sanitized history for visible session. Use before replying, debugging, resuming; supports limits/tool messages.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"includeTools": {
|
||||
@@ -1107,10 +1107,6 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"sessionKey": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -1061,7 +1061,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Fetch sanitized history for visible session. Use before replying, debugging, resuming; supports limit, offset pagination, and tool-message inclusion.",
|
||||
"description": "Fetch sanitized history for visible session. Use before replying, debugging, resuming; supports limits/tool messages.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"includeTools": {
|
||||
@@ -1071,10 +1071,6 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"sessionKey": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -227,8 +227,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 50947,
|
||||
"roughTokens": 12737
|
||||
"chars": 50816,
|
||||
"roughTokens": 12704
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2994,
|
||||
@@ -239,8 +239,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6927
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 78655,
|
||||
"roughTokens": 19664
|
||||
"chars": 78524,
|
||||
"roughTokens": 19631
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1629,
|
||||
|
||||
@@ -227,8 +227,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 50616,
|
||||
"roughTokens": 12654
|
||||
"chars": 50485,
|
||||
"roughTokens": 12622
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 1964,
|
||||
@@ -239,8 +239,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6544
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 76794,
|
||||
"roughTokens": 19199
|
||||
"chars": 76663,
|
||||
"roughTokens": 19166
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1129,
|
||||
|
||||
@@ -228,8 +228,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 51906,
|
||||
"roughTokens": 12977
|
||||
"chars": 51775,
|
||||
"roughTokens": 12944
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 1983,
|
||||
@@ -240,8 +240,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6780
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 79027,
|
||||
"roughTokens": 19757
|
||||
"chars": 78896,
|
||||
"roughTokens": 19724
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1367,
|
||||
|
||||
@@ -619,17 +619,6 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
|
||||
expect(parseFakeCrabboxOutput(result).args).toContain("blacksmith-testbox");
|
||||
});
|
||||
|
||||
it("tells operators how to read delegated Testbox proof status", () => {
|
||||
const result = runWrapper(
|
||||
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
|
||||
["run", "--provider", "blacksmith-testbox", "--", "echo ok"],
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("delegated Testbox proof uses the wrapper exitCode");
|
||||
expect(result.stderr).toContain("Actions run can show cancelled during external lease cleanup");
|
||||
});
|
||||
|
||||
it("rejects reused Blacksmith Testboxes that were not created by Crabbox", () => {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "openclaw-crabbox-home-"));
|
||||
tempDirs.push(home);
|
||||
|
||||
Reference in New Issue
Block a user