Compare commits

..

2 Commits

Author SHA1 Message Date
Ayaan Zaidi
03578e7a7b fix(telegram): track chunked outbound sends 2026-06-27 12:44:40 -07:00
Ayaan Zaidi
426c137e36 fix(telegram): preserve chunked bot replies 2026-06-27 12:00:06 -07:00
73 changed files with 870 additions and 4344 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,6 @@ describe("lazy protocol validators", () => {
sessionKey: "global",
agentId: "work",
limit: 50,
offset: 100,
}),
).toBe(true);
expect(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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