mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 18:01:53 +08:00
Compare commits
3 Commits
codex/mess
...
macos-app-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4e058769 | ||
|
|
dd4629c469 | ||
|
|
095d4e2305 |
14
.github/workflows/maturity-scorecard.yml
vendored
14
.github/workflows/maturity-scorecard.yml
vendored
@@ -134,7 +134,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: all
|
||||
qa_profile: release
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -238,8 +238,8 @@ jobs:
|
||||
}
|
||||
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (evidence.profile !== "all") {
|
||||
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
|
||||
if (evidence.profile !== "release") {
|
||||
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
|
||||
}
|
||||
|
||||
const artifactDir = path.dirname(evidencePath);
|
||||
@@ -256,8 +256,8 @@ jobs:
|
||||
const manifestPath = path.join(artifactDir, manifestNames[0]);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const manifestProfile = manifest.qaProfile ?? evidence.profile;
|
||||
if (manifestProfile !== "all") {
|
||||
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
|
||||
if (manifestProfile !== "release") {
|
||||
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
|
||||
}
|
||||
if (manifest.targetSha !== targetSha) {
|
||||
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
|
||||
@@ -428,14 +428,14 @@ jobs:
|
||||
cat > "$body_file" <<BODY
|
||||
## Summary
|
||||
|
||||
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and full taxonomy QA evidence
|
||||
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and release QA evidence
|
||||
- maturity source ref: ${REF_INPUT}
|
||||
- QA evidence run: ${evidence_run_id}
|
||||
|
||||
## Verification
|
||||
|
||||
- QA Lab maturity score validation passed
|
||||
- Maturity scorecard workflow rendered docs from all profile qa-evidence.json artifacts with strict inputs
|
||||
- Maturity scorecard workflow rendered docs from release profile qa-evidence.json artifacts with strict inputs
|
||||
BODY
|
||||
|
||||
pr_url="$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""')"
|
||||
|
||||
2
.github/workflows/qa-profile-evidence.yml
vendored
2
.github/workflows/qa-profile-evidence.yml
vendored
@@ -18,7 +18,7 @@ on:
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id to run (for example release or all)
|
||||
required: true
|
||||
default: all
|
||||
default: release
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
35b314075ff47453c5d57788861ca0c0e65d6a988b549ab2a2e1757b7590d140 plugin-sdk-api-baseline.json
|
||||
0dc8abcefccfe7d19280bde5fb2c0c69cf73b782d47e3759e2984baf904fe07c plugin-sdk-api-baseline.jsonl
|
||||
6620d5a6100d60f98cf13b8a13e3c46e9631400d1a1d7c0c6a22c490da810813 plugin-sdk-api-baseline.json
|
||||
961377a56fd0fb3307fb4be95dcb480610f14c717e1b82e4bf262dd5faaddcbc plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -579,7 +579,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Read receipts and typing">
|
||||
When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with:
|
||||
When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -19,23 +19,42 @@ Use this page to answer one question: which OpenClaw surfaces are credible choic
|
||||
## At a glance
|
||||
|
||||
<div className="maturity-summary-grid">
|
||||
<div className="maturity-summary-item maturity-score-experimental">
|
||||
<div className="maturity-summary-heading">
|
||||
<span className="maturity-summary-value">4%</span>
|
||||
<span>Coverage</span>
|
||||
</div>
|
||||
<div className="maturity-summary-bar" style={{ "--score": "4" }}><span /></div>
|
||||
<div className="maturity-summary-meta">
|
||||
<span className="maturity-level-pill maturity-level-experimental">Experimental</span>
|
||||
<span>QA profile evidence</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="maturity-summary-item maturity-score-alpha">
|
||||
<div className="maturity-summary-heading">
|
||||
<span className="maturity-summary-value">67%</span>
|
||||
<span>Maturity score</span>
|
||||
<span className="maturity-summary-value">63%</span>
|
||||
<span>Quality</span>
|
||||
</div>
|
||||
<div className="maturity-summary-bar" style={{ "--score": "67" }}><span /></div>
|
||||
<div className="maturity-summary-bar" style={{ "--score": "63" }}><span /></div>
|
||||
<div className="maturity-summary-meta">
|
||||
<span className="maturity-level-pill maturity-level-alpha">Alpha</span>
|
||||
<span>Quality + completeness</span>
|
||||
<span>Coverage Experimental - 4%</span>
|
||||
<span>Quality Alpha - 63%</span>
|
||||
<span>Completeness Beta - 70%</span>
|
||||
<span>Reliability and operator confidence</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="maturity-summary-item maturity-score-beta">
|
||||
<div className="maturity-summary-heading">
|
||||
<span className="maturity-summary-value">70%</span>
|
||||
<span>Completeness</span>
|
||||
</div>
|
||||
<div className="maturity-summary-bar" style={{ "--score": "70" }}><span /></div>
|
||||
<div className="maturity-summary-meta">
|
||||
<span className="maturity-level-pill maturity-level-beta">Beta</span>
|
||||
<span>Expected workflow coverage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists. It is not an input to the maturity score, but OpenClaw aims to keep end-to-end coverage above 90% for mature Stable-or-better features over time.
|
||||
Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists.
|
||||
|
||||
## Score bands
|
||||
|
||||
|
||||
@@ -57,6 +57,34 @@ Logging:
|
||||
The macOS app checks the gateway version against its own version. If they're
|
||||
incompatible, update the global CLI to match the app version.
|
||||
|
||||
## State directory on macOS
|
||||
|
||||
Keep OpenClaw state on a local, non-synced disk. Avoid iCloud Drive and other
|
||||
cloud-synced folders because sync latency and file locks can affect sessions,
|
||||
credentials, and Gateway state.
|
||||
|
||||
Set `OPENCLAW_STATE_DIR` to a local path only when you need an override.
|
||||
`openclaw doctor` warns about common cloud-synced state paths and recommends
|
||||
moving back to local storage. See
|
||||
[environment variables](/help/environment#path-related-env-vars) and
|
||||
[Doctor](/gateway/doctor).
|
||||
|
||||
## Debug app connectivity
|
||||
|
||||
Use the macOS debug CLI from a source checkout to exercise the same Gateway
|
||||
WebSocket handshake and discovery logic the app uses:
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run openclaw-mac connect --json
|
||||
swift run openclaw-mac discover --timeout 3000 --json
|
||||
```
|
||||
|
||||
`connect` accepts `--url`, `--token`, `--timeout`, and `--json`. `discover`
|
||||
accepts `--timeout`, `--json`, and `--include-local`. Compare discovery output
|
||||
with `openclaw gateway discover --json` when you need to separate CLI discovery
|
||||
from app-side connection issues.
|
||||
|
||||
## Smoke check
|
||||
|
||||
```bash
|
||||
|
||||
@@ -114,7 +114,18 @@ Example (in JS):
|
||||
window.location.href = "openclaw://agent?message=Review%20this%20design";
|
||||
```
|
||||
|
||||
The app prompts for confirmation unless a valid key is provided.
|
||||
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.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as
|
||||
`gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local
|
||||
tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and
|
||||
the local node-host service all use the same safe loopback transport.
|
||||
When discovery returns both raw Tailnet IPs and stable hostnames, the app
|
||||
prefers Tailscale MagicDNS or LAN names so remote connections survive address
|
||||
changes better.
|
||||
If the local tunnel port differs from the remote gateway port, set
|
||||
`gateway.remote.remotePort` to the port on the remote host.
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ title: "macOS IPC"
|
||||
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
- Common Mac node commands include `canvas.*`, `camera.snap`, `camera.clip`,
|
||||
`screen.snapshot`, `screen.record`, `system.run`, and `system.notify`.
|
||||
- The node reports a `permissions` map so agents can see whether screen,
|
||||
camera, microphone, speech, automation, or accessibility access is available.
|
||||
|
||||
### Node service + app IPC
|
||||
|
||||
|
||||
@@ -1,228 +1,87 @@
|
||||
---
|
||||
summary: "OpenClaw macOS companion app (menu bar + gateway broker)"
|
||||
summary: "Install and use the OpenClaw macOS menu bar app"
|
||||
read_when:
|
||||
- Implementing macOS app features
|
||||
- Changing gateway lifecycle or node bridging on macOS
|
||||
- Installing the macOS app
|
||||
- Deciding between local and remote Gateway mode on macOS
|
||||
- Looking for macOS app release downloads
|
||||
title: "macOS app"
|
||||
---
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
## What it does
|
||||
If you only need the CLI and Gateway, start with [Getting started](/start/getting-started).
|
||||
|
||||
- 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
|
||||
|
||||
## Local vs remote mode
|
||||
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** (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.
|
||||
- `OpenClaw-<version>.dmg` (preferred)
|
||||
- `OpenClaw-<version>.zip`
|
||||
|
||||
## Launchd control
|
||||
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).
|
||||
|
||||
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)
|
||||
## First run
|
||||
|
||||
1. Install and launch **OpenClaw.app**.
|
||||
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.
|
||||
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.
|
||||
|
||||
## State dir placement (macOS)
|
||||
For the CLI/Gateway setup path, use [Getting started](/start/getting-started).
|
||||
For permission recovery, use [macOS permissions](/platforms/mac/permissions).
|
||||
|
||||
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.
|
||||
## Choose a Gateway mode
|
||||
|
||||
Prefer a local non-synced state path such as:
|
||||
| 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) |
|
||||
|
||||
```bash
|
||||
OPENCLAW_STATE_DIR=~/.openclaw
|
||||
```
|
||||
Local mode requires an installed `openclaw` CLI. The app can install it, or you
|
||||
can follow [Gateway on macOS](/platforms/mac/bundled-gateway).
|
||||
|
||||
If `openclaw doctor` detects state under:
|
||||
## What the app owns
|
||||
|
||||
- `~/Library/Mobile Documents/com~apple~CloudDocs/...`
|
||||
- `~/Library/CloudStorage/...`
|
||||
- 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.
|
||||
|
||||
it will warn and recommend moving back to a local path.
|
||||
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.
|
||||
|
||||
## Build and dev workflow (native)
|
||||
## macOS detail pages
|
||||
|
||||
- `cd apps/macos && swift build`
|
||||
- `swift run OpenClaw` (or Xcode)
|
||||
- Package app: `scripts/package-mac-app.sh`
|
||||
| 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) |
|
||||
|
||||
## Debug gateway connectivity (macOS CLI)
|
||||
## Related
|
||||
|
||||
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)
|
||||
- [Platforms](/platforms)
|
||||
- [Getting started](/start/getting-started)
|
||||
- [Gateway](/gateway)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
@@ -269,7 +269,7 @@ html.dark .nav-tabs-underline {
|
||||
|
||||
.maturity-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 20px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
|
||||
@@ -1102,585 +1102,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps normalized explicit source routes terminal", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "sms",
|
||||
plugin: {
|
||||
id: "sms",
|
||||
messaging: {
|
||||
normalizeTarget: (raw: string) => {
|
||||
const digits = raw.replace(/\D/gu, "");
|
||||
return digits.length === 11 && digits.startsWith("1") ? `+${digits}` : raw.trim();
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "sms-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "sms",
|
||||
currentChannelId: "sms:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "sms",
|
||||
target: "+1 (206) 910-6512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "sms",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not let dry-run reply receipts terminate message-tool-only source replies", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
deliveryStatus: "dry_run",
|
||||
dryRun: true,
|
||||
replyToId: "provider-guid-862",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-862",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "862",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record dry-run reply actions as committed sends", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Dry run.", {
|
||||
deliveryStatus: "dry_run",
|
||||
dryRun: true,
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-862",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "862",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Dry run."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(false);
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "thread reply from top-level source",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let provider target aliases override source routes", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
id: "slack",
|
||||
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
|
||||
actions: {
|
||||
messageActionTargetAliases: {
|
||||
reply: {
|
||||
aliases: ["chatGuid"],
|
||||
deliveryTargetAliases: ["chatGuid"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "channel:c1",
|
||||
currentMessagingTarget: "channel:c1",
|
||||
currentMessageId: "provider-guid-854",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
chatGuid: "Channel:C2",
|
||||
messageId: "854",
|
||||
message: "cross-chat reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "slack",
|
||||
to: "channel:c2",
|
||||
text: "cross-chat reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isDeliveredMessagingToolResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -65,11 +63,9 @@ type CodexDynamicToolHookContext = {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
@@ -104,225 +100,6 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | number | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function explicitRouteTokensMismatchCurrent(
|
||||
args: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
currentToken: string | number | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrent = normalizeRouteToken(currentToken);
|
||||
if (!normalizedCurrent) {
|
||||
return false;
|
||||
}
|
||||
return keys.some((key) => {
|
||||
const normalized = normalizeRouteToken(readRouteToken(args, key));
|
||||
return normalized !== undefined && normalized !== normalizedCurrent;
|
||||
});
|
||||
}
|
||||
|
||||
function explicitThreadRouteTargetsNonSource(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
|
||||
const explicitThreadTokens = [
|
||||
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
|
||||
normalizeRouteToken(messagingTarget?.threadId),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
|
||||
if (explicitThreadTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedCurrentThread === undefined ||
|
||||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
explicitRouteTokensMismatchCurrent(
|
||||
args,
|
||||
EXPLICIT_MESSAGE_REPLY_KEYS,
|
||||
hookContext?.currentMessageId,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
messagingTarget?.to !== undefined &&
|
||||
!routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (messagingTarget?.to !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -337,7 +114,6 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -356,10 +132,6 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
|
||||
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -404,7 +176,6 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -562,9 +333,10 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -586,53 +358,12 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
isDeliveredMessagingToolResult({
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
}) &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
isToolResultYield(result),
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -1070,22 +801,9 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMessagingTool(params.toolName)) {
|
||||
return;
|
||||
}
|
||||
const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args);
|
||||
if (!isMessagingSendAction && !params.messagingTarget) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isMessagingSendAction &&
|
||||
!isDeliveredMessagingToolResult({
|
||||
toolName: params.toolName,
|
||||
args: params.args,
|
||||
result: params.result,
|
||||
hookResult: params.mediaTrustResult,
|
||||
isError: params.isError,
|
||||
})
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,19 +836,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -53,7 +53,6 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -412,8 +411,6 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
|
||||
@@ -841,11 +841,9 @@ export async function runCodexAppServerAttempt(
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Duckduckgo plugin module implements ddg client behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
@@ -114,10 +113,6 @@ function isBotChallenge(html: string): boolean {
|
||||
return /g-recaptcha|are you a human|id="challenge-form"|name="challenge"/i.test(html);
|
||||
}
|
||||
|
||||
async function readDuckDuckGoHtmlResponse(response: Response): Promise<string> {
|
||||
return await readProviderTextResponse(response, "DuckDuckGo search");
|
||||
}
|
||||
|
||||
function parseDuckDuckGoHtml(html: string): DuckDuckGoResult[] {
|
||||
const results: DuckDuckGoResult[] = [];
|
||||
const resultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi;
|
||||
@@ -207,7 +202,7 @@ export async function runDuckDuckGoSearch(params: {
|
||||
);
|
||||
}
|
||||
|
||||
const html = await readDuckDuckGoHtmlResponse(response);
|
||||
const html = await response.text();
|
||||
if (isBotChallenge(html)) {
|
||||
throw new Error("DuckDuckGo returned a bot-detection challenge.");
|
||||
}
|
||||
@@ -243,6 +238,5 @@ export const testing = {
|
||||
decodeHtmlEntities,
|
||||
isBotChallenge,
|
||||
parseDuckDuckGoHtml,
|
||||
readDuckDuckGoHtmlResponse,
|
||||
};
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Duckduckgo tests cover ddg search provider plugin behavior.
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import { createDuckDuckGoWebSearchProvider as createDuckDuckGoWebSearchContractProvider } from "../web-search-contract-api.js";
|
||||
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
|
||||
|
||||
@@ -105,24 +104,6 @@ describe("duckduckgo web search provider", () => {
|
||||
expect(runDuckDuckGoSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds successful DuckDuckGo HTML bodies without using response.text()", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(ddgClientTesting.readDuckDuckGoHtmlResponse(streamed.response)).rejects.toThrow(
|
||||
"DuckDuckGo search: text response exceeds 16777216 bytes",
|
||||
);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads region from plugin config and normalizes empty values away", () => {
|
||||
expect(
|
||||
resolveDdgRegion({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Firecrawl plugin module implements firecrawl client behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
markdownToText,
|
||||
@@ -42,7 +41,6 @@ const SCRAPE_CACHE = new Map<
|
||||
>();
|
||||
const DEFAULT_SEARCH_COUNT = 5;
|
||||
const DEFAULT_SCRAPE_MAX_CHARS = 50_000;
|
||||
const FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const ALLOWED_FIRECRAWL_HOSTS = new Set(["api.firecrawl.dev"]);
|
||||
const FIRECRAWL_SELF_HOSTED_PRIVATE_ERROR =
|
||||
"Firecrawl custom baseUrl must target a private or internal self-hosted endpoint.";
|
||||
@@ -67,9 +65,12 @@ type FirecrawlSearchItem = {
|
||||
async function readFirecrawlJsonResponse(
|
||||
response: Response,
|
||||
label: string,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await readProviderJsonResponse<Record<string, unknown>>(response, label, opts);
|
||||
try {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
export type FirecrawlSearchParams = {
|
||||
@@ -219,9 +220,11 @@ async function postFirecrawlJson<T>(
|
||||
const readJsonPayload = async (): Promise<Record<string, unknown> | null> => {
|
||||
const candidate = response as Response & { clone?: () => Response };
|
||||
const jsonResponse = typeof candidate.clone === "function" ? candidate.clone() : response;
|
||||
if (typeof jsonResponse.json !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body = await readResponseText(jsonResponse, { maxBytes: 64_000 });
|
||||
const payload = JSON.parse(body.text) as unknown;
|
||||
const payload = await jsonResponse.json();
|
||||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
@@ -576,10 +579,7 @@ export async function runFirecrawlScrape(
|
||||
},
|
||||
},
|
||||
async (response) => {
|
||||
const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed", {
|
||||
// Scrape can legitimately return page bodies before maxChars truncates parsed output.
|
||||
maxBytes: FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES,
|
||||
});
|
||||
const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed");
|
||||
if (payloadLocal.success === false) {
|
||||
const detail =
|
||||
typeof payloadLocal.error === "string"
|
||||
@@ -613,7 +613,6 @@ export const testing = {
|
||||
assertFirecrawlScrapeTargetAllowed,
|
||||
parseFirecrawlScrapePayload,
|
||||
postFirecrawlJson,
|
||||
readFirecrawlJsonResponse,
|
||||
resolveEndpoint,
|
||||
validateFirecrawlBaseUrl,
|
||||
resolveSearchItems,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import {
|
||||
DEFAULT_FIRECRAWL_BASE_URL,
|
||||
DEFAULT_FIRECRAWL_MAX_AGE_MS,
|
||||
@@ -967,27 +966,6 @@ describe("firecrawl tools", () => {
|
||||
).rejects.toThrow("Firecrawl Search API error: malformed JSON response");
|
||||
});
|
||||
|
||||
it("bounds successful Firecrawl JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(
|
||||
firecrawlClientTesting.readFirecrawlJsonResponse(
|
||||
streamed.response,
|
||||
"Firecrawl Search API error",
|
||||
),
|
||||
).rejects.toThrow("Firecrawl Search API error: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports malformed Firecrawl scrape JSON with a stable provider error", async () => {
|
||||
global.fetch = vi.fn(
|
||||
async () =>
|
||||
|
||||
@@ -256,183 +256,6 @@ describe("iMessage monitor last-route updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct progress options when imsg lacks native typing support", async () => {
|
||||
setCachedIMessagePrivateApiStatus("imsg", {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["watch.subscribe", "send", "read"],
|
||||
});
|
||||
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
||||
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||||
expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
expect(params.replyOptions?.onToolStart).toBeUndefined();
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
||||
});
|
||||
|
||||
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
||||
const client = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "watch.subscribe") {
|
||||
return { subscription: 1 };
|
||||
}
|
||||
if (method === "typing") {
|
||||
throw new Error("typing should not start without native typing support");
|
||||
}
|
||||
throw new Error(`unexpected imsg method ${method}`);
|
||||
}),
|
||||
waitForClose: vi.fn(async () => {
|
||||
onNotification?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 13,
|
||||
chat_id: 123,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "run a long script without native typing",
|
||||
is_group: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
||||
if (!params?.onNotification) {
|
||||
throw new Error("expected iMessage notification handler");
|
||||
}
|
||||
onNotification = params.onNotification;
|
||||
return client as never;
|
||||
});
|
||||
|
||||
await monitorIMessageProvider({
|
||||
config: {
|
||||
channels: {
|
||||
imessage: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sendReadReceipts: false,
|
||||
},
|
||||
},
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
session: { mainKey: "main" },
|
||||
} as never,
|
||||
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(client.request).not.toHaveBeenCalledWith(
|
||||
"typing",
|
||||
expect.objectContaining({ typing: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts direct typing before dispatching the inbound turn", async () => {
|
||||
setCachedIMessagePrivateApiStatus("imsg", {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["watch.subscribe", "send", "typing"],
|
||||
});
|
||||
|
||||
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
||||
const earlyTypingClient = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "typing") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected imsg typing-client method ${method}`);
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
const watchClient = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "watch.subscribe") {
|
||||
return { subscription: 1 };
|
||||
}
|
||||
if (method === "typing") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected imsg watch-client method ${method}`);
|
||||
}),
|
||||
waitForClose: vi.fn(async () => {
|
||||
onNotification?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 12,
|
||||
chat_id: 123,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "respond after a slow context build",
|
||||
is_group: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(earlyTypingClient.request).toHaveBeenCalledWith(
|
||||
"typing",
|
||||
expect.objectContaining({ typing: true, to: "+15550001111" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
||||
if (params?.onNotification) {
|
||||
onNotification = params.onNotification;
|
||||
return watchClient as never;
|
||||
}
|
||||
return earlyTypingClient as never;
|
||||
});
|
||||
dispatchInboundMessageMock.mockImplementationOnce(async () => {
|
||||
expect(earlyTypingClient.request).toHaveBeenCalledWith(
|
||||
"typing",
|
||||
expect.objectContaining({ typing: true, to: "+15550001111" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
||||
});
|
||||
|
||||
await monitorIMessageProvider({
|
||||
config: {
|
||||
channels: {
|
||||
imessage: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sendReadReceipts: false,
|
||||
},
|
||||
},
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
session: { mainKey: "main" },
|
||||
} as never,
|
||||
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
||||
});
|
||||
|
||||
expect(watchClient.request).not.toHaveBeenCalledWith(
|
||||
"typing",
|
||||
expect.objectContaining({ typing: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(earlyTypingClient.request).toHaveBeenCalledWith(
|
||||
"typing",
|
||||
expect.objectContaining({ typing: false, to: "+15550001111" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["never", "message", "thinking"] as const)(
|
||||
"does not start direct tool typing when typingMode is %s",
|
||||
async (typingMode) => {
|
||||
@@ -597,87 +420,6 @@ describe("iMessage monitor last-route updates", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wait for read receipts before dispatching the inbound turn", async () => {
|
||||
setCachedIMessagePrivateApiStatus("imsg", {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["watch.subscribe", "read"],
|
||||
});
|
||||
|
||||
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
||||
const readClient = {
|
||||
request: vi.fn((method: string) => {
|
||||
if (method === "read") {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected imsg read-client method ${method}`));
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
const watchClient = {
|
||||
request: vi.fn((method: string) => {
|
||||
if (method === "watch.subscribe") {
|
||||
return Promise.resolve({ subscription: 1 });
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected imsg watch-client method ${method}`));
|
||||
}),
|
||||
waitForClose: vi.fn(async () => {
|
||||
onNotification?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 11,
|
||||
chat_id: 123,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "respond without waiting for read receipt",
|
||||
is_group: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
||||
if (params?.onNotification) {
|
||||
onNotification = params.onNotification;
|
||||
return watchClient as never;
|
||||
}
|
||||
return readClient as never;
|
||||
});
|
||||
|
||||
await monitorIMessageProvider({
|
||||
config: {
|
||||
channels: {
|
||||
imessage: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
},
|
||||
},
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
session: { mainKey: "main" },
|
||||
} as never,
|
||||
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
||||
});
|
||||
|
||||
expect(readClient.request).toHaveBeenCalledWith(
|
||||
"read",
|
||||
expect.objectContaining({ to: "+15550001111" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(watchClient.request).not.toHaveBeenCalledWith(
|
||||
"read",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "flat true",
|
||||
|
||||
@@ -1087,7 +1087,7 @@ function buildIMessageEchoScope(params: {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
export function buildDirectIMessageReplyTarget(params: {
|
||||
function buildDirectIMessageReplyTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
sender: string;
|
||||
|
||||
@@ -94,7 +94,6 @@ import {
|
||||
releaseIMessageInboundReplay,
|
||||
} from "./inbound-dedupe.js";
|
||||
import {
|
||||
buildDirectIMessageReplyTarget,
|
||||
buildIMessageInboundContext,
|
||||
rememberIMessageSkippedFromMeForSelfChatDedupe,
|
||||
resolveIMessageReactionContext,
|
||||
@@ -1040,87 +1039,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: decision.route.agentId,
|
||||
});
|
||||
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
|
||||
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
|
||||
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
|
||||
if (privateApiStatus?.available === true) {
|
||||
// Surface a single warning per restart when the bridge is up but we
|
||||
// had to gate off typing/read because the imsg build pre-dates the
|
||||
// capability list. Otherwise the user sees no typing bubble / no
|
||||
// "Read" receipt with no visible reason.
|
||||
if (!supportsTyping || !supportsRead) {
|
||||
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
|
||||
}
|
||||
}
|
||||
const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg);
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }),
|
||||
sessionKey: decision.route.sessionKey,
|
||||
channel: "imessage",
|
||||
chatType: decision.isGroup ? "group" : "direct",
|
||||
});
|
||||
const shouldUseDirectToolTypingOptions =
|
||||
!decision.isGroup &&
|
||||
sendPolicy !== "deny" &&
|
||||
(configuredTypingMode === undefined || configuredTypingMode === "instant");
|
||||
const shouldStartDirectTyping = supportsTyping && shouldUseDirectToolTypingOptions;
|
||||
const earlyDirectTypingTarget = shouldStartDirectTyping
|
||||
? buildDirectIMessageReplyTarget({
|
||||
cfg,
|
||||
accountId: decision.route.accountId,
|
||||
sender: decision.sender,
|
||||
})
|
||||
: undefined;
|
||||
let stopEarlyDirectTyping: (() => void) | undefined;
|
||||
if (earlyDirectTypingTarget) {
|
||||
// Start channel-native feedback before the expensive history/context/model
|
||||
// path. Use a short-lived client so a slow typing RPC cannot block the
|
||||
// monitor client's watch stream. Stop is sequenced after start so fast
|
||||
// command replies cannot leave a late true after typing:false.
|
||||
const earlyDirectTypingStarted = sendIMessageTyping(earlyDirectTypingTarget, true, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
}).then(
|
||||
() => true,
|
||||
(err: unknown) => {
|
||||
logTypingFailure({
|
||||
log: (msg) => logVerbose(msg),
|
||||
channel: "imessage",
|
||||
action: "start",
|
||||
target: earlyDirectTypingTarget,
|
||||
error: err,
|
||||
});
|
||||
return false;
|
||||
},
|
||||
);
|
||||
let earlyTypingStopQueued = false;
|
||||
stopEarlyDirectTyping = () => {
|
||||
if (earlyTypingStopQueued) {
|
||||
return;
|
||||
}
|
||||
earlyTypingStopQueued = true;
|
||||
void earlyDirectTypingStarted
|
||||
.then(async (started) => {
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
await sendIMessageTyping(earlyDirectTypingTarget, false, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logTypingFailure({
|
||||
log: (msg) => logVerbose(msg),
|
||||
channel: "imessage",
|
||||
action: "stop",
|
||||
target: earlyDirectTypingTarget,
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
const stagedAttachments = remoteHost
|
||||
? []
|
||||
: await stageIMessageAttachments(validAttachments, {
|
||||
@@ -1189,20 +1107,31 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
);
|
||||
}
|
||||
|
||||
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
|
||||
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
|
||||
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
|
||||
if (privateApiStatus?.available === true) {
|
||||
// Surface a single warning per restart when the bridge is up but we
|
||||
// had to gate off typing/read because the imsg build pre-dates the
|
||||
// capability list. Otherwise the user sees no typing bubble / no
|
||||
// "Read" receipt with no visible reason.
|
||||
if (!supportsTyping || !supportsRead) {
|
||||
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
|
||||
}
|
||||
}
|
||||
const sendReadReceipts = imessageCfg.sendReadReceipts !== false;
|
||||
const typingTarget = ctxPayload.To;
|
||||
|
||||
if (supportsRead && sendReadReceipts && typingTarget) {
|
||||
// Read receipts are best-effort channel UI. Do not put them on the
|
||||
// critical path before model dispatch; slow private-API reads otherwise
|
||||
// make accepted iMessage turns feel stuck before the agent starts. Use
|
||||
// a short-lived client so a stuck read cannot block monitor-client typing.
|
||||
void markIMessageChatRead(typingTarget, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
}).catch((err: unknown) => {
|
||||
try {
|
||||
await markIMessageChatRead(typingTarget, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
client: getActiveClient(),
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`imessage: mark read failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
@@ -1305,27 +1234,35 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
},
|
||||
});
|
||||
let directTypingController: IMessageTypingController | undefined;
|
||||
const directToolTypingOptions = shouldUseDirectToolTypingOptions
|
||||
const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg);
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }),
|
||||
sessionKey: decision.route.sessionKey,
|
||||
channel: "imessage",
|
||||
chatType: decision.isGroup ? "group" : "direct",
|
||||
});
|
||||
const shouldStartToolTyping =
|
||||
!decision.isGroup &&
|
||||
sendPolicy !== "deny" &&
|
||||
(configuredTypingMode === undefined || configuredTypingMode === "instant");
|
||||
const directToolTypingOptions = shouldStartToolTyping
|
||||
? ({
|
||||
// iMessage's native typing bubble is channel-owned UI, not a
|
||||
// visible tool-progress message. The suppress flag is what lets
|
||||
// dispatch forward this callback even when verbose progress is off;
|
||||
// allowProgress covers message_tool_only source delivery. Keep this on
|
||||
// the direct instant/default path even when older imsg builds do not
|
||||
// report native typing support.
|
||||
// the direct instant/default path so configured typingMode values still
|
||||
// decide when typing can begin.
|
||||
suppressDefaultToolProgressMessages: true,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
||||
onTypingController: (typing: IMessageTypingController) => {
|
||||
directTypingController = typing;
|
||||
typingReplyOptions.onTypingController?.(typing);
|
||||
},
|
||||
...(supportsTyping
|
||||
? {
|
||||
onToolStart: async () => {
|
||||
await directTypingController?.startTypingLoop();
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
onToolStart: async () => {
|
||||
await directTypingController?.startTypingLoop();
|
||||
},
|
||||
} as const)
|
||||
: {};
|
||||
const configuredBlockStreaming = resolveChannelStreamingBlockEnabled(accountInfo.config);
|
||||
@@ -1388,13 +1325,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
historyMap: groupHistories,
|
||||
limit: historyLimit,
|
||||
},
|
||||
onPreDispatchFailure: () => {
|
||||
stopEarlyDirectTyping?.();
|
||||
void settleReplyDispatcher({
|
||||
onPreDispatchFailure: () =>
|
||||
settleReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => markDispatchIdle(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
runDispatch: async () => {
|
||||
try {
|
||||
return await dispatchInboundMessage({
|
||||
@@ -1413,7 +1348,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
});
|
||||
} finally {
|
||||
markDispatchIdle();
|
||||
stopEarlyDirectTyping?.();
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Ollama tests cover embedding provider plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn(
|
||||
@@ -413,40 +412,10 @@ describe("ollama embedding provider", () => {
|
||||
});
|
||||
|
||||
await expect(provider.embedQuery("hello")).rejects.toThrow(
|
||||
"Ollama embed response: malformed JSON response",
|
||||
"Ollama embed response returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds successful embed JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => streamed.response),
|
||||
);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await expect(provider.embedQuery("hello")).rejects.toThrow(
|
||||
"Ollama embed response: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-number embedding values instead of zeroing them", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
normalizeOptionalSecretInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
@@ -120,9 +117,14 @@ async function withRemoteHttpResponse<T>(params: {
|
||||
}
|
||||
|
||||
async function readOllamaEmbeddingJsonResponse(
|
||||
response: Response,
|
||||
response: Pick<Response, "json">,
|
||||
): Promise<{ embeddings?: unknown }> {
|
||||
const payload = await readProviderJsonResponse<unknown>(response, "Ollama embed response");
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (cause) {
|
||||
throw new Error("Ollama embed response returned malformed JSON", { cause });
|
||||
}
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
||||
throw new Error("Ollama embed response returned a non-object JSON payload");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Ollama tests cover web search provider plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import {
|
||||
testing,
|
||||
@@ -404,32 +403,7 @@ describe("ollama web search provider", () => {
|
||||
config: createOllamaConfig(),
|
||||
query: "openclaw",
|
||||
}),
|
||||
).rejects.toThrow("Ollama web search: malformed JSON response");
|
||||
});
|
||||
|
||||
it("bounds successful Ollama web search JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: streamed.response,
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runOllamaWebSearch({
|
||||
config: createOllamaConfig(),
|
||||
query: "openclaw",
|
||||
}),
|
||||
).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
).rejects.toThrow("Ollama web search returned malformed JSON");
|
||||
});
|
||||
|
||||
it("warns when Ollama is not reachable during setup without cancelling", async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
normalizeOptionalSecretInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
enablePluginInConfig,
|
||||
readPositiveIntegerParam,
|
||||
@@ -68,7 +67,11 @@ type OllamaWebSearchAttempt = {
|
||||
};
|
||||
|
||||
async function readOllamaWebSearchResponse(response: Response): Promise<OllamaWebSearchResponse> {
|
||||
return await readProviderJsonResponse<OllamaWebSearchResponse>(response, "Ollama web search");
|
||||
try {
|
||||
return (await response.json()) as OllamaWebSearchResponse;
|
||||
} catch (cause) {
|
||||
throw new Error("Ollama web search returned malformed JSON", { cause });
|
||||
}
|
||||
}
|
||||
|
||||
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
type EndpointCall = {
|
||||
url: string;
|
||||
@@ -312,27 +311,4 @@ describe("runParallelMcpSearch", () => {
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds successful MCP bodies without using response.text()", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
endpointMockState.responses.push(streamed.response);
|
||||
|
||||
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
"Parallel MCP: text response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
|
||||
|
||||
// Free hosted Search MCP. This keyless transport is used only after the user
|
||||
@@ -221,7 +218,7 @@ async function postMcp(params: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
text: response.ok
|
||||
? await readProviderTextResponse(response, "Parallel MCP")
|
||||
? await response.text()
|
||||
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
|
||||
sessionIdHeader: response.headers.get("mcp-session-id"),
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
type EndpointCall = {
|
||||
url: string;
|
||||
@@ -60,6 +59,40 @@ function cancelTrackedResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function streamedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
// Multi-chunk fixture: proves the bounded read stops pulling chunks before
|
||||
// the whole (here syntactically broken / unbounded) body is buffered, and
|
||||
// that the stream is cancelled on overflow.
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
getReadCount: () => reads,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
import { testing } from "../test-api.js";
|
||||
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
|
||||
@@ -588,12 +621,7 @@ describe("parallel web search provider", () => {
|
||||
// 200-chunk x 1 MiB body (~200 MiB) caps at 16 MiB: the bounded reader must
|
||||
// stop pulling chunks and cancel the stream well before draining it, then
|
||||
// surface a bounded error rather than buffering the whole payload.
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 200,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "a",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const streamed = streamedJsonResponse({ chunkCount: 200, chunkSize: 1024 * 1024 });
|
||||
endpointMockState.responses.push(streamed.response);
|
||||
const provider = createParallelWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
// Perplexity provider module implements model/runtime integration.
|
||||
import {
|
||||
readPositiveIntegerParam,
|
||||
@@ -143,7 +142,11 @@ function buildPerplexityRequestHeaders(apiKey: string, acceptJson = false): Reco
|
||||
}
|
||||
|
||||
async function readPerplexityJsonResponse<T>(response: Response, label: string): Promise<T> {
|
||||
return await readProviderJsonResponse<T>(response, label);
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Perplexity tests cover perplexity web search provider plugin behavior.
|
||||
import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPerplexityWebSearchProvider } from "./perplexity-web-search-provider.js";
|
||||
import { testing } from "./perplexity-web-search-provider.runtime.js";
|
||||
|
||||
@@ -172,22 +171,4 @@ describe("perplexity web search provider", () => {
|
||||
testing.readPerplexityJsonResponse(new Response("{ nope"), "Perplexity"),
|
||||
).rejects.toThrow("Perplexity: malformed JSON response");
|
||||
});
|
||||
|
||||
it("bounds successful Perplexity JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(
|
||||
testing.readPerplexityJsonResponse(streamed.response, "Perplexity Search"),
|
||||
).rejects.toThrow("Perplexity Search: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Qqbot tests cover api-client plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -89,35 +88,4 @@ describe("ApiClient", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds successful response bodies without using response.text()", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: streamed.response,
|
||||
release,
|
||||
});
|
||||
|
||||
const client = new ApiClient({ baseUrl: "https://qqbot.test" });
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await client.request("token-1", "GET", "/v2/users/@me");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(String(error)).toContain("QQBot API response: text response exceeds 16777216 bytes");
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
* - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
|
||||
*/
|
||||
|
||||
import {
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
@@ -165,7 +162,7 @@ export class ApiClient {
|
||||
const readBody = async (limitBytes?: number): Promise<string> => {
|
||||
try {
|
||||
return limitBytes === undefined
|
||||
? await readProviderTextResponse(res, "QQBot API response")
|
||||
? await res.text()
|
||||
: await readResponseTextLimited(res, limitBytes);
|
||||
} catch (err) {
|
||||
throw new ApiError(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Qqbot tests cover channel-api tool behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -110,33 +109,4 @@ describe("executeChannelApi", () => {
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds successful response bodies without using response.text()", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: streamed.response,
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await executeChannelApi(
|
||||
{ method: "GET", path: "/guilds/123/channels" },
|
||||
{ accessToken: "token-1" },
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
error: "QQ channel API response: text response exceeds 16777216 bytes",
|
||||
path: "/guilds/123/channels",
|
||||
});
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
* validation, fetch, and structured response formatting.
|
||||
*/
|
||||
|
||||
import {
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
import { debugLog, debugError } from "../utils/log.js";
|
||||
@@ -219,7 +216,7 @@ export async function executeChannelApi(
|
||||
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
|
||||
|
||||
const rawBody = res.ok
|
||||
? await readProviderTextResponse(res, "QQ channel API response")
|
||||
? await res.text()
|
||||
: await readResponseTextLimited(res, CHANNEL_API_ERROR_BODY_LIMIT_BYTES);
|
||||
if (!rawBody || rawBody.trim() === "") {
|
||||
if (res.ok) {
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import type { EventEmitter } from "node:events";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import {
|
||||
keepHttpServerTaskAlive,
|
||||
waitUntilAbort,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { keepHttpServerTaskAlive, waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import {
|
||||
createClaimableDedupe,
|
||||
type ClaimableDedupe,
|
||||
} from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import { RAFT_CHANNEL_ID, type ResolvedRaftAccount } from "./accounts.js";
|
||||
import { dispatchRaftWake } from "./inbound.js";
|
||||
|
||||
@@ -43,7 +54,11 @@ type RaftBridgeProcess = Pick<ChildProcess, "kill"> & Pick<EventEmitter, "once">
|
||||
|
||||
type RaftGatewayDeps = {
|
||||
createToken?: () => string;
|
||||
spawnBridge?: (params: { profile: string; endpoint: string; token: string }) => RaftBridgeProcess;
|
||||
spawnBridge?: (params: {
|
||||
profile: string;
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}) => RaftBridgeProcess;
|
||||
wakeDedupe?: ClaimableDedupe;
|
||||
};
|
||||
|
||||
@@ -65,8 +80,6 @@ function spawnRaftBridge(params: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): RaftBridgeProcess {
|
||||
// Raft owns the fixed bridge command. OpenClaw passes profile/loopback
|
||||
// endpoint/token as separate argv/env fields; wake payloads never reach argv.
|
||||
return spawn(
|
||||
"raft",
|
||||
[
|
||||
@@ -234,7 +247,7 @@ export async function startRaftGatewayAccount(
|
||||
onDiskError: (error) => {
|
||||
ctx.log?.warn?.(`Raft wake dedupe storage failed: ${String(error)}`);
|
||||
},
|
||||
});
|
||||
});
|
||||
const token = (deps.createToken ?? createToken)();
|
||||
const runtimeSession = randomUUID();
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
@@ -116,8 +116,6 @@ function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
|
||||
|
||||
export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
|
||||
const args = buildDaemonArgs(opts);
|
||||
// The executable is operator-selected or setup-discovered signal-cli.
|
||||
// Runtime message content only flows through the daemon HTTP API, not argv.
|
||||
const child = spawn(opts.cliPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Tavily tests cover tavily client plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
// Capture every call to postTrustedWebToolsJson so we can assert on extraHeaders.
|
||||
const postTrustedWebToolsJson = vi.fn();
|
||||
@@ -62,29 +61,6 @@ describe("tavily client X-Client-Source header", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds successful Tavily JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
postTrustedWebToolsJson.mockImplementationOnce(
|
||||
async (_params: unknown, parse: (r: Response) => Promise<unknown>) =>
|
||||
parse(streamed.response),
|
||||
);
|
||||
|
||||
await expect(runTavilySearch({ query: "test query" })).rejects.toThrow(
|
||||
"Tavily Search: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runTavilyExtract sends X-Client-Source: openclaw", async () => {
|
||||
await runTavilyExtract({ urls: ["https://example.com"] });
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Tavily plugin module implements tavily client behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
normalizeCacheKey,
|
||||
@@ -27,7 +26,6 @@ const EXTRACT_CACHE = new Map<
|
||||
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
|
||||
>();
|
||||
const DEFAULT_SEARCH_COUNT = 5;
|
||||
const TAVILY_EXTRACT_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export type TavilySearchParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
@@ -75,7 +73,6 @@ async function postTavilyJson(params: {
|
||||
apiKey: string;
|
||||
body: Record<string, unknown>;
|
||||
errorLabel: string;
|
||||
responseMaxBytes?: number;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return postTrustedWebToolsJson(
|
||||
{
|
||||
@@ -86,19 +83,19 @@ async function postTavilyJson(params: {
|
||||
errorLabel: params.errorLabel,
|
||||
extraHeaders: { "X-Client-Source": "openclaw" },
|
||||
},
|
||||
async (response) =>
|
||||
readTavilyJsonResponse(response, params.errorLabel, {
|
||||
maxBytes: params.responseMaxBytes,
|
||||
}),
|
||||
async (response) => readTavilyJsonResponse(response, params.errorLabel),
|
||||
);
|
||||
}
|
||||
|
||||
async function readTavilyJsonResponse(
|
||||
response: Response,
|
||||
label: string,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await readProviderJsonResponse<Record<string, unknown>>(response, label, opts);
|
||||
try {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
} catch (cause) {
|
||||
throw new Error(`${label}: malformed JSON response`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTavilySearch(
|
||||
@@ -258,8 +255,6 @@ export async function runTavilyExtract(
|
||||
apiKey,
|
||||
body,
|
||||
errorLabel: "Tavily Extract",
|
||||
// Extract can include raw page content and image lists, unlike search metadata.
|
||||
responseMaxBytes: TAVILY_EXTRACT_RESPONSE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const rawResults = Array.isArray(payload.results) ? payload.results : [];
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
// Test Support plugin module implements streaming response fixtures.
|
||||
export type StreamingResponseFixture = {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
};
|
||||
|
||||
export function createStreamingResponse(params: {
|
||||
status?: number;
|
||||
// Test Support plugin module implements streaming error response behavior.
|
||||
export function createStreamingErrorResponse(params: {
|
||||
status: number;
|
||||
chunkCount: number;
|
||||
chunkSize: number;
|
||||
byte?: number;
|
||||
text?: string;
|
||||
headers?: HeadersInit;
|
||||
}): StreamingResponseFixture {
|
||||
byte: number;
|
||||
}): { response: Response; getReadCount: () => number } {
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
@@ -23,28 +13,11 @@ export function createStreamingResponse(params: {
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
const chunk =
|
||||
params.text !== undefined
|
||||
? encoder.encode(params.text.repeat(params.chunkSize))
|
||||
: new Uint8Array(params.chunkSize).fill(params.byte ?? 120);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
controller.enqueue(new Uint8Array(params.chunkSize).fill(params.byte));
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, { status: params.status ?? 200, headers: params.headers }),
|
||||
response: new Response(stream, { status: params.status }),
|
||||
getReadCount: () => reads,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStreamingErrorResponse(params: {
|
||||
status: number;
|
||||
chunkCount: number;
|
||||
chunkSize: number;
|
||||
byte: number;
|
||||
}): StreamingResponseFixture {
|
||||
return createStreamingResponse(params);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ HOST_BUILD="${OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD:-1}"
|
||||
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
|
||||
run_log=""
|
||||
|
||||
# This lane installs the package and then exercises a managed npm install of Codex.
|
||||
# Keep the package install budget above the shared default so slow npm hosts reach
|
||||
# the Codex assertions instead of failing as a silent package-install timeout.
|
||||
export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}"
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${PACKAGE_TGZ:-}" ]; then
|
||||
docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ"
|
||||
|
||||
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10386),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10381),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5210),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3247,
|
||||
|
||||
@@ -299,7 +299,6 @@ function scoreSummary(
|
||||
title: string,
|
||||
value: QaMaturityScoreObject | undefined,
|
||||
description: string,
|
||||
details: readonly string[] = [],
|
||||
): string[] {
|
||||
const score = scorePercent(value);
|
||||
const displayScore = score === undefined ? "-" : `${score}%`;
|
||||
@@ -314,7 +313,6 @@ function scoreSummary(
|
||||
' <div className="maturity-summary-meta">',
|
||||
` ${maturityLabelPill(value?.label ?? "Unscored")}`,
|
||||
` <span>${markdownEscape(description)}</span>`,
|
||||
...details.map((detail) => ` <span>${markdownEscape(detail)}</span>`),
|
||||
" </div>",
|
||||
"</div>",
|
||||
];
|
||||
@@ -966,7 +964,6 @@ function renderMaturityScorecard({
|
||||
const surfaceAverage = coverage.rollups.surface_average;
|
||||
const qualityAverage = scores.rollups.surface_average.quality;
|
||||
const completenessAverage = scores.rollups.surface_average.completeness;
|
||||
const maturityAverage = averageScores([qualityAverage, completenessAverage]);
|
||||
const lines = [
|
||||
...frontmatter(
|
||||
"Maturity scorecard",
|
||||
@@ -988,17 +985,18 @@ function renderMaturityScorecard({
|
||||
"## At a glance",
|
||||
"",
|
||||
'<div className="maturity-summary-grid">',
|
||||
...indentMarkdown(scoreSummary("Coverage", surfaceAverage, "QA profile evidence"), 2),
|
||||
...indentMarkdown(
|
||||
scoreSummary("Maturity score", maturityAverage, "Quality + completeness", [
|
||||
`Coverage ${scoreLabel(surfaceAverage)}`,
|
||||
`Quality ${scoreLabel(qualityAverage)}`,
|
||||
`Completeness ${scoreLabel(completenessAverage)}`,
|
||||
]),
|
||||
scoreSummary("Quality", qualityAverage, "Reliability and operator confidence"),
|
||||
2,
|
||||
),
|
||||
...indentMarkdown(
|
||||
scoreSummary("Completeness", completenessAverage, "Expected workflow coverage"),
|
||||
2,
|
||||
),
|
||||
"</div>",
|
||||
"",
|
||||
'Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists. It is not an input to the maturity score, but OpenClaw aims to keep end-to-end coverage above 90% for mature Stable-or-better features over time.',
|
||||
'Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists.',
|
||||
"",
|
||||
...renderScoreBands(),
|
||||
];
|
||||
@@ -1214,7 +1212,9 @@ function main(): void {
|
||||
}
|
||||
|
||||
const evidenceSummaries = readEvidenceSummaries(args.evidenceDir);
|
||||
rejectBlockingEvidence(evidenceSummaries);
|
||||
if (args.strictInputs) {
|
||||
rejectBlockingEvidence(evidenceSummaries);
|
||||
}
|
||||
const coverage = deriveCoverageScores(taxonomy, evidenceSummaries);
|
||||
const { scores, warnings: scoreWarnings } = readValidatedQaMaturityScoreSources({
|
||||
coverageScores: coverage,
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Tests for ingress model.usage diagnostic emission in agentCommandFromIngress.
|
||||
*
|
||||
* Covers:
|
||||
* - ingressDiagnosticChannel channel label resolution
|
||||
* - emitIngressModelUsageDiagnostic with diagnostics enabled + valid usage
|
||||
* - emitIngressModelUsageDiagnostic with diagnostics disabled
|
||||
* - emitIngressModelUsageDiagnostic with null/missing usage
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
emitTrustedDiagnosticEvent: vi.fn(),
|
||||
isDiagnosticsEnabled: vi.fn(),
|
||||
getRuntimeConfig: vi.fn(),
|
||||
hasNonzeroUsage: vi.fn(),
|
||||
resolveModelCostConfig: vi.fn(),
|
||||
estimateUsageCost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/diagnostic-events.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../infra/diagnostic-events.js")>(
|
||||
"../infra/diagnostic-events.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
emitTrustedDiagnosticEvent: mocks.emitTrustedDiagnosticEvent,
|
||||
isDiagnosticsEnabled: mocks.isDiagnosticsEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/usage-format.js", () => ({
|
||||
resolveModelCostConfig: (...args: Array<unknown>) => mocks.resolveModelCostConfig(...args),
|
||||
estimateUsageCost: (...args: Array<unknown>) => mocks.estimateUsageCost(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./usage.js", () => ({
|
||||
hasNonzeroUsage: (usage: unknown) => mocks.hasNonzeroUsage(usage),
|
||||
}));
|
||||
|
||||
vi.mock("../config/io.js", () => ({
|
||||
getRuntimeConfig: () => mocks.getRuntimeConfig(),
|
||||
}));
|
||||
|
||||
let testing: typeof import("./agent-command.js").testing;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("./agent-command.js");
|
||||
testing = mod.testing;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isDiagnosticsEnabled.mockReturnValue(true);
|
||||
mocks.hasNonzeroUsage.mockReturnValue(true);
|
||||
mocks.getRuntimeConfig.mockReturnValue({});
|
||||
mocks.resolveModelCostConfig.mockReturnValue({});
|
||||
mocks.estimateUsageCost.mockReturnValue(0.001);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeResult(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
payloads: [{ text: "hello", mediaUrl: "" }],
|
||||
meta: {
|
||||
durationMs: 1234,
|
||||
aborted: false,
|
||||
stopReason: "end_turn",
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
sessionId: "sess-abc",
|
||||
usage: {
|
||||
input: 500,
|
||||
output: 200,
|
||||
cacheRead: 50,
|
||||
cacheWrite: 25,
|
||||
total: 775,
|
||||
},
|
||||
contextTokens: 128000,
|
||||
promptTokens: 1200,
|
||||
lastCallUsage: { input: 500, output: 200 },
|
||||
...(overrides?.agentMeta as Record<string, unknown> | undefined),
|
||||
},
|
||||
...(overrides?.meta as Record<string, unknown> | undefined),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpts(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
message: "hello",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
allowModelOverride: false,
|
||||
messageChannel: "api",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ingressDiagnosticChannel
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("ingressDiagnosticChannel", () => {
|
||||
it("returns runContext.messageChannel when set", () => {
|
||||
const channel = testing.ingressDiagnosticChannel({
|
||||
message: "hi",
|
||||
allowModelOverride: false,
|
||||
runContext: { messageChannel: "discord" },
|
||||
messageChannel: "api",
|
||||
channel: "http",
|
||||
});
|
||||
expect(channel).toBe("discord");
|
||||
});
|
||||
|
||||
it("falls back to opts.messageChannel", () => {
|
||||
const channel = testing.ingressDiagnosticChannel({
|
||||
message: "hi",
|
||||
allowModelOverride: false,
|
||||
messageChannel: "api",
|
||||
channel: "http",
|
||||
});
|
||||
expect(channel).toBe("api");
|
||||
});
|
||||
|
||||
it("falls back to opts.channel", () => {
|
||||
const channel = testing.ingressDiagnosticChannel({
|
||||
message: "hi",
|
||||
allowModelOverride: false,
|
||||
channel: "webchat",
|
||||
});
|
||||
expect(channel).toBe("webchat");
|
||||
});
|
||||
|
||||
it('defaults to "http" when no channel info is present', () => {
|
||||
const channel = testing.ingressDiagnosticChannel({
|
||||
message: "hi",
|
||||
allowModelOverride: false,
|
||||
});
|
||||
expect(channel).toBe("http");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// emitIngressModelUsageDiagnostic
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("emitIngressModelUsageDiagnostic", () => {
|
||||
it("emits model.usage when diagnostics are enabled and result has usage", () => {
|
||||
const result = makeResult();
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1);
|
||||
const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0];
|
||||
expect(event).toMatchObject({
|
||||
type: "model.usage",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "sess-abc",
|
||||
channel: "api",
|
||||
agentId: "main",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
usage: {
|
||||
input: 500,
|
||||
output: 200,
|
||||
cacheRead: 50,
|
||||
cacheWrite: 25,
|
||||
promptTokens: 575,
|
||||
total: 775,
|
||||
},
|
||||
durationMs: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit when diagnostics are disabled", () => {
|
||||
mocks.isDiagnosticsEnabled.mockReturnValue(false);
|
||||
const result = makeResult();
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit when agentMeta is missing", () => {
|
||||
const result = makeResult({
|
||||
meta: { durationMs: 100, aborted: false, stopReason: "end_turn" },
|
||||
});
|
||||
// result.meta.agentMeta is undefined
|
||||
(result as Record<string, unknown>).meta = { durationMs: 100 };
|
||||
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit when usage is zero", () => {
|
||||
mocks.hasNonzeroUsage.mockReturnValue(false);
|
||||
const result = makeResult();
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves channel from runContext when available", () => {
|
||||
const result = makeResult();
|
||||
const opts = makeOpts({
|
||||
runContext: { messageChannel: "discord" },
|
||||
messageChannel: "api",
|
||||
});
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1);
|
||||
const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0];
|
||||
expect(event.channel).toBe("discord");
|
||||
});
|
||||
|
||||
it('defaults channel to "http" when no channel info is present', () => {
|
||||
const result = makeResult();
|
||||
const opts = { message: "hi", allowModelOverride: false };
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1);
|
||||
const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0];
|
||||
expect(event.channel).toBe("http");
|
||||
});
|
||||
|
||||
it("computes cost when billable usage buckets are present", () => {
|
||||
const result = makeResult();
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.resolveModelCostConfig).toHaveBeenCalledWith({
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
config: expect.any(Object) as unknown,
|
||||
});
|
||||
expect(mocks.estimateUsageCost).toHaveBeenCalled();
|
||||
expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1);
|
||||
const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0];
|
||||
expect(event.costUsd).toBe(0.001);
|
||||
});
|
||||
|
||||
it("handles missing optional usage fields gracefully", () => {
|
||||
const result = makeResult({
|
||||
agentMeta: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
sessionId: "sess-min",
|
||||
usage: { input: 100, output: 50 },
|
||||
},
|
||||
});
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1);
|
||||
const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0];
|
||||
expect(event.usage).toMatchObject({
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
promptTokens: 100,
|
||||
total: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits context.used when promptTokens is undefined", () => {
|
||||
const result = makeResult({
|
||||
agentMeta: {
|
||||
promptTokens: undefined,
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
sessionId: "sess-no-prompt",
|
||||
usage: { input: 10, output: 5 },
|
||||
contextTokens: 128000,
|
||||
},
|
||||
});
|
||||
const opts = makeOpts();
|
||||
|
||||
testing.emitIngressModelUsageDiagnostic(result, opts);
|
||||
|
||||
expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1);
|
||||
const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0];
|
||||
expect(event.context).toEqual({ limit: 128000 });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
registerAgentRunContext,
|
||||
withAgentRunLifecycleGeneration,
|
||||
} from "../infra/agent-events.js";
|
||||
import { isDiagnosticsEnabled, emitTrustedDiagnosticEvent } from "../infra/diagnostic-events.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import {
|
||||
resolveAgentDeliveryPlan,
|
||||
@@ -68,7 +67,6 @@ import {
|
||||
isDeliverableMessageChannel,
|
||||
resolveMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
|
||||
import { resolveAgentRuntimeConfig } from "./agent-runtime-config.js";
|
||||
import {
|
||||
clearAutoFallbackPrimaryProbeSelection,
|
||||
@@ -139,7 +137,6 @@ import {
|
||||
} from "./run-termination.js";
|
||||
import { normalizeSpawnedRunMetadata } from "./spawned-context.js";
|
||||
import { resolveAgentTimeoutMs } from "./timeout.js";
|
||||
import { hasNonzeroUsage } from "./usage.js";
|
||||
import { ensureAgentWorkspace } from "./workspace.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/agent-command");
|
||||
@@ -2445,83 +2442,6 @@ export async function agentCommand(
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the channel label for model.usage diagnostics from ingress run options. */
|
||||
function ingressDiagnosticChannel(opts: AgentCommandIngressOpts): string {
|
||||
return opts.runContext?.messageChannel ?? opts.messageChannel ?? opts.channel ?? "http";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a model.usage diagnostic event after an ingress agent run completes.
|
||||
*
|
||||
* Unlike channel/cron paths which emit model.usage in runReplyAgent /
|
||||
* finalizeCronRun, the ingress path has no such existing emission — without
|
||||
* this every diagnostics consumer (Langfuse bridge, @openclaw/diagnostics-otel,
|
||||
* diagnostics-prometheus) sees usage/cost only for webchat/cli/cron turns
|
||||
* and is blind to HTTP API traffic (POST /v1/responses, POST /v1/chat/completions,
|
||||
* and node-event dispatch).
|
||||
*/
|
||||
function emitIngressModelUsageDiagnostic(
|
||||
result: NonNullable<Awaited<ReturnType<typeof agentCommandInternal>>>,
|
||||
opts: AgentCommandIngressOpts,
|
||||
): void {
|
||||
const cfg = getRuntimeConfig();
|
||||
if (!isDiagnosticsEnabled(cfg)) {
|
||||
return;
|
||||
}
|
||||
const agentMeta = result.meta?.agentMeta;
|
||||
const usage = agentMeta?.usage;
|
||||
if (!agentMeta || !hasNonzeroUsage(usage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerUsed = agentMeta.provider ?? "";
|
||||
const modelUsed = agentMeta.model ?? "";
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const cacheRead = usage.cacheRead ?? 0;
|
||||
const cacheWrite = usage.cacheWrite ?? 0;
|
||||
const usagePromptTokens = input + cacheRead + cacheWrite;
|
||||
const totalTokens = usage.total ?? usagePromptTokens + output;
|
||||
const hasBillableUsageBuckets =
|
||||
usage.input !== undefined ||
|
||||
usage.output !== undefined ||
|
||||
usage.cacheRead !== undefined ||
|
||||
usage.cacheWrite !== undefined;
|
||||
const costConfig = resolveModelCostConfig({
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
config: cfg,
|
||||
});
|
||||
const costUsd = hasBillableUsageBuckets
|
||||
? estimateUsageCost({ usage, cost: costConfig })
|
||||
: undefined;
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
sessionKey: opts.sessionKey,
|
||||
sessionId: agentMeta.sessionId,
|
||||
channel: ingressDiagnosticChannel(opts),
|
||||
agentId: opts.agentId,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
usage: {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
promptTokens: usagePromptTokens,
|
||||
total: totalTokens,
|
||||
},
|
||||
lastCallUsage: agentMeta.lastCallUsage,
|
||||
context: {
|
||||
limit: agentMeta.contextTokens,
|
||||
...(agentMeta.promptTokens !== undefined ? { used: agentMeta.promptTokens } : {}),
|
||||
},
|
||||
costUsd,
|
||||
durationMs: result.meta?.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs an agent turn from an inbound channel/gateway ingress context. */
|
||||
export async function agentCommandFromIngress(
|
||||
opts: AgentCommandIngressOpts,
|
||||
@@ -2533,8 +2453,8 @@ export async function agentCommandFromIngress(
|
||||
}
|
||||
const lifecycleGeneration =
|
||||
opts.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(opts.runId ?? "");
|
||||
return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => {
|
||||
const result = await agentCommandInternal(
|
||||
return await withAgentRunLifecycleGeneration(lifecycleGeneration, () =>
|
||||
agentCommandInternal(
|
||||
{
|
||||
...opts,
|
||||
lifecycleGeneration,
|
||||
@@ -2542,22 +2462,14 @@ export async function agentCommandFromIngress(
|
||||
},
|
||||
runtime,
|
||||
deps,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
emitIngressModelUsageDiagnostic(result, opts);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
resolveAgentRuntimeConfig,
|
||||
prepareAgentCommandExecution,
|
||||
resolveExplicitAgentCommandSessionKey,
|
||||
ingressDiagnosticChannel,
|
||||
emitIngressModelUsageDiagnostic,
|
||||
};
|
||||
|
||||
/** @deprecated Use `testing`. */
|
||||
|
||||
@@ -75,10 +75,6 @@ export {
|
||||
const log = createSubsystemLogger("errors");
|
||||
const sandboxToolPolicyAuditMessages = new WeakSet<AssistantMessage>();
|
||||
export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed.";
|
||||
export const AUTH_INVALID_TOKEN_USER_TEXT =
|
||||
"Authentication failed (provider returned HTTP 401). " +
|
||||
"Your provider token may have expired — try the request again in a moment. " +
|
||||
"If the failure persists, re-authenticate this provider.";
|
||||
const PROVIDER_SCHEMA_REJECTION_USER_TEXT =
|
||||
"LLM request failed: provider rejected the request schema or tool payload.";
|
||||
const MODEL_NOT_FOUND_USER_TEXT =
|
||||
@@ -1423,7 +1419,11 @@ export function formatAssistantErrorText(
|
||||
}
|
||||
|
||||
if (providerRuntimeFailureKind === "auth_invalid_token") {
|
||||
return AUTH_INVALID_TOKEN_USER_TEXT;
|
||||
return (
|
||||
"Authentication failed (provider returned HTTP 401). " +
|
||||
"Your provider token may have expired — try the request again in a moment. " +
|
||||
"If the failure persists, re-authenticate this provider."
|
||||
);
|
||||
}
|
||||
|
||||
if (providerRuntimeFailureKind === "upstream_html") {
|
||||
|
||||
@@ -73,7 +73,9 @@ describe("isDeliveredMessagingToolResult", () => {
|
||||
result: [{ type: "text", text: JSON.stringify({ result: { messageId: "msg-1" } }) }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isDeliveredMessagingToolResult({ result: { content: [{ text: "sent" }] } })).toBe(true);
|
||||
expect(isDeliveredMessagingToolResult({ result: { content: [{ text: "sent" }] } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isDeliveredMessagingToolResult({ result: { status: "sent" } })).toBe(true);
|
||||
});
|
||||
|
||||
@@ -332,47 +334,4 @@ describe("isDeliveredMessageToolOnlySourceReplyResult", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts confirmed explicit routes when the caller verified the source route", () => {
|
||||
expect(
|
||||
isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
toolName: "message",
|
||||
args: {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
message: "reply",
|
||||
},
|
||||
result: { ok: true, messageId: "imessage-853" },
|
||||
allowExplicitSourceRoute: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
toolName: "message",
|
||||
args: {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
message: "reply",
|
||||
},
|
||||
result: { ok: true, messageId: "imessage-853" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
toolName: "message",
|
||||
args: {
|
||||
action: "react",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
},
|
||||
result: { ok: true },
|
||||
allowExplicitSourceRoute: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,13 +50,6 @@ function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
|
||||
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
|
||||
}
|
||||
|
||||
function isMessageToolSourceReplyActionName(action: unknown): boolean {
|
||||
if (isMessageToolSendActionName(action)) {
|
||||
return true;
|
||||
}
|
||||
return typeof action === "string" && action.trim().toLowerCase() === "reply";
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
|
||||
}
|
||||
@@ -554,7 +547,6 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
|
||||
result?: unknown;
|
||||
hookResult?: unknown;
|
||||
isError?: boolean;
|
||||
allowExplicitSourceRoute?: boolean;
|
||||
}): boolean {
|
||||
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
|
||||
return false;
|
||||
@@ -563,12 +555,7 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
|
||||
return false;
|
||||
}
|
||||
const args = asRecord(params.args);
|
||||
const sourceRouteReplyAction =
|
||||
params.allowExplicitSourceRoute === true && isMessageToolSourceReplyActionName(args.action);
|
||||
if (!isMessageToolSendActionName(args.action) && !sourceRouteReplyAction) {
|
||||
return false;
|
||||
}
|
||||
if (hasExplicitMessageRoute(args) && params.allowExplicitSourceRoute !== true) {
|
||||
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
|
||||
return false;
|
||||
}
|
||||
return isDeliveredMessagingToolResult(params);
|
||||
|
||||
@@ -3992,103 +3992,6 @@ describe("embedded attempt session lock lifecycle", () => {
|
||||
expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("releaseHeldLockWithFence sets deferred flag when bailed out during active scope; re-attempted after scope deactivation (#95915)", async () => {
|
||||
const events: string[] = [];
|
||||
const releasePrep = vi.fn(async () => events.push("prep-release"));
|
||||
const releaseRetained = vi.fn(async () => events.push("retained-release"));
|
||||
const acquireSessionWriteLockLocal = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ release: releasePrep })
|
||||
.mockResolvedValueOnce({ release: releaseRetained });
|
||||
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock: acquireSessionWriteLockLocal,
|
||||
lockOptions,
|
||||
});
|
||||
|
||||
await controller.releaseForPrompt();
|
||||
await controller.reacquireAfterPrompt();
|
||||
|
||||
await controller.withSessionWriteLock(async () => {
|
||||
events.push("write-start");
|
||||
await controller.releaseHeldLockForAbort();
|
||||
events.push("write-end");
|
||||
});
|
||||
|
||||
expect(events).toEqual(["prep-release", "write-start", "write-end", "retained-release"]);
|
||||
expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("controls the held lock lifecycle across deferred abort release, reacquisition, and prompt release", async () => {
|
||||
const events: string[] = [];
|
||||
const acquireSessionWriteLockLocal = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) })
|
||||
.mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) })
|
||||
.mockResolvedValueOnce({ release: vi.fn(async () => events.push("reacquire-release")) });
|
||||
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock: acquireSessionWriteLockLocal,
|
||||
lockOptions,
|
||||
});
|
||||
|
||||
await controller.releaseForPrompt();
|
||||
await controller.reacquireAfterPrompt();
|
||||
|
||||
await controller.withSessionWriteLock(async () => {
|
||||
events.push("write");
|
||||
await controller.releaseHeldLockForAbort();
|
||||
});
|
||||
|
||||
expect(events).toEqual(["init-release", "write", "held-release"]);
|
||||
|
||||
await controller.reacquireAfterPrompt();
|
||||
await controller.releaseForPrompt();
|
||||
|
||||
expect(events).toEqual(["init-release", "write", "held-release", "reacquire-release"]);
|
||||
expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("takeHeldLockAfterRetainedIdle does not self-deadlock when called from inside active write scope (#95915)", async () => {
|
||||
const events: string[] = [];
|
||||
const acquireSessionWriteLockLocal = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) })
|
||||
.mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) })
|
||||
.mockRejectedValueOnce(
|
||||
new SessionWriteLockTimeoutError({
|
||||
timeoutMs: lockOptions.timeoutMs,
|
||||
owner: "pid=test",
|
||||
lockPath: `${lockOptions.sessionFile}.lock`,
|
||||
}),
|
||||
);
|
||||
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock: acquireSessionWriteLockLocal,
|
||||
lockOptions,
|
||||
});
|
||||
|
||||
await controller.releaseForPrompt();
|
||||
await controller.reacquireAfterPrompt();
|
||||
|
||||
const takeoverError = await controller
|
||||
.withSessionWriteLock(async () => {
|
||||
events.push("write-start");
|
||||
const cleanupLock = await controller.acquireForCleanup();
|
||||
await cleanupLock.release();
|
||||
events.push("cleanup-inside-done");
|
||||
})
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(takeoverError).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError);
|
||||
|
||||
const cleanupLock = await controller.acquireForCleanup();
|
||||
await cleanupLock.release();
|
||||
|
||||
expect(events).toEqual(["init-release", "write-start", "cleanup-inside-done", "held-release"]);
|
||||
expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("returns a no-op cleanup lock after prompt lock reacquisition times out", async () => {
|
||||
const releases: string[] = [];
|
||||
const acquireSessionWriteLockResult = vi
|
||||
|
||||
@@ -1171,9 +1171,6 @@ export async function createEmbeddedAttemptSessionLockController(params: {
|
||||
let fenceGeneration = 0;
|
||||
let fenceActive = false;
|
||||
let takeoverDetected = false;
|
||||
// Set when an active retained write prevents immediate held-lock release.
|
||||
// The scope completion path retries release after the retained use unwinds.
|
||||
let releaseHeldLockDeferred = false;
|
||||
let retainedLockUseCount = 0;
|
||||
const retainedLockIdleWaiters = new Set<() => void>();
|
||||
let heldLockDraining = false;
|
||||
@@ -1606,7 +1603,6 @@ export async function createEmbeddedAttemptSessionLockController(params: {
|
||||
const drainOwner = await beginHeldLockDrain();
|
||||
try {
|
||||
if (!(await waitForRetainedLockIdle())) {
|
||||
releaseHeldLockDeferred = true;
|
||||
return;
|
||||
}
|
||||
if (!heldLock) {
|
||||
@@ -1643,8 +1639,6 @@ export async function createEmbeddedAttemptSessionLockController(params: {
|
||||
const drainOwner = await beginHeldLockDrain();
|
||||
try {
|
||||
if (!(await waitForRetainedLockIdle())) {
|
||||
// Do not wait for retained idle from inside the active scope; that
|
||||
// scope must unwind before the retained-use waiter can resolve.
|
||||
return undefined;
|
||||
}
|
||||
if (!heldLock) {
|
||||
@@ -1666,7 +1660,6 @@ export async function createEmbeddedAttemptSessionLockController(params: {
|
||||
const drainOwner = await beginHeldLockDrain();
|
||||
try {
|
||||
if (!(await waitForRetainedLockIdle())) {
|
||||
// Same active-scope self-deadlock guard as takeHeldLockAfterRetainedIdle.
|
||||
return;
|
||||
}
|
||||
if (!heldLock) {
|
||||
@@ -1728,12 +1721,6 @@ export async function createEmbeddedAttemptSessionLockController(params: {
|
||||
}
|
||||
}
|
||||
await releaseHeldLockAfterTakeover();
|
||||
// Retained use has been released and the active scope is no longer live,
|
||||
// so a prior active-scope release bailout can drain the held file lock now.
|
||||
if (releaseHeldLockDeferred) {
|
||||
releaseHeldLockDeferred = false;
|
||||
await releaseHeldLockWithFence();
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
throw outcome.error;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ProviderHttpError,
|
||||
readProviderBinaryResponse,
|
||||
readProviderJsonResponse,
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "./provider-http-errors.js";
|
||||
|
||||
@@ -65,31 +64,6 @@ function createStreamingJsonResponse(params: { chunkCount: number; chunkSize: nu
|
||||
};
|
||||
}
|
||||
|
||||
function createStreamingTextResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
} {
|
||||
let reads = 0;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("x".repeat(params.chunkSize)));
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
getReadCount: () => reads,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider error utils", () => {
|
||||
it("formats nested provider error details with request ids", async () => {
|
||||
const response = new Response(
|
||||
@@ -289,21 +263,6 @@ describe("provider error utils", () => {
|
||||
expect(streamed.getReadCount()).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("caps successful text responses instead of buffering oversized bodies", async () => {
|
||||
const streamed = createStreamingTextResponse({
|
||||
chunkCount: 20,
|
||||
chunkSize: 1024,
|
||||
});
|
||||
|
||||
await expect(
|
||||
readProviderTextResponse(streamed.response, "Provider text failed", {
|
||||
maxBytes: 2048,
|
||||
}),
|
||||
).rejects.toThrow("Provider text failed: text response exceeds 2048 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("caps successful binary responses instead of buffering oversized bodies", async () => {
|
||||
const streamed = createStreamingBinaryResponse({
|
||||
chunkCount: 20,
|
||||
|
||||
@@ -14,7 +14,6 @@ export { normalizeOptionalString as trimToUndefined } from "../../packages/norma
|
||||
const ERROR_BODY_METADATA_LIMIT = 500;
|
||||
const PROVIDER_BINARY_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const PROVIDER_TEXT_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Returns a plain object view for provider JSON payloads when one exists. */
|
||||
export function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
@@ -87,20 +86,6 @@ export async function readResponseTextLimited(
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Reads a successful provider text response under a byte cap. */
|
||||
export async function readProviderTextResponse(
|
||||
response: Response,
|
||||
label: string,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<string> {
|
||||
const maxBytes = opts?.maxBytes ?? PROVIDER_TEXT_RESPONSE_MAX_BYTES;
|
||||
const bytes = await readResponseWithLimit(response, maxBytes, {
|
||||
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
||||
new Error(`${label}: text response exceeds ${maxBytesLocal} bytes`),
|
||||
});
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/** Formats common provider JSON error payload shapes into one readable detail string. */
|
||||
export function formatProviderErrorPayload(payload: unknown): string | undefined {
|
||||
const root = asObject(payload);
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "./agent-runner-execution.js";
|
||||
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
|
||||
import {
|
||||
PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
|
||||
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
|
||||
PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
|
||||
@@ -6535,38 +6534,6 @@ describe("runAgentTurnWithFallback", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"surfaces provider authentication failures in $label chats",
|
||||
async (testCase) => {
|
||||
const rawError =
|
||||
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses";
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
||||
new FailoverError("LLM request unauthorized.", {
|
||||
reason: "auth",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
status: 401,
|
||||
rawError,
|
||||
}),
|
||||
);
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.isError).toBe(true);
|
||||
expect(result.payload.text).toBe(PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE);
|
||||
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
|
||||
expect(result.payload.text).not.toContain(rawError);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"surfaces rate-limit fallback copy in $label chats",
|
||||
async (testCase) => {
|
||||
|
||||
@@ -1,60 +1,13 @@
|
||||
/** Tests provider request error classification for retry/fallback decisions. */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FailoverError } from "../../agents/failover-error.js";
|
||||
import {
|
||||
classifyProviderRequestError,
|
||||
PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
|
||||
PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
||||
PROVIDER_INTERNAL_ERROR_USER_MESSAGE,
|
||||
PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE,
|
||||
} from "./provider-request-error-classifier.js";
|
||||
|
||||
describe("provider request error classifier", () => {
|
||||
it("classifies provider HTTP 401 authentication failures", () => {
|
||||
const message =
|
||||
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses";
|
||||
|
||||
expect(classifyProviderRequestError(new Error(message))).toEqual({
|
||||
code: "provider_authentication_error",
|
||||
userMessage: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
|
||||
technicalMessage: message,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies typed authentication failures without relying on raw provider text", () => {
|
||||
const error = new FailoverError("LLM request unauthorized.", {
|
||||
reason: "auth",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(classifyProviderRequestError(error)).toEqual({
|
||||
code: "provider_authentication_error",
|
||||
userMessage: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
|
||||
technicalMessage: "LLM request unauthorized.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not label typed HTTP 403 authorization failures as HTTP 401", () => {
|
||||
const error = new FailoverError("Provider access denied.", {
|
||||
reason: "auth_permanent",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(classifyProviderRequestError(error)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves unrelated HTTP 401 failures unclassified", () => {
|
||||
expect(
|
||||
classifyProviderRequestError(
|
||||
new Error("401 input item id does not belong to this conversation"),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"OpenAI missing custom tool output",
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
// Classifies provider request failures into retry and user-facing categories.
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
AUTH_INVALID_TOKEN_USER_TEXT,
|
||||
classifyProviderRuntimeFailureKind,
|
||||
} from "../../agents/embedded-agent-helpers/errors.js";
|
||||
import { isFailoverError } from "../../agents/failover-error.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
|
||||
/** Provider request error classes that get a specialized user-facing reply. */
|
||||
export type ProviderRequestErrorCode =
|
||||
| "provider_authentication_error"
|
||||
| "provider_conversation_state_error"
|
||||
| "provider_internal_error"
|
||||
| "provider_rate_limit_or_quota_error";
|
||||
@@ -31,24 +25,11 @@ export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE =
|
||||
export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE =
|
||||
"⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening.";
|
||||
|
||||
export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`;
|
||||
|
||||
/** Classifies provider request failures that are actionable for users. */
|
||||
export function classifyProviderRequestError(
|
||||
err: unknown,
|
||||
): ProviderRequestErrorClassification | undefined {
|
||||
const technicalMessage = formatErrorMessage(err);
|
||||
const isTypedAuthFailure = isFailoverError(err) && err.reason === "auth" && err.status === 401;
|
||||
if (
|
||||
isTypedAuthFailure ||
|
||||
classifyProviderRuntimeFailureKind(technicalMessage) === "auth_invalid_token"
|
||||
) {
|
||||
return {
|
||||
code: "provider_authentication_error",
|
||||
userMessage: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE,
|
||||
technicalMessage,
|
||||
};
|
||||
}
|
||||
if (
|
||||
hasHttp429Evidence(err, technicalMessage) &&
|
||||
isGenericProviderRuntimeErrorMessage(technicalMessage)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Unit tests for failure-alert SQLite column codec roundtrip.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js";
|
||||
import type { CronJobRow } from "./schema.js";
|
||||
|
||||
function roundtrip(
|
||||
input: Parameters<typeof bindFailureAlertColumns>[0],
|
||||
): ReturnType<typeof failureAlertFromRow> {
|
||||
const columns = bindFailureAlertColumns(input);
|
||||
return failureAlertFromRow(columns as CronJobRow);
|
||||
}
|
||||
|
||||
describe("failureAlertFromRow", () => {
|
||||
it("round-trips disabled config (false)", () => {
|
||||
expect(roundtrip(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips undefined (no alert config) as undefined", () => {
|
||||
expect(roundtrip(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips enabled-with-defaults ({}) as {}", () => {
|
||||
const result = roundtrip({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("round-trips populated config with all fields", () => {
|
||||
const config = {
|
||||
after: 3,
|
||||
cooldownMs: 120_000,
|
||||
channel: "telegram" as const,
|
||||
to: "@user",
|
||||
mode: "announce" as const,
|
||||
accountId: "acc-1",
|
||||
includeSkipped: true,
|
||||
};
|
||||
expect(roundtrip(config)).toEqual(config);
|
||||
});
|
||||
|
||||
it("round-trips partial config (only after)", () => {
|
||||
expect(roundtrip({ after: 5 })).toEqual({ after: 5 });
|
||||
});
|
||||
|
||||
it("enabled-with-defaults does not collapse to undefined on read", () => {
|
||||
const columns = bindFailureAlertColumns({});
|
||||
const row = columns as CronJobRow;
|
||||
expect(row.failure_alert_disabled).toBe(0);
|
||||
expect(row.failure_alert_after).toBeNull();
|
||||
const decoded = failureAlertFromRow(row);
|
||||
expect(decoded).toEqual({});
|
||||
expect(decoded).not.toBeUndefined();
|
||||
expect(decoded).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ export function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false |
|
||||
if (row.failure_alert_disabled === 1) {
|
||||
return false;
|
||||
}
|
||||
const failureAlertExplicitlyEnabled = row.failure_alert_disabled === 0;
|
||||
if (
|
||||
row.failure_alert_after == null &&
|
||||
!row.failure_alert_channel &&
|
||||
@@ -54,8 +53,7 @@ export function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false |
|
||||
row.failure_alert_cooldown_ms == null &&
|
||||
row.failure_alert_include_skipped == null &&
|
||||
!row.failure_alert_mode &&
|
||||
!row.failure_alert_account_id &&
|
||||
!failureAlertExplicitlyEnabled
|
||||
!row.failure_alert_account_id
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,6 @@ import { truncateUtf16Safe } from "../utils.js";
|
||||
export const TOOL_PROGRESS_OUTPUT_MAX_CHARS = 8_000;
|
||||
|
||||
export { FAST_MODE_AUTO_PROGRESS_KIND } from "../auto-reply/reply-payload.js";
|
||||
export {
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isDeliveredMessagingToolResult,
|
||||
} from "../agents/embedded-agent-message-tool-source-reply.js";
|
||||
export { formatFastModeAutoProgressText, resolveFastModeForElapsed } from "../shared/fast-mode.js";
|
||||
export type { AgentMessage } from "../agents/runtime/index.js";
|
||||
export type { FastModeAutoProgressState } from "../shared/fast-mode.js";
|
||||
|
||||
@@ -14,7 +14,6 @@ export {
|
||||
readProviderJsonArrayFieldResponse,
|
||||
readProviderJsonObjectResponse,
|
||||
readProviderJsonResponse,
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
truncateErrorDetail,
|
||||
} from "../agents/provider-http-errors.js";
|
||||
|
||||
@@ -34,8 +34,6 @@ const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS = new Set([
|
||||
"@openclaw/google-meet:dangerous-exec:src/node-host.ts",
|
||||
"@openclaw/google-meet:dangerous-exec:src/realtime.ts",
|
||||
"@openclaw/matrix:dangerous-exec:src/matrix/deps.ts",
|
||||
"@openclaw/raft:dangerous-exec:src/gateway.ts",
|
||||
"@openclaw/signal:dangerous-exec:src/daemon.ts",
|
||||
"@openclaw/voice-call:dangerous-exec:src/tunnel.ts",
|
||||
"@openclaw/voice-call:dangerous-exec:src/webhook/tailscale.ts",
|
||||
]);
|
||||
|
||||
@@ -664,7 +664,6 @@ describe("ci workflow guards", () => {
|
||||
expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs).not.toHaveProperty("fail_on_qa_failure");
|
||||
expect(qaEvidenceWorkflow.on.workflow_call.inputs).not.toHaveProperty("fail_on_qa_failure");
|
||||
expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs.qa_profile).not.toHaveProperty("options");
|
||||
expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs.qa_profile.default).toBe("all");
|
||||
expect(qaEvidenceWorkflow.on.workflow_call.inputs.qa_profile.type).toBe("string");
|
||||
const validateProfileStep = qaRunJob.steps.find(
|
||||
(step) => step.name === "Validate QA profile input",
|
||||
@@ -683,7 +682,7 @@ describe("ci workflow guards", () => {
|
||||
// Keep the caller's ref while the callee verifies it against expected_sha.
|
||||
ref: "${{ inputs.ref }}",
|
||||
expected_sha: "${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
||||
qa_profile: "all",
|
||||
qa_profile: "release",
|
||||
});
|
||||
expect(generateJob.with).not.toHaveProperty("fail_on_qa_failure");
|
||||
|
||||
@@ -714,8 +713,6 @@ describe("ci workflow guards", () => {
|
||||
(step) => step.name === "Validate QA evidence manifest",
|
||||
);
|
||||
expect(validateManifestStep.run).toContain("qa-profile-evidence-manifest.json");
|
||||
expect(validateManifestStep.run).toContain("qa-evidence.json profile must be all");
|
||||
expect(validateManifestStep.run).toContain("QA evidence manifest profile must be all");
|
||||
expect(validateManifestStep.run).toContain("manifest.targetSha !== targetSha");
|
||||
|
||||
expect(qaRunJob.outputs.artifact_name).toBe("${{ steps.evidence.outputs.artifact_name }}");
|
||||
|
||||
@@ -56,12 +56,14 @@ const QR_IMPORT_DOCKER_E2E_PATH = "scripts/e2e/qr-import-docker.sh";
|
||||
const MULTI_NODE_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/multi-node-update-docker.sh";
|
||||
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH =
|
||||
"scripts/e2e/bundled-plugin-install-uninstall-docker.sh";
|
||||
const AGENT_BUNDLE_MCP_TOOLS_DOCKER_E2E_PATH = "scripts/e2e/agent-bundle-mcp-tools-docker.sh";
|
||||
const AGENT_BUNDLE_MCP_TOOLS_DOCKER_E2E_PATH =
|
||||
"scripts/e2e/agent-bundle-mcp-tools-docker.sh";
|
||||
const COMMITMENTS_SAFETY_DOCKER_E2E_PATH = "scripts/e2e/commitments-safety-docker.sh";
|
||||
const CRESTODIAN_FIRST_RUN_DOCKER_E2E_PATH = "scripts/e2e/crestodian-first-run-docker.sh";
|
||||
const CRESTODIAN_PLANNER_DOCKER_E2E_PATH = "scripts/e2e/crestodian-planner-docker.sh";
|
||||
const CRESTODIAN_RESCUE_DOCKER_E2E_PATH = "scripts/e2e/crestodian-rescue-docker.sh";
|
||||
const SESSION_RUNTIME_CONTEXT_DOCKER_E2E_PATH = "scripts/e2e/session-runtime-context-docker.sh";
|
||||
const SESSION_RUNTIME_CONTEXT_DOCKER_E2E_PATH =
|
||||
"scripts/e2e/session-runtime-context-docker.sh";
|
||||
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH =
|
||||
"scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh";
|
||||
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_PROBE_PATH =
|
||||
@@ -2802,14 +2804,6 @@ grep -Fxq preserved "$TMPDIR/caller-fd"
|
||||
}
|
||||
});
|
||||
|
||||
it("gives Codex on-demand package installs enough time to reach Codex assertions", () => {
|
||||
const runner = readFileSync(CODEX_ON_DEMAND_DOCKER_E2E_PATH, "utf8");
|
||||
|
||||
expect(runner).toContain(
|
||||
'export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}"',
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans package-backed onboarding and plugin Docker artifacts on every exit path", () => {
|
||||
for (const path of [
|
||||
CODEX_ON_DEMAND_DOCKER_E2E_PATH,
|
||||
@@ -4194,7 +4188,7 @@ output="$(cat "$sampler_log")"
|
||||
const client = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_CLIENT_PATH, "utf8");
|
||||
|
||||
expect(runner).toContain(
|
||||
'PORT="$(docker_e2e_read_tcp_port_env OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT 18789)"',
|
||||
"PORT=\"$(docker_e2e_read_tcp_port_env OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT 18789)\"",
|
||||
);
|
||||
expect(runner).toContain('MOCK_PORT="80"');
|
||||
expect(runner).not.toContain("OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_MOCK_PORT");
|
||||
|
||||
@@ -3,32 +3,11 @@ import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { createTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "../..");
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
type TaxonomyFixture = {
|
||||
surfaces?: TaxonomySurfaceFixture[];
|
||||
};
|
||||
|
||||
type TaxonomySurfaceFixture = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
categories?: TaxonomyCategoryFixture[];
|
||||
};
|
||||
|
||||
type TaxonomyCategoryFixture = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
features?: TaxonomyFeatureFixture[];
|
||||
};
|
||||
|
||||
type TaxonomyFeatureFixture = {
|
||||
coverageIds?: string[];
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
tempDirs.cleanup();
|
||||
});
|
||||
@@ -47,33 +26,7 @@ function runCli(...args: string[]) {
|
||||
function writeQaEvidence(params: {
|
||||
dir: string;
|
||||
entries: Array<{ id: string; status: "pass" | "fail" | "blocked" | "skipped" }>;
|
||||
scorecard?: unknown;
|
||||
}) {
|
||||
const scorecard = params.scorecard ?? {
|
||||
filters: { surface: null, category: null },
|
||||
run: { evidenceEntryCount: params.entries.length },
|
||||
categories: {
|
||||
total: 0,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: 0,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
features: {
|
||||
total: 0,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: 0,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
coverageIds: {
|
||||
total: 0,
|
||||
fulfilled: 0,
|
||||
missing: 0,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
categoryReports: [],
|
||||
};
|
||||
fs.mkdirSync(params.dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(params.dir, "qa-evidence.json"),
|
||||
@@ -94,7 +47,31 @@ function writeQaEvidence(params: {
|
||||
coverage: [{ id: "tools.evidence", role: "primary" }],
|
||||
result: { status: entry.status },
|
||||
})),
|
||||
scorecard,
|
||||
scorecard: {
|
||||
filters: { surface: null, category: null },
|
||||
run: { evidenceEntryCount: params.entries.length },
|
||||
categories: {
|
||||
total: 0,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: 0,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
features: {
|
||||
total: 0,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: 0,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
coverageIds: {
|
||||
total: 0,
|
||||
fulfilled: 0,
|
||||
missing: 0,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
categoryReports: [],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -103,73 +80,6 @@ function writeQaEvidence(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function allProfileScorecardFixture() {
|
||||
const taxonomy = parseYaml(
|
||||
fs.readFileSync(path.join(repoRoot, "taxonomy.yaml"), "utf8"),
|
||||
) as TaxonomyFixture;
|
||||
const activeSurfaces = (taxonomy.surfaces ?? []).filter(
|
||||
(surface) => surface.status !== "retired",
|
||||
);
|
||||
const categoryReports = activeSurfaces.flatMap((surface) =>
|
||||
(surface.categories ?? []).map((category) => {
|
||||
const coverageIds = [
|
||||
...new Set((category.features ?? []).flatMap((feature) => feature.coverageIds ?? [])),
|
||||
].sort();
|
||||
return {
|
||||
id: `${surface.id}.${category.id}`,
|
||||
surfaceId: surface.id,
|
||||
name: category.name,
|
||||
status: "missing",
|
||||
features: {
|
||||
total: category.features.length,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: category.features.length,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
coverageIds: {
|
||||
total: coverageIds.length,
|
||||
fulfilled: 0,
|
||||
missing: coverageIds.length,
|
||||
fulfillmentPercent: 0,
|
||||
secondaryOnly: 0,
|
||||
},
|
||||
missingCoverageIds: coverageIds,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const featureCount = categoryReports.reduce((count, report) => count + report.features.total, 0);
|
||||
const coverageIdCount = categoryReports.reduce(
|
||||
(count, report) => count + report.coverageIds.total,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
filters: { surface: null, category: null },
|
||||
run: { evidenceEntryCount: 1 },
|
||||
categories: {
|
||||
total: categoryReports.length,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: categoryReports.length,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
features: {
|
||||
total: featureCount,
|
||||
fulfilled: 0,
|
||||
partial: 0,
|
||||
missing: featureCount,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
coverageIds: {
|
||||
total: coverageIdCount,
|
||||
fulfilled: 0,
|
||||
missing: coverageIdCount,
|
||||
fulfillmentPercent: 0,
|
||||
},
|
||||
categoryReports,
|
||||
};
|
||||
}
|
||||
|
||||
describe("maturity docs renderer CLI", () => {
|
||||
it("checks maturity inputs without requiring QA evidence artifacts", () => {
|
||||
const result = runCli("--check");
|
||||
@@ -203,7 +113,13 @@ describe("maturity docs renderer CLI", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = runCli("--output-dir", outputDir, "--evidence-dir", evidenceDir);
|
||||
const result = runCli(
|
||||
"--output-dir",
|
||||
outputDir,
|
||||
"--evidence-dir",
|
||||
evidenceDir,
|
||||
"--strict-inputs",
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
@@ -231,23 +147,4 @@ describe("maturity docs renderer CLI", () => {
|
||||
expect(scorecard).not.toContain("0 failed");
|
||||
expect(scorecard).not.toContain("0 blocked");
|
||||
});
|
||||
|
||||
it("renders the maturity score from quality and completeness without coverage", () => {
|
||||
const outputDir = tempDirs.make("openclaw-maturity-docs-output-");
|
||||
const evidenceDir = tempDirs.make("openclaw-maturity-docs-evidence-");
|
||||
writeQaEvidence({
|
||||
dir: evidenceDir,
|
||||
entries: [{ id: "passing-scenario", status: "pass" }],
|
||||
scorecard: allProfileScorecardFixture(),
|
||||
});
|
||||
|
||||
const result = runCli("--output-dir", outputDir, "--evidence-dir", evidenceDir);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const scorecard = fs.readFileSync(path.join(outputDir, "maturity", "scorecard.md"), "utf8");
|
||||
expect(scorecard).toContain("<span>Maturity score</span>");
|
||||
expect(scorecard).toContain('<span className="maturity-summary-value">67%</span>');
|
||||
expect(scorecard).toContain("Coverage Experimental - 0%");
|
||||
expect(scorecard).toContain("end-to-end coverage above 90%");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user