Compare commits

..

3 Commits

Author SHA1 Message Date
Dallin Romney
ee4e058769 docs: address macOS review feedback 2026-06-26 19:09:08 -07:00
Dallin Romney
dd4629c469 docs: preserve macOS app detail links 2026-06-26 16:24:33 -07:00
Dallin Romney
095d4e2305 docs: simplify macOS app docs 2026-06-26 15:59:07 -07:00
64 changed files with 368 additions and 2763 deletions

View File

@@ -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 // ""')"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1087,7 +1087,7 @@ function buildIMessageEchoScope(params: {
return scopes;
}
export function buildDirectIMessageReplyTarget(params: {
function buildDirectIMessageReplyTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
sender: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ export {
readProviderJsonArrayFieldResponse,
readProviderJsonObjectResponse,
readProviderJsonResponse,
readProviderTextResponse,
readResponseTextLimited,
truncateErrorDetail,
} from "../agents/provider-http-errors.js";

View File

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

View File

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

View File

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

View File

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