Compare commits

..

1 Commits

Author SHA1 Message Date
Val Alexander
bf3d17b641 fix: surface safe codex activity in control ui 2026-05-10 03:56:23 -05:00
205 changed files with 5205 additions and 11602 deletions

View File

@@ -79,7 +79,6 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- PR shortlist: `gh pr list ...`; then `gh pr view <n> --json number,title,body,closingIssuesReferences,files,statusCheckRollup,reviewDecision`.
- After landing PR: search duplicate open issues/PRs. Before closing: comment why + canonical link.
- If an issue/PR is already fixed on current `main` or solved by a new release: comment with proof + canonical commit/PR/release, then close.
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
- GH comments with markdown backticks, `$`, or shell snippets: avoid inline double-quoted `--body`; use single quotes or `--body-file`.
- PR create: description/body always required. Include concise Summary + Verification sections; mention issue/PR refs, behavior changed, and exact local/Testbox/CI proof. Never open an empty-description, empty-body, or placeholder-body PR.
- PR execution artifacts/screenshots: attach them to the PR, comment, or an external artifact store. Do not add `.github/pr-assets` or other PR-only assets to the repo.

View File

@@ -6,35 +6,24 @@ Docs: https://docs.openclaw.ai
### Changes
- Auto-reply/queue: prioritize foreground user/manual turns ahead of lower-priority cron, heartbeat, memory, and deferred maintenance work within the same command lane, while preserving FIFO ordering within each priority and promoting old background entries to avoid starvation. Fixes #79589. Thanks @SebTardif.
- QA/Mantis: add Telegram live PR evidence automation with Convex-leased credentials, Crabbox transcript capture, motion GIF previews, and inline PR comments.
- QA/Mantis: add a Telegram desktop scenario builder that leases Crabbox, installs native Telegram Desktop, configures an OpenClaw Telegram gateway with leased bot credentials, and records VNC screenshot/video artifacts.
- Discord/voice: add realtime voice diagnostics for speaker turns, playback resets, barge-in detection, and audio cutoff analysis.
- Talk: add `talk.realtime.instructions` so operators can append realtime voice style instructions while preserving OpenClaw's built-in agent-consult guidance. (#79081) Thanks @VACInc.
- Discord/voice: default test and source installs to the pure-JS `opusscript` decoder by ignoring optional native `@discordjs/opus` builds, avoiding slow native addon compiles outside dedicated voice-performance lanes.
- Discord/voice: add an opt-in native `@discordjs/opus` install script and decoder preference for live voice-performance lanes without charging unrelated Docker/tests for native addon builds.
- Gateway/skills: add an opt-in private skill archive upload install path gated by `skills.install.allowUploadedArchives`, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong.
- Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`.
- Agents/compaction: preserve scoped background exec/process session references across embedded compaction and after-turn runtime contexts without exposing sessions from unrelated scopes. Fixes #79284. (#79307) Thanks @TurboTheTurtle.
- Agents/process: tell agents to inspect background sessions with `process log` before sending interactive input and to use `waitingForInput`/`stdinWritable` hints from `log`/`poll`.
- CLI/onboarding: improve setup, onboarding, configure, and channel command wayfinding so terminal flows explain the next useful command instead of relying on terse setup labels.
- Agents/Codex: remove the configurable Codex dynamic-tools profile so Codex app-server always owns workspace, edit, patch, exec, process, and plan tools while OpenClaw integration tools remain available.
### Fixes
- Ollama: stop native `/api/chat` requests from copying catalog `contextWindow` or `maxTokens` into `options.num_ctx` unless `params.num_ctx` is explicitly configured, avoiding pathological prompt-ingestion latency on local large-context models. Fixes #62267. Thanks @BenSHPD.
- Ollama: keep the model idle watchdog enabled for `*:cloud` models routed through a local Ollama host, so cloud-backed tool-loop stalls fail over visibly instead of inheriting local-model no-idle behavior. Fixes #79350. Thanks @geek111.
- Voice/Ollama: honor routed voice agent `tools.allow` for classic embedded voice responses, including empty allowlists, so no-tool Ollama agents do not receive tool schemas. Fixes #79506. Thanks @donkeykong91.
- Gateway: reread config from disk after the first in-process restart loop startup, preventing SIGUSR1 restarts from reusing a stale startup snapshot and dropping config written after boot. Fixes #79947. Thanks @TheLevti.
- Codex app-server: deliver native image-generation outputs from Codex `savedPath` events as reply media, so blank-text image generation turns still attach the generated file. Thanks @keshavbotagent.
- Network/SSRF: keep pinned automatic DNS lookups on IPv4 when dual-stack hosts also publish AAAA records, and treat `EADDRNOTAVAIL` as a transient gateway network failure instead of a fatal crash. Fixes #80078. Thanks @takamasa-aiso.
- Control UI: show compact one-line live/idle/terminal run status badges in the Sessions table and rename the active-minute filter to its updated-within meaning. Fixes #78307. Thanks @BunsDev.
- Control UI: scope chat session-list refreshes by agent and skip disk-only agent store discovery for configured-only lists, preventing post-first-message session switching stalls on large Windows stores. Fixes #79675. Thanks @lovelefeng-glitch, @BunsDev.
- Control UI: show safe native Codex plan, item, tool, and lifecycle activity in Chat without rendering private reasoning text, so reasoning-enabled Codex runs no longer look stuck behind only the reading indicator. Fixes #80039. Thanks @BunsDev.
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
- Cron: let isolated self-cleanup runs inspect their own job run history while keeping other cron jobs and mutation actions blocked. Fixes #80019. Thanks @hclsys.
- Cron: report isolated agent-turn setup and pre-model stalls with phase-specific timeout errors instead of waiting for the full job budget when no model call starts. Fixes #74803. Thanks @jeffsteinbok-openclaw and @dgkim311.
- CLI/plugins: treat arbitrary unknown subcommands outside plugin CLI metadata as normal unknown commands instead of suggesting `plugins.allow`, while preserving allowlist guidance for real plugin command roots. Fixes #80109. (#80123) Thanks @kagura-agent.
- CLI/config: persist explicit `config set` and `config patch` values that equal runtime defaults instead of reporting success while dropping them. Fixes #79856. (#80106) Thanks @abodanty and @hclsys.
- OpenAI/realtime voice: accept Codex-compatible legacy audio and transcript event aliases so provider protocol drift does not drop assistant audio or captions.
- Discord/voice: keep default agent-proxy realtime sessions from auto-speaking filler before the forced OpenClaw consult answer, finish Discord playback on realtime response completion, and queue later exact-speech answers until playback idles to avoid mid-sentence replacement.
@@ -56,7 +45,6 @@ Docs: https://docs.openclaw.ai
- Agents: stop blank model-emitted tool calls before dispatch while preserving id-based tool-name recovery, preventing Kimi/NVIDIA blank-name retry loops without creating a callable `_blank` sentinel. Fixes #34129. (#56391) Thanks @smartchainark.
- Agents/Telegram: deliver the canonical final assistant answer instead of replaying accumulated pre-tool text blocks, preventing duplicate Telegram replies and raw-looking tool-output fragments from leaking into chat delivery. Fixes #79621 and #79986. Thanks @nonzeroclaw and @dudaefj.
- Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle.
- Heartbeat: clear stale auto fallback model overrides when the configured default model changes, so heartbeat runs follow updated `agents.defaults.model.primary` without requiring a manual reset. Fixes #74284. Thanks @brtkwr and @bitloi.
- CLI/agent: let `openclaw agent --model` use the backend/admin Gateway scope without cached device-token scopes silently downscoping the request. (#78837) Thanks @VACInc.
- CLI/help: keep help and version invocations configless while improving shared port, channel, plugin, task, session, message, pairing, and auth recovery text.
- CLI/config: explain strict JSON parse failures with a valid example and the plain-string escape hatch.
@@ -77,11 +65,9 @@ Docs: https://docs.openclaw.ai
- OpenAI/Codex: point gateway missing-key recovery and wizard docs at the canonical `openai/gpt-5.5` plus Codex OAuth route, and fix trajectory export errors so they suggest the valid `openclaw sessions` command.
- Google/Gemini: normalize retired `google/gemini-3-pro-preview` primary, fallback, and model-map refs during config load and unrelated config writes so saved config keeps targeting Gemini 3.1 Pro Preview.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside emitted Google provider model config, so regenerated models.json rows test `google/gemini-3.1-pro-preview`.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids preserved from existing merged models.json providers so config emission keeps targeting `google/gemini-3.1-pro-preview`.
- GitHub Copilot: mint short-lived Copilot API tokens with the same `vscode-chat` integration identity used by runtime requests, and refresh legacy cached tokens missing that identity so image-capable Copilot models no longer inherit the `copilot-language-server` scope. Fixes #79946, #80074. Thanks @TurboTheTurtle.
- Plugins/doctor: drop stale managed npm install records when `openclaw doctor --fix` removes npm packages that shadow bundled plugins, so the rebuilt registry no longer resurrects the removed package metadata.
- Discord/voice: reuse or suppress late realtime consult tool calls without stealing newer speaker context or speaking forced fallback answers twice.
- Discord/voice: skip likely incomplete realtime forced-consult transcript fragments and non-actionable closings so stale partial speech does not queue delayed answers over the next turn.
- Discord/voice: synthesize realtime playback timestamps from emitted Discord PCM so OpenAI realtime barge-in truncation no longer sees `audioEndMs=0` and skips legitimate interruptions.
- Plugin SDK: keep activated linked plugin runtime facades loadable when bundled plugin fallback is disabled. Thanks @shakkernerd.
- Feishu: auto-thread `message(action="send")` replies inside the topic when the active session is group_topic or group_topic_sender, and propagate `replyInThread` through text, card, and media outbound adapters so topic-scoped sessions no longer post at the group root. Fixes #74903. (#77151) Thanks @ai-hpc.
@@ -117,7 +103,6 @@ Docs: https://docs.openclaw.ai
### Changes
- Skills: add `skills.load.allowSymlinkTargets` so intentional symlinked skill folders can resolve into trusted sibling repos without disabling root containment.
- Agents/tools: add core Tool Search so agents can search and call large OpenClaw, MCP, and client tool catalogs through one compact PI bridge.
- Chat commands: add `/think default` and `/fast default` to clear session overrides and inherit configured/provider defaults. (#79385) Thanks @VACInc.
- Dependencies: refresh workspace dependency pins and lockfile, including `@openai/codex` `0.130.0`, `acpx` `0.7.0`, AWS SDK `3.1044.0`, OpenTelemetry `0.217.0`, `typebox` `1.1.38`, `vite` `8.0.11`, `oxfmt` `0.48.0`, and `oxlint` `1.63.0`, and update the Codex harness model snapshot for the new bundled app-server catalog.
- Plugins/install: add guarded plugin install overrides so onboarding and repair tests can route specific plugins to registry specs or local `npm pack` artifacts via environment variables.
@@ -193,7 +178,6 @@ Docs: https://docs.openclaw.ai
- Matrix: attach `com.openclaw.presentation` metadata to semantic presentation replies so OpenClaw-aware Matrix clients can render rich buttons, selects, context rows, and dividers while stock clients keep the plain text fallback. (#73312) Thanks @kakahu2015.
- Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu.
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
- Process tool: show input-wait hints from `log` and `poll` for idle interactive background sessions so operators can inspect stuck CLIs and resume them with existing input actions. Fixes #33957. Thanks @bitloi and @vincentkoc.
- Shell env/Windows: hide the login-shell environment probe child window so gateway startup and shell-env refreshes do not flash a console on Windows. Fixes #78159. (#78266) Thanks @BradGroux.
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
- Media/host-read: allow buffer-verified ZIP archives in the host-local media validator so agents can send ZIP attachments via the message tool. Fixes #78057. (#78292) Thanks @Linux2010.

View File

@@ -716,7 +716,6 @@ public struct AgentParams: Codable, Sendable {
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let acpturnsource: String?
public let internalruntimehandoffid: String?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let voicewaketrigger: String?
@@ -753,7 +752,6 @@ public struct AgentParams: Codable, Sendable {
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
acpturnsource: String?,
internalruntimehandoffid: String?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
voicewaketrigger: String?,
@@ -789,7 +787,6 @@ public struct AgentParams: Codable, Sendable {
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.acpturnsource = acpturnsource
self.internalruntimehandoffid = internalruntimehandoffid
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.voicewaketrigger = voicewaketrigger
@@ -827,7 +824,6 @@ public struct AgentParams: Codable, Sendable {
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case acpturnsource = "acpTurnSource"
case internalruntimehandoffid = "internalRuntimeHandoffId"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case voicewaketrigger = "voiceWakeTrigger"

View File

@@ -862,25 +862,5 @@
{
"source": "fs-safe Cleanup Plan",
"target": "fs-safe Cleanup Plan"
},
{
"source": "Tool Search",
"target": "工具搜索"
},
{
"source": "Tools and plugins",
"target": "工具和插件"
},
{
"source": "Multi-agent sandbox and tools",
"target": "多 Agent 沙盒和工具"
},
{
"source": "Exec tool",
"target": "Exec 工具"
},
{
"source": "ACP agents setup",
"target": "ACP Agents 设置"
}
]

View File

@@ -1214,7 +1214,7 @@ Notes:
- If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild.
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
- OpenClaw defaults to the pure-JS `opusscript` decoder for Discord voice receive. The optional native `@discordjs/opus` package is ignored by the repo pnpm install policy so normal installs, Docker lanes, and unrelated tests do not compile a native addon. Dedicated voice-performance hosts can opt in with `OPENCLAW_DISCORD_OPUS_DECODER=native` after installing the native addon.
- OpenClaw defaults to the pure-JS `opusscript` decoder for Discord voice receive. The optional native `@discordjs/opus` package is ignored by the repo pnpm install policy so normal installs and tests do not compile a native addon; only opt into a native opus build in a dedicated voice-performance or live-lane environment.
- `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`.
- `voice.reconnectGraceMs` controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: `15000`.
- In `stt-tts` mode, voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn. Realtime modes forward speaker starts as barge-in signals to the realtime provider.
@@ -1225,24 +1225,6 @@ Notes:
- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)` after updating, collect a dependency report and logs. The bundled `@discordjs/voice` line includes the upstream padding fix from discord.js PR #11449, which closed discord.js issue #11419.
- `The operation was aborted` receive events are expected when OpenClaw finalizes a captured speaker segment; they are verbose diagnostics, not warnings.
- Verbose Discord voice logs include a bounded one-line STT transcript preview for each accepted speaker segment, so debugging shows both the user side and the agent reply side without dumping unbounded transcript text.
- In `agent-proxy` mode, forced consult fallback skips likely incomplete transcript fragments such as text ending in `...` or a trailing connector like `and`, plus obvious non-actionable closings like “be right back” or “bye”. Logs show `forced agent consult skipped reason=...` when this prevents a stale queued answer.
Native opus setup for source checkouts:
```bash
pnpm install
mise exec node@22 -- pnpm discord:opus:install
```
Use Node 22 for the gateway when you want the upstream macOS arm64 prebuilt native addon. If you use another Node runtime, the opt-in installer may need a local `node-gyp` source-build toolchain.
After installing the native addon, start the Gateway with:
```bash
OPENCLAW_DISCORD_OPUS_DECODER=native pnpm gateway:watch
```
Verbose voice logs should show `discord voice: opus decoder: @discordjs/opus`. Without the env opt-in, or if the native addon is missing or cannot load on the host, OpenClaw logs `discord voice: opus decoder: opusscript` and keeps receiving voice through the pure-JS fallback.
STT plus TTS pipeline:
@@ -1389,7 +1371,6 @@ Expected voice logs:
- On join: `discord voice: joining ... voiceSession=... supervisorSession=... agentSessionMode=... voiceModel=... realtimeModel=...`
- On realtime start: `discord voice: realtime bridge starting ... autoRespond=false interruptResponse=false bargeIn=false minBargeInAudioEndMs=...`
- On speaker audio: `discord voice: realtime speaker turn opened ...`, `discord voice: realtime input audio started ... outputAudioMs=... outputActive=...`, and `discord voice: realtime speaker turn closed ... chunks=... discordBytes=... realtimeBytes=... interruptedPlayback=...`
- On skipped stale speech: `discord voice: realtime forced agent consult skipped reason=incomplete-transcript ...` or `reason=non-actionable-closing ...`
- On realtime response completion: `discord voice: realtime audio playback finishing reason=response.done ... audioMs=... chunks=...`
- On playback stop/reset: `discord voice: realtime audio playback stopped reason=... audioMs=... elapsedMs=... chunks=...`
- On realtime consult: `discord voice: realtime consult requested ... voiceSession=... supervisorSession=... question=...`

View File

@@ -60,7 +60,7 @@ OpenClaw separates the selected provider/model from why it was selected. That so
- **Configured default**: `agents.defaults.model.primary` uses `agents.defaults.model.fallbacks`.
- **Agent primary**: `agents.list[].model` is strict unless that agent model object includes its own `fallbacks`. Use `fallbacks: []` to make the strict behavior explicit, or provide a non-empty list to opt that agent into model fallback.
- **Auto fallback override**: a runtime fallback writes `providerOverride`, `modelOverride`, `modelOverrideSource: "auto"`, and the selected origin model before retrying. That auto override can keep walking the configured fallback chain and is cleared by `/new`, `/reset`, and `sessions.reset`. Heartbeat runs without an explicit `heartbeat.model` also clear a direct auto override when its origin no longer matches the current configured default.
- **Auto fallback override**: a runtime fallback writes `providerOverride`, `modelOverride`, and `modelOverrideSource: "auto"` before retrying. That auto override can keep walking the configured fallback chain and is cleared by `/new`, `/reset`, and `sessions.reset`.
- **User session override**: `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` write `modelOverrideSource: "user"`. That is an exact session selection. If the selected provider/model fails before producing a reply, OpenClaw reports the failure instead of answering from an unrelated configured fallback.
- **Legacy session override**: older session entries may have `modelOverride` without `modelOverrideSource`. OpenClaw treats those as user overrides so an explicit old selection is not silently converted into fallback behavior.
- **Cron payload model**: a cron job `payload.model` / `--model` is a job primary, not a user session override. It uses configured fallbacks unless the job provides `payload.fallbacks`; `payload.fallbacks: []` makes the cron run strict.

View File

@@ -15,10 +15,9 @@ We serialize inbound auto-reply runs (all channels) through a tiny in-process qu
## How it works
- A lane-aware queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8). Entries with the same priority remain FIFO; user/manual turns can jump ahead of lower-priority background work in the same lane.
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8).
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- Priority is local to a lane. It does not interrupt an active run; it only chooses the next queued entry when a lane has capacity. A starvation guard promotes old low/normal-priority entries after a wait threshold.
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
- Typing indicators still fire immediately on enqueue (when supported by the channel) so user experience is unchanged while we wait our turn.

View File

@@ -1280,7 +1280,6 @@
"tools/reactions",
"tools/thinking",
"tools/tokenjuice",
"tools/tool-search",
"tools/loop-detection",
"tools/trajectory",
"tools/tts",

View File

@@ -46,7 +46,6 @@ Environment overrides:
- `PI_BASH_MAX_OUTPUT_CHARS`: in-memory output cap (chars)
- `OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m3h)
- `OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS`: idle-output threshold before writable background sessions are marked as likely waiting for input (default 15000 ms)
Config (preferred):
@@ -62,7 +61,7 @@ Actions:
- `list`: running + finished sessions
- `poll`: drain new output for a session (also reports exit status)
- `log`: read the aggregated output and show input recovery hints (supports `offset` + `limit`)
- `log`: read the aggregated output (supports `offset` + `limit`)
- `write`: send stdin (`data`, optional `eof`)
- `send-keys`: send explicit key tokens or bytes to a PTY-backed session
- `submit`: send Enter / carriage return to a PTY-backed session
@@ -79,14 +78,9 @@ Notes:
- `process` is scoped per agent; it only sees sessions started by that agent.
- Use `poll` / `log` for status, logs, quiet-success confirmation, or
completion confirmation when automatic completion wake is unavailable.
- Use `log` before recovering an interactive CLI so the current transcript,
stdin state, and input-wait hint are visible together.
- Use `write` / `send-keys` / `submit` / `paste` / `kill` when you need input
or intervention.
- `process list` includes a derived `name` (command verb + target) for quick scans.
- `process list`, `poll`, and `log` report `waitingForInput` only
when the session still has writable stdin and has been idle longer than the
input-wait threshold.
- `process log` uses line-based `offset`/`limit`.
- When both `offset` and `limit` are omitted, it returns the last 200 lines and includes a paging hint.
- When `offset` is provided and `limit` is omitted, it returns from `offset` to the end (not capped to 200).
@@ -105,12 +99,6 @@ Run a long task and poll later:
{ "tool": "process", "action": "poll", "sessionId": "<id>" }
```
Inspect an interactive session before sending input:
```json
{ "tool": "process", "action": "log", "sessionId": "<id>" }
```
Start immediately in background:
```json

View File

@@ -200,9 +200,6 @@ settings such as `agents.defaults.imageGenerationModel`, `videoGenerationModel`,
Text, images, video, music, TTS, approvals, and messaging-tool output continue
through the normal OpenClaw delivery path. Media generation does not require PI.
When Codex emits a native image-generation item with a `savedPath`, OpenClaw
forwards that exact file through the normal reply-media path even if the Codex
turn has no assistant text.
## Related

View File

@@ -117,13 +117,6 @@ tool descriptor during discovery and caches it by plugin source and contract, so
later tool planning can skip plugin runtime loading. Tool execution still loads
the owning plugin and calls the live registered implementation.
[Tool Search](/tools/tool-search) is the compact surface
for large catalogs. Instead of putting every OpenClaw, MCP, or client tool
schema into the prompt, OpenClaw can give the model an isolated Node runtime
with `openclaw.tools.search`, `openclaw.tools.describe`, and
`openclaw.tools.call`. Calls still flow back through the Gateway, so tool
policy, approvals, hooks, and session logs remain authoritative.
## Tool configuration
### Allow and deny lists

View File

@@ -1,260 +0,0 @@
---
summary: "Tool Search: compact large PI tool catalogs behind search, describe, and call"
title: "Tool Search"
read_when:
- You want PI agents to use a large tool catalog without adding every tool schema to the prompt
- You want OpenClaw tools, MCP tools, and client tools exposed through one compact PI surface
- You are implementing or debugging tool discovery for PI runs
---
Tool Search gives PI agents one compact way to discover and call large tool
catalogs. It is useful when the run has many available tools but the model is
likely to need only a few of them.
When enabled for PI, the model receives one `tool_search_code` tool by default.
That tool runs a short JavaScript body in an isolated Node subprocess with an
`openclaw.tools` bridge:
```js
const hits = await openclaw.tools.search("create a GitHub issue");
const tool = await openclaw.tools.describe(hits[0].id);
return await openclaw.tools.call(tool.id, {
title: "Crash on startup",
body: "Steps to reproduce...",
});
```
The catalog can include OpenClaw tools, plugin tools, MCP tools, and
client-provided tools. The model does not see every full schema up front.
Instead, it searches compact descriptors, describes one selected tool when it
needs the exact schema, and calls that tool through OpenClaw.
Codex harness runs do not receive these OpenClaw Tool Search controls. OpenClaw
passes product capabilities to Codex as dynamic tools, and Codex owns native
code mode, native tool search, deferred dynamic tools, and nested tool calls.
## How a turn runs
At planning time the PI embedded runner builds the effective catalog for the
run:
1. Resolve the active tool policy for the agent, profile, sandbox, and session.
2. List eligible OpenClaw and plugin tools.
3. List eligible MCP tools through the session MCP runtime.
4. Add eligible client tools supplied for the current run.
5. Index compact descriptors for search.
6. Expose either the PI code bridge or the structured fallback tools to the
model.
At execution time every real tool call returns to OpenClaw. The isolated Node
runtime does not hold plugin implementations, MCP client objects, or secrets.
`openclaw.tools.call(...)` crosses the bridge back into the Gateway, where the
normal policy, approval, hook, logging, and result handling still apply.
## Modes
`tools.toolSearch` has two model-facing modes:
- `code`: exposes `tool_search_code`, the default compact JavaScript bridge.
- `tools`: exposes `tool_search`, `tool_describe`, and `tool_call` as plain
structured tools for providers that should not receive code.
Both modes use the same catalog and execution path. The only difference is the
shape the model sees. If the current runtime cannot launch the isolated Node
code-mode child process, the default `code` mode falls back to `tools` before
catalog compaction.
There is no separate source-selection config. When Tool Search is enabled, the
catalog includes eligible OpenClaw, MCP, and client tools after normal policy
filtering.
## Why this exists
Large catalogs are useful but expensive. Sending every tool schema to the model
makes the request larger, slows planning, and increases accidental tool
selection.
Tool Search changes the shape:
- direct tools: the model sees every selected schema before the first token
- Tool Search code mode: the model sees one compact code tool and a short API
contract
- Tool Search tools mode: the model sees three compact structured fallback
tools
- during the turn: the model loads only the tool schemas it actually needs
Direct tool exposure is still the right default for small catalogs. Tool Search
is best when one run can see many tools, especially from MCP servers or
client-provided app tools.
## API
`openclaw.tools.search(query, options?)`
Searches the effective catalog for the current run. Results are compact and safe
to put back into prompt context.
```js
const hits = await openclaw.tools.search("calendar event", { limit: 5 });
```
`openclaw.tools.describe(id)`
Loads full metadata for one search result, including the exact input schema.
```js
const calendarCreate = await openclaw.tools.describe("mcp:calendar:create_event");
```
`openclaw.tools.call(id, args)`
Calls a selected tool through OpenClaw.
```js
await openclaw.tools.call(calendarCreate.id, {
summary: "Planning",
start: "2026-05-09T14:00:00Z",
});
```
The structured fallback mode exposes the same operations as tools:
- `tool_search`
- `tool_describe`
- `tool_call`
## Runtime boundary
The code bridge runs in a short-lived Node subprocess. The subprocess starts
with Node permission mode enabled, an empty environment, no filesystem or
network grants, and no child-process or worker grants. OpenClaw enforces a
parent-process wall-clock timeout and kills the subprocess on timeout, including
after async continuations.
The runtime exposes only:
- `console.log`, `console.warn`, and `console.error`
- `openclaw.tools.search`
- `openclaw.tools.describe`
- `openclaw.tools.call`
Normal OpenClaw behavior still applies to final calls:
- tool allow and deny policies
- per-agent and per-sandbox tool restrictions
- owner-only gating
- approval hooks
- plugin `before_tool_call` hooks
- session identity, logs, and telemetry
## Config
Enable Tool Search for PI runs with the default code bridge:
```bash
openclaw config set tools.toolSearch true
```
Equivalent JSON:
```json5
{
tools: {
toolSearch: true,
},
}
```
Use the structured fallback tools instead for PI runs:
```json5
{
tools: {
toolSearch: {
mode: "tools",
},
},
}
```
Tune code-mode timeout and search result limits:
```json5
{
tools: {
toolSearch: {
mode: "code",
codeTimeoutMs: 10000,
searchDefaultLimit: 8,
maxSearchLimit: 20,
},
},
}
```
Disable it:
```json5
{
tools: {
toolSearch: false,
},
}
```
## Prompt and telemetry
Tool Search records enough telemetry to compare it with direct tool exposure:
- total serialized tool and prompt bytes sent to the harness
- catalog size and source breakdown
- search, describe, and call counts
- final tool calls executed through OpenClaw
- selected tool ids and sources
Session logs should make it possible to answer:
- how many tool schemas the model saw up front
- how many search and describe operations it performed
- which final tool was called
- whether the result came from OpenClaw, MCP, or a client tool
## E2E validation
The gateway E2E runner proves both paths with the PI harness:
```bash
node --import tsx scripts/tool-search-gateway-e2e.ts
```
It creates a temporary fake plugin with a large tool catalog, starts the mock
OpenAI provider, starts a Gateway once in direct mode and once with Tool Search
enabled, then compares provider request payloads and session logs.
The regression proves:
1. Direct mode can call the fake plugin tool.
2. Tool Search can call the same fake plugin tool.
3. Direct mode exposes the fake plugin tool schemas directly to the provider.
4. Tool Search exposes only the compact bridge.
5. The Tool Search request payload is smaller for the large fake catalog.
6. Session logs show the expected tool-call counts and bridged call telemetry.
## Failure behavior
Tool Search should fail closed:
- if a tool is not in the effective policy, search should not return it
- if a selected tool becomes unavailable, `tool_call` should fail
- if policy or approval blocks execution, the call result should report that
block instead of bypassing it
- if the code bridge cannot create an isolated runtime, use `mode: "tools"` or
disable Tool Search for that deployment
## Related
- [Tools and plugins](/tools)
- [Multi-agent sandbox and tools](/tools/multi-agent-sandbox-tools)
- [Exec tool](/tools/exec)
- [ACP agents setup](/tools/acp-agents-setup)
- [Building plugins](/plugins/building-plugins)

View File

@@ -99,6 +99,7 @@ Imported themes are stored only in the current browser profile. They are not wri
- Chat history refreshes request a bounded recent window with per-message text caps so large sessions do not force the browser to render a full transcript payload before the chat becomes usable.
- Talk through browser realtime sessions. OpenAI uses direct WebRTC, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use the Gateway relay transport. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio` and forwards `openclaw_agent_consult` provider tool calls through `talk.client.toolCall` for Gateway policy and the larger configured OpenClaw model.
- Stream tool calls + live tool output cards in Chat (agent events).
- Native Codex runs show a compact safe activity indicator for plan, item, tool, and lifecycle progress. The Control UI does not render raw private reasoning text from Codex app-server events; when reasoning visibility is enabled, the indicator only shows approved progress summaries.
</Accordion>
<Accordion title="Channels, instances, sessions, dreams">

View File

@@ -12,51 +12,6 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => (
const mockCallGatewayTool = vi.mocked(callGatewayTool);
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function gatewayRequestPayload(callIndex = 0) {
return requireRecord(
mockCallGatewayTool.mock.calls[callIndex]?.[2],
`gateway request payload ${callIndex + 1}`,
);
}
function gatewayCallOptions(callIndex = 0) {
return mockCallGatewayTool.mock.calls[callIndex]?.[3];
}
function findApprovalEvent(
params: EmbeddedRunAttemptParams,
fields: { status?: string; approvalId?: string; command?: string; reason?: string },
) {
const onAgentEvent = params.onAgentEvent as unknown as { mock?: { calls?: unknown[][] } };
const calls = onAgentEvent.mock?.calls;
if (!Array.isArray(calls)) {
throw new Error("Expected onAgentEvent mock calls");
}
for (const call of calls) {
const event = requireRecord(call[0], "agent event");
if (event.stream !== "approval") {
continue;
}
const data = requireRecord(event.data, "approval event data");
if (
(!fields.status || data.status === fields.status) &&
(!fields.approvalId || data.approvalId === fields.approvalId) &&
(!fields.command || data.command === fields.command) &&
(!fields.reason || data.reason === fields.reason)
) {
return data;
}
}
throw new Error(`Expected approval event ${JSON.stringify(fields)}`);
}
function createParams(): EmbeddedRunAttemptParams {
return {
sessionKey: "agent:main:session-1",
@@ -98,21 +53,30 @@ describe("Codex app-server approval bridge", () => {
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
expect(mockCallGatewayTool.mock.calls[0]?.[0]).toBe("plugin.approval.request");
expect(typeof mockCallGatewayTool.mock.calls[0]?.[1]).toBe("object");
const requestPayload = gatewayRequestPayload();
expect(requestPayload.pluginId).toBe("openclaw-codex-app-server");
expect(requestPayload.title).toBe("Codex app-server command approval");
expect(requestPayload.twoPhase).toBe(true);
expect(requestPayload.turnSourceChannel).toBe("telegram");
expect(requestPayload.turnSourceTo).toBe("chat-1");
expect(gatewayCallOptions()).toEqual({ expectFinal: false });
expect(
findApprovalEvent(params, { status: "pending", approvalId: "plugin:approval-1" }),
).toBeDefined();
expect(
findApprovalEvent(params, { status: "approved", approvalId: "plugin:approval-1" }),
).toBeDefined();
expect(mockCallGatewayTool).toHaveBeenCalledWith(
"plugin.approval.request",
expect.any(Object),
expect.objectContaining({
pluginId: "openclaw-codex-app-server",
title: "Codex app-server command approval",
twoPhase: true,
turnSourceChannel: "telegram",
turnSourceTo: "chat-1",
}),
{ expectFinal: false },
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ status: "pending", approvalId: "plugin:approval-1" }),
}),
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ status: "approved", approvalId: "plugin:approval-1" }),
}),
);
});
it("describes command approvals from parsed command actions when available", async () => {
@@ -135,10 +99,23 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
const requestPayload = gatewayRequestPayload();
expect(String(requestPayload.description)).toContain("Command: pnpm test extensions/codex");
expect(String(requestPayload.description)).not.toContain("bash -lc");
expect(findApprovalEvent(params, { command: "pnpm test extensions/codex" })).toBeDefined();
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description: expect.stringContaining("Command: pnpm test extensions/codex"),
}),
);
expect(requestPayload).toEqual(
expect.objectContaining({
description: expect.not.stringContaining("bash -lc"),
}),
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ command: "pnpm test extensions/codex" }),
}),
);
});
it("describes command approval permission and policy amendments", async () => {
@@ -237,15 +214,22 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
expect(gatewayRequestPayload().description).toBe(
"Command: pnpm test --watch extensions/codex/src/app-server\nSession: agent:main:session-1",
);
expect(
findApprovalEvent(params, {
status: "pending",
command: "pnpm test --watch extensions/codex/src/app-server",
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description:
"Command: pnpm test --watch extensions/codex/src/app-server\nSession: agent:main:session-1",
}),
).toBeDefined();
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({
status: "pending",
command: "pnpm test --watch extensions/codex/src/app-server",
}),
}),
);
});
it("escapes command approval previews before forwarding approval text and events", async () => {
@@ -275,12 +259,15 @@ describe("Codex app-server approval bridge", () => {
expect(description).not.toContain("<@U123>");
expect(description).not.toContain("[trusted](https://evil)");
expect(description).not.toContain("@here");
expect(
findApprovalEvent(params, {
command:
"printf '&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'",
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({
command:
"printf '&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'",
}),
}),
).toBeDefined();
);
});
it("preserves visible OSC-8 link labels in command previews", async () => {
@@ -303,10 +290,18 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
expect(gatewayRequestPayload().description).toBe(
"Command: prefix VISIBLE suffix\nSession: agent:main:session-1",
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description: "Command: prefix VISIBLE suffix\nSession: agent:main:session-1",
}),
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ command: "prefix VISIBLE suffix" }),
}),
);
expect(findApprovalEvent(params, { command: "prefix VISIBLE suffix" })).toBeDefined();
});
it("strips bidi and invisible formatting controls from command previews", async () => {
@@ -328,10 +323,18 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
expect(gatewayRequestPayload().description).toBe(
"Command: echo safe cod.exe hidden done\nSession: agent:main:session-1",
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description: "Command: echo safe cod.exe hidden done\nSession: agent:main:session-1",
}),
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ command: "echo safe cod.exe hidden done" }),
}),
);
expect(findApprovalEvent(params, { command: "echo safe cod.exe hidden done" })).toBeDefined();
});
it("marks oversized unsafe command previews as omitted", async () => {
@@ -355,11 +358,21 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
expect(gatewayRequestPayload().description).toBe(
"Command: [preview truncated or unsafe content omitted]\nSession: agent:main:session-1",
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description:
"Command: [preview truncated or unsafe content omitted]\nSession: agent:main:session-1",
}),
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({
commandPreviewOmitted: true,
}),
}),
);
const omittedEvent = findApprovalEvent(params, {});
expect(omittedEvent.commandPreviewOmitted).toBe(true);
});
it("marks clipped command previews even when a safe prefix remains", async () => {
@@ -384,8 +397,14 @@ describe("Codex app-server approval bridge", () => {
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
const description = (requestPayload as { description: string }).description;
expect(description).toContain("[preview truncated or unsafe content omitted]");
const omittedEvent = findApprovalEvent(params, {});
expect(omittedEvent.commandPreviewOmitted).toBe(true);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({
commandPreviewOmitted: true,
}),
}),
);
});
it("does not trust request-time decisions for two-phase command approvals", async () => {
@@ -416,12 +435,15 @@ describe("Codex app-server approval bridge", () => {
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
expect(
findApprovalEvent(params, {
status: "denied",
approvalId: "plugin:approval-untrusted",
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({
status: "denied",
approvalId: "plugin:approval-untrusted",
}),
}),
).toBeDefined();
);
});
it("only treats own null data-property request decisions as no-route", async () => {
@@ -538,9 +560,12 @@ describe("Codex app-server approval bridge", () => {
expect(result).toEqual({ decision: "decline" });
expect(mockCallGatewayTool).toHaveBeenCalledTimes(1);
expect(
findApprovalEvent(params, { status: "unavailable", reason: "needs write access" }),
).toBeDefined();
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({ status: "unavailable", reason: "needs write access" }),
}),
);
});
it("sanitizes reason previews before forwarding approval text and events", async () => {
@@ -563,15 +588,21 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
expect(gatewayRequestPayload().description).toBe(
"Reason: needs write access for /tmp please\nSession: agent:main:session-1",
);
expect(
findApprovalEvent(params, {
status: "unavailable",
reason: "needs write access for /tmp please",
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description: "Reason: needs write access for /tmp please\nSession: agent:main:session-1",
}),
).toBeDefined();
);
expect(params.onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "approval",
data: expect.objectContaining({
status: "unavailable",
reason: "needs write access for /tmp please",
}),
}),
);
});
it("fails closed for unsupported native approval methods without requesting plugin approval", async () => {
@@ -625,20 +656,28 @@ describe("Codex app-server approval bridge", () => {
},
scope: "turn",
});
expect(mockCallGatewayTool.mock.calls[0]?.[0]).toBe("plugin.approval.request");
expect(typeof mockCallGatewayTool.mock.calls[0]?.[1]).toBe("object");
const requestPayload = gatewayRequestPayload();
expect(requestPayload.title).toBe("Codex app-server permission approval");
expect(requestPayload.toolName).toBe("codex_permission_approval");
const description = String(requestPayload.description);
expect(description).toContain("Permissions: network, fileSystem");
expect(gatewayCallOptions()).toEqual({ expectFinal: false });
expect(mockCallGatewayTool).toHaveBeenCalledWith(
"plugin.approval.request",
expect.any(Object),
expect.objectContaining({
title: "Codex app-server permission approval",
toolName: "codex_permission_approval",
description: expect.stringContaining("Permissions: network, fileSystem"),
}),
{ expectFinal: false },
);
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
const description = (requestPayload as { description: string }).description;
expect(description).toContain("Network allowHosts: example.com, *.internal");
expect(description).toContain("File system roots: /; writePaths: ~");
expect(description).toContain(
"High-risk targets: wildcard hosts, private-network wildcards, filesystem root",
);
expect(description).not.toContain("agent:main:session-1");
expect(requestPayload).toEqual(
expect.objectContaining({
description: expect.not.stringContaining("agent:main:session-1"),
}),
);
});
it("keeps permission detail bounded with truncated and compacted target samples", async () => {
@@ -674,7 +713,13 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
});
const description = String(gatewayRequestPayload().description);
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description: expect.any(String),
}),
);
const description = (requestPayload as { description: string }).description;
expect(description.length).toBeLessThanOrEqual(700);
expect(description).toContain("example.com");
expect(description).not.toContain("secret-token");

View File

@@ -18,42 +18,6 @@ function resolveRuntimeForTest(params: RuntimeOptionsParams = {}) {
return resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null, ...params });
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function expectFields(
value: unknown,
label: string,
fields: Record<string, unknown>,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expected] of Object.entries(fields)) {
expect(record[key]).toEqual(expected);
}
return record;
}
function expectRuntimePolicy(
runtime: unknown,
fields: {
approvalPolicy: string;
sandbox: string;
approvalsReviewer: string;
},
) {
expectFields(runtime, "runtime policy", fields);
}
function expectUiHintLabel(manifest: { uiHints: Record<string, unknown> }, key: string) {
const hint = requireRecord(manifest.uiHints[key], `${key} UI hint`);
expect(typeof hint.label).toBe("string");
expect((hint.label as string).length).toBeGreaterThan(0);
}
describe("Codex app-server config", () => {
it("parses typed plugin config before falling back to environment knobs", () => {
const runtime = resolveRuntimeForTest({
@@ -76,18 +40,20 @@ describe("Codex app-server config", () => {
},
});
expectFields(runtime, "runtime", {
approvalPolicy: "on-request",
sandbox: "danger-full-access",
approvalsReviewer: "guardian_subagent",
serviceTier: "flex",
turnCompletionIdleTimeoutMs: 120_000,
});
expectFields(runtime.start, "runtime start", {
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "danger-full-access",
approvalsReviewer: "guardian_subagent",
serviceTier: "flex",
turnCompletionIdleTimeoutMs: 120_000,
start: expect.objectContaining({
transport: "websocket",
url: "ws://127.0.0.1:39175",
headers: { "X-Test": "yes" },
}),
}),
);
});
it("ignores app-server environment clearing for websocket transports", () => {
@@ -115,9 +81,11 @@ describe("Codex app-server config", () => {
env: {},
});
expectFields(runtime.start, "runtime start", {
clearEnv: ["OPENAI_API_KEY"],
});
expect(runtime.start).toEqual(
expect.objectContaining({
clearEnv: ["OPENAI_API_KEY"],
}),
);
});
it("normalizes legacy service tiers without discarding the rest of the config", () => {
@@ -133,12 +101,14 @@ describe("Codex app-server config", () => {
env: {},
});
expectFields(runtime, "runtime", {
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "auto_review",
serviceTier: "priority",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "auto_review",
serviceTier: "priority",
}),
);
});
it("passes through non-empty Codex app-server service tiers for forward compatibility", () => {
@@ -178,15 +148,17 @@ describe("Codex app-server config", () => {
pluginConfig: {},
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expectFields(runtime.start, "runtime start", {
command: "codex",
commandSource: "managed",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
start: expect.objectContaining({
command: "codex",
commandSource: "managed",
}),
}),
);
});
it("defaults native Codex approvals to guardian when requirements disallow full access", () => {
@@ -195,11 +167,13 @@ describe("Codex app-server config", () => {
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("uses read-only sandbox for guardian defaults when requirements only allow read-only", () => {
@@ -208,11 +182,13 @@ describe("Codex app-server config", () => {
requirementsToml: 'allowed_sandbox_modes = ["read-only"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "auto_review",
}),
);
});
it("defaults native Codex approvals to guardian when requirements disallow never approval", () => {
@@ -221,11 +197,13 @@ describe("Codex app-server config", () => {
requirementsToml: 'allowed_approval_policies = ["on-request"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("selects an allowed guardian approval policy when on-request is unavailable", () => {
@@ -234,11 +212,13 @@ describe("Codex app-server config", () => {
requirementsToml: 'allowed_approval_policies = ["on-failure"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-failure",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-failure",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("keeps native Codex approvals unchained when requirements allow never approval", () => {
@@ -247,11 +227,13 @@ describe("Codex app-server config", () => {
requirementsToml: 'allowed_approval_policies = ["never"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("defaults native Codex approvals to guardian when requirements disallow user reviewer", () => {
@@ -260,11 +242,13 @@ describe("Codex app-server config", () => {
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("selects an allowed reviewer when sandbox requirements force guardian defaults", () => {
@@ -274,11 +258,13 @@ describe("Codex app-server config", () => {
'allowed_sandbox_modes = ["read-only", "workspace-write"]\nallowed_approvals_reviewers = ["user"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
}),
);
});
it("ignores quoted sandbox modes inside requirements comments", () => {
@@ -292,11 +278,13 @@ describe("Codex app-server config", () => {
`,
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("applies the first matching remote sandbox requirements before resolving local stdio defaults", () => {
@@ -313,11 +301,13 @@ allowed_sandbox_modes = ["read-only", "danger-full-access"]
`,
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("ignores non-matching remote-only sandbox requirements when resolving local stdio defaults", () => {
@@ -330,11 +320,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
`,
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("reads local requirements policy from the configured requirements path", () => {
@@ -350,11 +342,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
expect(readPaths).toEqual(["/custom/codex/requirements.toml"]);
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("reads local requirements policy from the Codex Windows requirements path", () => {
@@ -370,11 +364,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
expect(readPaths).toEqual(["D:\\ManagedData\\OpenAI\\Codex\\requirements.toml"]);
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("keeps native Codex approvals unchained when requirements allow full access", () => {
@@ -384,11 +380,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
'allowed_sandbox_modes = ["ReadOnly", "WorkspaceWrite", "DangerFullAccess"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("keeps native Codex approvals unchained when requirements are malformed", () => {
@@ -397,11 +395,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
requirementsToml: "allowed_sandbox_modes = [read-only]\n",
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("does not apply local requirements policy to websocket app-server transports", () => {
@@ -415,37 +415,41 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("keeps explicit yolo mode when requirements disallow full access", () => {
const requirementsToml = 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
expectRuntimePolicy(
expect(
resolveRuntimeForTest({
pluginConfig: { appServer: { mode: "yolo" } },
requirementsToml,
}),
{
).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
},
}),
);
expectRuntimePolicy(
expect(
resolveRuntimeForTest({
pluginConfig: {},
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "yolo" },
requirementsToml,
}),
{
).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
},
}),
);
});
@@ -536,28 +540,28 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
it("treats configured and environment commands as explicit overrides", () => {
expectFields(
expect(
resolveRuntimeForTest({
pluginConfig: { appServer: { command: "/opt/codex/bin/codex" } },
env: { OPENCLAW_CODEX_APP_SERVER_BIN: "/usr/local/bin/codex" },
}).start,
"configured start",
{
).toEqual(
expect.objectContaining({
command: "/opt/codex/bin/codex",
commandSource: "config",
},
}),
);
expectFields(
expect(
resolveRuntimeForTest({
pluginConfig: {},
env: { OPENCLAW_CODEX_APP_SERVER_BIN: "/usr/local/bin/codex" },
}).start,
"environment start",
{
).toEqual(
expect.objectContaining({
command: "/usr/local/bin/codex",
commandSource: "env",
},
}),
);
});
@@ -583,7 +587,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
marketplaceName: "desktop-tools",
});
expectFields(
expect(
resolveCodexComputerUseConfig({
pluginConfig: {},
env: {
@@ -593,13 +597,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
OPENCLAW_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS: "30000",
},
}),
"computer use config",
{
).toEqual(
expect.objectContaining({
enabled: true,
autoInstall: true,
marketplaceDiscoveryTimeoutMs: 30_000,
marketplaceSource: "github:example/plugins",
},
}),
);
});
@@ -613,11 +617,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: {},
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("allows environment mode fallback to opt in to guardian-reviewed local execution", () => {
@@ -626,11 +632,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "guardian" },
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
}),
);
});
it("accepts the latest auto_review reviewer and legacy guardian_subagent alias", () => {
@@ -654,11 +662,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: { OPENCLAW_CODEX_APP_SERVER_GUARDIAN: "1" },
});
expectRuntimePolicy(runtime, {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("lets explicit policy fields override guardian mode", () => {
@@ -674,11 +684,13 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: {},
});
expectRuntimePolicy(runtime, {
approvalPolicy: "on-failure",
sandbox: "danger-full-access",
approvalsReviewer: "user",
});
expect(runtime).toEqual(
expect.objectContaining({
approvalPolicy: "on-failure",
sandbox: "danger-full-access",
approvalsReviewer: "user",
}),
);
});
it("derives distinct shared-client keys for distinct auth tokens without exposing them", () => {
@@ -779,21 +791,27 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
expect(manifestKeys).toEqual([...CODEX_APP_SERVER_CONFIG_KEYS].toSorted());
for (const key of CODEX_APP_SERVER_CONFIG_KEYS) {
expectUiHintLabel(manifest, `appServer.${key}`);
expect(manifest.uiHints[`appServer.${key}`]).toMatchObject({
label: expect.any(String),
});
}
const computerUseManifestKeys = Object.keys(
manifest.configSchema.properties.computerUse.properties,
).toSorted();
expect(computerUseManifestKeys).toEqual([...CODEX_COMPUTER_USE_CONFIG_KEYS].toSorted());
for (const key of CODEX_COMPUTER_USE_CONFIG_KEYS) {
expectUiHintLabel(manifest, `computerUse.${key}`);
expect(manifest.uiHints[`computerUse.${key}`]).toMatchObject({
label: expect.any(String),
});
}
const codexPluginsProperties = manifest.configSchema.properties.codexPlugins;
const codexPluginsManifestKeys = Object.keys(codexPluginsProperties.properties).toSorted();
expect(codexPluginsManifestKeys).toEqual([...CODEX_PLUGINS_CONFIG_KEYS].toSorted());
expect(codexPluginsProperties.additionalProperties).toBe(false);
for (const key of CODEX_PLUGINS_CONFIG_KEYS) {
expectUiHintLabel(manifest, `codexPlugins.${key}`);
expect(manifest.uiHints[`codexPlugins.${key}`]).toMatchObject({
label: expect.any(String),
});
}
const pluginEntryProperties = (
codexPluginsProperties.properties.plugins as {

View File

@@ -117,84 +117,6 @@ function buildEmptyToolTelemetry(): CodexAppServerToolTelemetry {
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value;
}
function expectUsageFields(
usage: unknown,
expected: { input: number; output: number; cacheRead: number; total: number },
) {
const record = requireRecord(usage, "usage");
expect(record.input).toBe(expected.input);
expect(record.output).toBe(expected.output);
expect(record.cacheRead).toBe(expected.cacheRead);
expect(record.total ?? record.totalTokens).toBe(expected.total);
}
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`Expected ${label} mock calls`);
}
const call = calls[callIndex];
if (!call) {
throw new Error(`Expected ${label} call ${callIndex + 1}`);
}
return call[argIndex];
}
function findAgentEvent(
mock: unknown,
params: { stream: string; phase?: string; itemId?: string; name?: string },
) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error("Expected onAgentEvent mock calls");
}
for (const call of calls) {
const event = requireRecord(call[0], "agent event");
const data = requireRecord(event.data, "agent event data");
if (
event.stream === params.stream &&
(!params.phase || data.phase === params.phase) &&
(!params.itemId || data.itemId === params.itemId) &&
(!params.name || data.name === params.name)
) {
return { event, data };
}
}
throw new Error(`Expected agent event ${params.stream}`);
}
function findPlanEventWithSteps(mock: unknown, steps: string[]) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error("Expected onAgentEvent mock calls");
}
for (const call of calls) {
const event = requireRecord(call[0], "agent event");
if (event.stream !== "plan") {
continue;
}
const data = requireRecord(event.data, "plan event data");
if (JSON.stringify(data.steps) === JSON.stringify(steps)) {
return data;
}
}
throw new Error(`Expected plan event ${steps.join(", ")}`);
}
function forCurrentTurn(
method: ProjectorNotification["method"],
params: Record<string, unknown>,
@@ -283,12 +205,12 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual(["hello"]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 });
expectUsageFields(result.lastAssistant?.usage, {
expect(result.attemptUsage).toMatchObject({ input: 3, output: 7, cacheRead: 2, total: 12 });
expect(result.lastAssistant?.usage).toMatchObject({
input: 3,
output: 7,
cacheRead: 2,
total: 12,
totalTokens: 12,
});
expect(result.replayMetadata.replaySafe).toBe(true);
});
@@ -314,11 +236,11 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual(["done"]);
expect(result.attemptUsage).toBeUndefined();
expectUsageFields(result.lastAssistant?.usage, {
expect(result.lastAssistant?.usage).toMatchObject({
input: 0,
output: 0,
cacheRead: 0,
total: 0,
totalTokens: 0,
});
});
@@ -343,55 +265,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "OK from raw" }]);
});
it("attaches native Codex image-generation saved paths as reply media", async () => {
const projector = await createProjector();
const savedPath = "/tmp/codex-home/generated_images/session-1/ig_123.png";
await projector.handleNotification(
turnCompleted([
{
type: "imageGeneration",
id: "ig_123",
status: "completed",
revisedPrompt: "A tiny blue square",
result: "Zm9v",
savedPath,
},
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toStrictEqual([]);
expect(result.toolMediaUrls).toEqual([savedPath]);
});
it("does not append native Codex image-generation media after explicit media delivery", async () => {
const projector = await createProjector();
const savedPath = "/tmp/codex-home/generated_images/session-1/ig_123.png";
await projector.handleNotification(
turnCompleted([
{
type: "imageGeneration",
id: "ig_123",
status: "completed",
revisedPrompt: null,
result: "Zm9v",
savedPath,
},
]),
);
const result = projector.buildResult({
...buildEmptyToolTelemetry(),
messagingToolSentMediaUrls: [savedPath],
toolMediaUrls: [],
});
expect(result.toolMediaUrls).toStrictEqual([]);
});
it("does not fail a completed reply after a retryable app-server error notification", async () => {
const projector = await createProjector();
@@ -535,12 +408,12 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expectUsageFields(result.attemptUsage, { input: 5, output: 9, cacheRead: 3, total: 17 });
expectUsageFields(result.lastAssistant?.usage, {
expect(result.attemptUsage).toMatchObject({ input: 5, output: 9, cacheRead: 3, total: 17 });
expect(result.lastAssistant?.usage).toMatchObject({
input: 5,
output: 9,
cacheRead: 3,
total: 17,
totalTokens: 17,
});
});
@@ -683,26 +556,30 @@ describe("CodexAppServerEventProjector", () => {
}),
);
const started = findAgentEvent(onAgentEvent, {
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.guardian",
phase: "started",
}).data;
expect(started.reviewId).toBe("review-1");
expect(started.targetItemId).toBe("cmd-1");
expect(started.status).toBe("inProgress");
expect(started.actionType).toBe("execve");
const completed = findAgentEvent(onAgentEvent, {
data: expect.objectContaining({
phase: "started",
reviewId: "review-1",
targetItemId: "cmd-1",
status: "inProgress",
actionType: "execve",
}),
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.guardian",
phase: "completed",
}).data;
expect(completed.reviewId).toBe("review-1");
expect(completed.targetItemId).toBe("cmd-1");
expect(completed.decisionSource).toBe("agent");
expect(completed.status).toBe("approved");
expect(completed.riskLevel).toBe("low");
expect(completed.userAuthorization).toBe("high");
expect(completed.rationale).toBe("Benign local probe.");
expect(completed.actionType).toBe("execve");
data: expect.objectContaining({
phase: "completed",
reviewId: "review-1",
targetItemId: "cmd-1",
decisionSource: "agent",
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Benign local probe.",
actionType: "execve",
}),
});
expect(
projector.buildResult(buildEmptyToolTelemetry()).didSendDeterministicApprovalPrompt,
).toBe(false);
@@ -760,11 +637,17 @@ describe("CodexAppServerEventProjector", () => {
expect(onReasoningStream).toHaveBeenCalledWith({ text: "thinking" });
expect(onReasoningEnd).toHaveBeenCalledTimes(1);
expect(findPlanEventWithSteps(onAgentEvent, ["patch (in_progress)"]).steps).toEqual([
"patch (in_progress)",
]);
expect(findAgentEvent(onAgentEvent, { stream: "compaction", phase: "start" }).data.itemId).toBe(
"compact-1",
expect(onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "plan",
data: expect.objectContaining({ steps: ["patch (in_progress)"] }),
}),
);
expect(onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "compaction",
data: expect.objectContaining({ phase: "start", itemId: "compact-1" }),
}),
);
expect(result.toolMetas).toEqual([{ toolName: "sessions_send" }]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
@@ -774,7 +657,7 @@ describe("CodexAppServerEventProjector", () => {
]);
expect(JSON.stringify(result.messagesSnapshot[1])).toContain("Codex reasoning");
expect(JSON.stringify(result.messagesSnapshot[2])).toContain("Codex plan");
expect(requireRecord(result.itemLifecycle, "item lifecycle").compactionCount).toBe(1);
expect(result.itemLifecycle).toMatchObject({ compactionCount: 1 });
});
it("synthesizes normalized tool progress for Codex-native tool items", async () => {
@@ -816,63 +699,68 @@ describe("CodexAppServerEventProjector", () => {
}),
);
const itemStart = findAgentEvent(onAgentEvent, {
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "item",
phase: "start",
itemId: "cmd-1",
}).data;
expect(itemStart.kind).toBe("command");
expect(itemStart.name).toBe("bash");
expect(itemStart.suppressChannelProgress).toBe(true);
const toolStart = findAgentEvent(onAgentEvent, {
data: expect.objectContaining({
phase: "start",
kind: "command",
name: "bash",
itemId: "cmd-1",
suppressChannelProgress: true,
}),
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "tool",
phase: "start",
itemId: "cmd-1",
name: "bash",
}).data;
expect(toolStart.toolCallId).toBe("cmd-1");
expect(toolStart.args).toEqual({ command: "pnpm test extensions/codex", cwd: "/workspace" });
const toolResult = findAgentEvent(onAgentEvent, {
data: expect.objectContaining({
phase: "start",
name: "bash",
itemId: "cmd-1",
toolCallId: "cmd-1",
args: { command: "pnpm test extensions/codex", cwd: "/workspace" },
}),
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "tool",
phase: "result",
itemId: "cmd-1",
name: "bash",
}).data;
expect(toolResult.toolCallId).toBe("cmd-1");
expect(toolResult.status).toBe("completed");
expect(toolResult.isError).toBe(false);
const toolResultPayload = requireRecord(toolResult.result, "tool result payload");
expect(toolResultPayload.exitCode).toBe(0);
expect(toolResultPayload.durationMs).toBe(42);
data: expect.objectContaining({
phase: "result",
name: "bash",
itemId: "cmd-1",
toolCallId: "cmd-1",
status: "completed",
isError: false,
result: expect.objectContaining({ exitCode: 0, durationMs: 42 }),
}),
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
]);
const assistant = requireRecord(result.messagesSnapshot[1], "assistant tool call message");
expect(assistant.role).toBe("assistant");
const assistantContent = requireArray(assistant.content, "assistant content");
expect(assistantContent[0]).toEqual({
type: "toolCall",
id: "cmd-1",
name: "bash",
arguments: { command: "pnpm test extensions/codex", cwd: "/workspace" },
input: { command: "pnpm test extensions/codex", cwd: "/workspace" },
expect(result.messagesSnapshot[1]).toMatchObject({
role: "assistant",
content: [
{
type: "toolCall",
id: "cmd-1",
name: "bash",
arguments: { command: "pnpm test extensions/codex", cwd: "/workspace" },
},
],
});
expect(result.messagesSnapshot[2]).toMatchObject({
role: "toolResult",
toolCallId: "cmd-1",
toolName: "bash",
isError: false,
content: [
expect.objectContaining({
type: "toolResult",
toolCallId: "cmd-1",
content: "ok",
}),
],
});
const toolResultMessage = requireRecord(result.messagesSnapshot[2], "tool result message");
expect(toolResultMessage.role).toBe("toolResult");
expect(toolResultMessage.toolCallId).toBe("cmd-1");
expect(toolResultMessage.toolName).toBe("bash");
expect(toolResultMessage.isError).toBe(false);
const toolResultContent = requireArray(toolResultMessage.content, "tool result content");
const toolResultContentItem = requireRecord(toolResultContent[0], "tool result content item");
expect(toolResultContentItem.type).toBe("toolResult");
expect(toolResultContentItem.id).toBe("cmd-1");
expect(toolResultContentItem.name).toBe("bash");
expect(toolResultContentItem.toolName).toBe("bash");
expect(toolResultContentItem.toolCallId).toBe("cmd-1");
expect(toolResultContentItem.content).toBe("ok");
});
it("records dynamic OpenClaw tool calls in mirrored transcript snapshots", async () => {
@@ -899,30 +787,30 @@ describe("CodexAppServerEventProjector", () => {
"toolResult",
"assistant",
]);
const assistant = requireRecord(result.messagesSnapshot[1], "assistant tool call message");
expect(assistant.role).toBe("assistant");
expect(requireArray(assistant.content, "assistant content")[0]).toEqual({
type: "toolCall",
id: "call-browser-1",
name: "browser",
arguments: { action: "open", url: "http://127.0.0.1:3000" },
input: { action: "open", url: "http://127.0.0.1:3000" },
expect(result.messagesSnapshot[1]).toMatchObject({
role: "assistant",
content: [
{
type: "toolCall",
id: "call-browser-1",
name: "browser",
arguments: { action: "open", url: "http://127.0.0.1:3000" },
},
],
});
expect(result.messagesSnapshot[2]).toMatchObject({
role: "toolResult",
toolCallId: "call-browser-1",
toolName: "browser",
isError: false,
content: [
expect.objectContaining({
type: "toolResult",
toolCallId: "call-browser-1",
content: "opened",
}),
],
});
const toolResultMessage = requireRecord(result.messagesSnapshot[2], "tool result message");
expect(toolResultMessage.role).toBe("toolResult");
expect(toolResultMessage.toolCallId).toBe("call-browser-1");
expect(toolResultMessage.toolName).toBe("browser");
expect(toolResultMessage.isError).toBe(false);
const toolResultContent = requireRecord(
requireArray(toolResultMessage.content, "tool result content")[0],
"tool result content item",
);
expect(toolResultContent.type).toBe("toolResult");
expect(toolResultContent.id).toBe("call-browser-1");
expect(toolResultContent.name).toBe("browser");
expect(toolResultContent.toolName).toBe("browser");
expect(toolResultContent.toolCallId).toBe("call-browser-1");
expect(toolResultContent.content).toBe("opened");
});
it("marks declined Codex-native tool results as non-success", async () => {
@@ -947,24 +835,28 @@ describe("CodexAppServerEventProjector", () => {
}),
);
const itemEnd = findAgentEvent(onAgentEvent, {
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "item",
phase: "end",
itemId: "cmd-declined",
}).data;
expect(itemEnd.kind).toBe("command");
expect(itemEnd.name).toBe("bash");
expect(itemEnd.status).toBe("blocked");
expect(itemEnd.suppressChannelProgress).toBe(true);
const toolResult = findAgentEvent(onAgentEvent, {
data: expect.objectContaining({
phase: "end",
kind: "command",
name: "bash",
itemId: "cmd-declined",
status: "blocked",
suppressChannelProgress: true,
}),
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "tool",
phase: "result",
itemId: "cmd-declined",
name: "bash",
}).data;
expect(toolResult.toolCallId).toBe("cmd-declined");
expect(toolResult.status).toBe("blocked");
expect(toolResult.isError).toBe(true);
data: expect.objectContaining({
phase: "result",
name: "bash",
itemId: "cmd-declined",
toolCallId: "cmd-declined",
status: "blocked",
isError: true,
}),
});
});
it("leaves Codex dynamic tool item progress to item/tool/call normalization", async () => {
@@ -987,23 +879,23 @@ describe("CodexAppServerEventProjector", () => {
}),
);
const itemStart = findAgentEvent(onAgentEvent, {
stream: "item",
phase: "start",
name: "message",
}).data;
expect(itemStart.kind).toBe("tool");
expect(itemStart.suppressChannelProgress).toBe(true);
const calls = (onAgentEvent as { mock: { calls: unknown[][] } }).mock.calls;
const toolStart = calls.some((call) => {
const event = requireRecord(call[0], "agent event");
if (event.stream !== "tool") {
return false;
}
const data = requireRecord(event.data, "agent event data");
return data.phase === "start" && data.name === "message";
});
expect(toolStart).toBe(false);
expect(onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "item",
data: expect.objectContaining({
phase: "start",
kind: "tool",
name: "message",
suppressChannelProgress: true,
}),
}),
);
expect(onAgentEvent).not.toHaveBeenCalledWith(
expect.objectContaining({
stream: "tool",
data: expect.objectContaining({ phase: "start", name: "message" }),
}),
);
});
it("emits verbose tool summaries through onToolResult", async () => {
@@ -1255,10 +1147,12 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(findAgentEvent(onAgentEvent, { stream: "plan" }).data.steps).toEqual([
"step one",
"step two",
]);
expect(onAgentEvent).toHaveBeenCalledWith(
expect.objectContaining({
stream: "plan",
data: expect.objectContaining({ steps: ["step one", "step two"] }),
}),
);
expect(result.assistantTexts).toEqual(["final answer"]);
expect(JSON.stringify(result.messagesSnapshot)).toContain("Codex plan");
});
@@ -1279,33 +1173,28 @@ describe("CodexAppServerEventProjector", () => {
);
expect(openSpy).not.toHaveBeenCalled();
const beforePayload = requireRecord(
mockCallArg(beforeCompaction, 0, 0, "beforeCompaction"),
"before payload",
expect(beforeCompaction).toHaveBeenCalledWith(
expect.objectContaining({
messageCount: 1,
sessionFile: expect.stringContaining("session.jsonl"),
messages: [expect.objectContaining({ role: "assistant" })],
}),
expect.objectContaining({
runId: "run-1",
sessionId: "session-1",
}),
);
expect(beforePayload.messageCount).toBe(1);
expect(String(beforePayload.sessionFile)).toContain("session.jsonl");
const beforeMessages = requireArray(beforePayload.messages, "before messages");
expect(requireRecord(beforeMessages[0], "before message").role).toBe("assistant");
const beforeContext = requireRecord(
mockCallArg(beforeCompaction, 0, 1, "beforeCompaction"),
"before context",
expect(afterCompaction).toHaveBeenCalledWith(
expect.objectContaining({
messageCount: 1,
compactedCount: -1,
sessionFile: expect.stringContaining("session.jsonl"),
}),
expect.objectContaining({
runId: "run-1",
sessionId: "session-1",
}),
);
expect(beforeContext.runId).toBe("run-1");
expect(beforeContext.sessionId).toBe("session-1");
const afterPayload = requireRecord(
mockCallArg(afterCompaction, 0, 0, "afterCompaction"),
"after payload",
);
expect(afterPayload.messageCount).toBe(1);
expect(afterPayload.compactedCount).toBe(-1);
expect(String(afterPayload.sessionFile)).toContain("session.jsonl");
const afterContext = requireRecord(
mockCallArg(afterCompaction, 0, 1, "afterCompaction"),
"after context",
);
expect(afterContext.runId).toBe("run-1");
expect(afterContext.sessionId).toBe("session-1");
});
it("projects codex hook started and completed notifications into agent events", async () => {
@@ -1347,24 +1236,28 @@ describe("CodexAppServerEventProjector", () => {
}),
);
const started = findAgentEvent(onAgentEvent, {
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.hook",
phase: "started",
}).data;
expect(started.threadId).toBe(THREAD_ID);
expect(started.turnId).toBe(TURN_ID);
expect(started.hookRunId).toBe("hook-1");
expect(started.eventName).toBe("preToolUse");
expect(started.status).toBe("running");
const completed = findAgentEvent(onAgentEvent, {
data: expect.objectContaining({
phase: "started",
threadId: THREAD_ID,
turnId: TURN_ID,
hookRunId: "hook-1",
eventName: "preToolUse",
status: "running",
}),
});
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.hook",
phase: "completed",
}).data;
expect(completed.hookRunId).toBe("hook-1");
expect(completed.status).toBe("blocked");
expect(completed.statusMessage).toBe("blocked by hook");
expect(completed.durationMs).toBe(42);
expect(completed.entries).toEqual([{ kind: "stderr", text: "blocked" }]);
data: expect.objectContaining({
phase: "completed",
hookRunId: "hook-1",
status: "blocked",
statusMessage: "blocked by hook",
durationMs: 42,
entries: [{ kind: "stderr", text: "blocked" }],
}),
});
});
it("projects thread-scoped codex hook notifications that omit a turn id", async () => {
@@ -1392,14 +1285,16 @@ describe("CodexAppServerEventProjector", () => {
},
});
const started = findAgentEvent(onAgentEvent, {
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "codex_app_server.hook",
phase: "started",
}).data;
expect(started.threadId).toBe(THREAD_ID);
expect(started.turnId).toBeNull();
expect(started.hookRunId).toBe("hook-thread-1");
expect(started.eventName).toBe("sessionStart");
expect(started.scope).toBe("thread");
data: expect.objectContaining({
phase: "started",
threadId: THREAD_ID,
turnId: null,
hookRunId: "hook-thread-1",
eventName: "sessionStart",
scope: "thread",
}),
});
});
});

View File

@@ -115,7 +115,6 @@ export class CodexAppServerEventProjector {
private readonly toolTranscriptMessages: AgentMessage[] = [];
private readonly toolTranscriptCallIds = new Set<string>();
private readonly toolTranscriptResultIds = new Set<string>();
private readonly nativeGeneratedMediaUrls = new Set<string>();
private assistantStarted = false;
private reasoningStarted = false;
private reasoningEnded = false;
@@ -295,7 +294,7 @@ export class CodexAppServerEventProjector {
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
toolMediaUrls: this.buildToolMediaUrls(toolTelemetry),
toolMediaUrls: toolTelemetry.toolMediaUrls,
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,
cloudCodeAssistFormatError: false,
@@ -463,7 +462,6 @@ export class CodexAppServerEventProjector {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
}
this.recordNativeGeneratedMedia(item);
if (item?.type === "plan" && typeof item.text === "string" && item.text) {
this.planTextByItem.set(item.id, item.text);
this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(item.text) });
@@ -599,7 +597,6 @@ export class CodexAppServerEventProjector {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
}
this.recordNativeGeneratedMedia(item);
if (item.type === "plan" && typeof item.text === "string" && item.text) {
this.planTextByItem.set(item.id, item.text);
this.emitPlanUpdate({ explanation: undefined, steps: splitPlanText(item.text) });
@@ -675,28 +672,6 @@ export class CodexAppServerEventProjector {
this.assistantTextByItem.set(itemId, text);
}
private recordNativeGeneratedMedia(item: CodexThreadItem | undefined): void {
if (item?.type !== "imageGeneration") {
return;
}
const savedPath = readItemString(item, "savedPath")?.trim();
if (savedPath) {
this.nativeGeneratedMediaUrls.add(savedPath);
}
}
private buildToolMediaUrls(toolTelemetry: CodexAppServerToolTelemetry): string[] | undefined {
const mediaUrls = new Set(
toolTelemetry.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? [],
);
if ((toolTelemetry.messagingToolSentMediaUrls?.length ?? 0) === 0) {
for (const mediaUrl of this.nativeGeneratedMediaUrls) {
mediaUrls.add(mediaUrl);
}
}
return mediaUrls.size > 0 ? [...mediaUrls] : toolTelemetry.toolMediaUrls;
}
private async maybeEndReasoning(): Promise<void> {
if (!this.reasoningStarted || this.reasoningEnded) {
return;
@@ -1041,7 +1016,6 @@ export class CodexAppServerEventProjector {
}
private createToolCallMessage(params: ToolTranscriptCallInput): AgentMessage {
const args = normalizeToolTranscriptArguments(params.arguments);
return {
role: "assistant",
content: [
@@ -1049,8 +1023,7 @@ export class CodexAppServerEventProjector {
type: "toolCall",
id: params.id,
name: params.name,
arguments: args,
input: args,
arguments: normalizeToolTranscriptArguments(params.arguments),
},
],
api: this.params.model.api ?? "openai-codex-responses",
@@ -1072,9 +1045,6 @@ export class CodexAppServerEventProjector {
content: [
{
type: "toolResult",
id: params.id,
name: params.name,
toolName: params.name,
toolCallId: params.id,
toolUseId: params.id,
tool_use_id: params.id,

View File

@@ -28,10 +28,7 @@ import {
resolveCodexAppServerHomeDir,
} from "./auth-bridge.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import {
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
createCodexDynamicToolBridge,
} from "./dynamic-tools.js";
import { CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE } from "./dynamic-tools.js";
import * as elicitationBridge from "./elicitation-bridge.js";
import type { CodexServerNotification } from "./protocol.js";
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
@@ -636,44 +633,6 @@ describe("runCodexAppServerAttempt", () => {
}
});
it("does not expose OpenClaw Tool Search controls through Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.config = {
tools: {
toolSearch: true,
},
};
const sandboxSessionKey = params.sessionKey;
if (!sandboxSessionKey) {
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
}
const tools = await __testing.buildDynamicTools({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey,
sandbox: null as never,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
});
const bridge = createCodexDynamicToolBridge({
tools,
signal: new AbortController().signal,
loading: "searchable",
});
const dynamicToolNames = bridge.specs.map((tool) => tool.name);
expect(dynamicToolNames).not.toEqual(
expect.arrayContaining(["tool_search_code", "tool_search", "tool_describe", "tool_call"]),
);
});
it("normalizes Codex dynamic toolsAllow entries before filtering", () => {
const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name }));
@@ -734,89 +693,6 @@ describe("runCodexAppServerAttempt", () => {
expect(heartbeat?.deferLoading).toBe(true);
});
it("keeps searchable Codex dynamic tools canonical in mirrored transcript snapshots", async () => {
__testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("wiki_status"),
]);
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["wiki_status"];
const run = runCodexAppServerAttempt(params, {
pluginConfig: {
codexDynamicToolsLoading: "searchable",
appServer: { mode: "yolo" },
},
});
await harness.waitForMethod("turn/start", 120_000);
const toolResult = (await harness.handleServerRequest({
id: "request-tool-wiki-status",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-wiki-status-1",
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
tool: "wiki_status",
arguments: { topic: "README.md" },
},
})) as {
contentItems?: Array<{ text?: string; type?: string }>;
success?: boolean;
};
expect(toolResult).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "wiki_status done" }],
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
]);
expect(result.messagesSnapshot[1]).toMatchObject({
role: "assistant",
content: [
{
type: "toolCall",
id: "call-wiki-status-1",
name: "wiki_status",
arguments: { topic: "README.md" },
input: { topic: "README.md" },
},
],
});
expect(result.messagesSnapshot[2]).toMatchObject({
role: "toolResult",
toolCallId: "call-wiki-status-1",
toolName: "wiki_status",
isError: false,
content: [
expect.objectContaining({
type: "toolResult",
id: "call-wiki-status-1",
name: "wiki_status",
toolName: "wiki_status",
toolCallId: "call-wiki-status-1",
toolUseId: "call-wiki-status-1",
tool_use_id: "call-wiki-status-1",
content: "wiki_status done",
}),
],
});
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("tool_search");
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("function_call_output");
});
it("passes the live run session key to Codex dynamic tools when sandbox policy uses another key", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
@@ -2673,43 +2549,6 @@ describe("runCodexAppServerAttempt", () => {
expect(result.timedOut).toBe(false);
});
it("surfaces Codex-native image generation saved paths as reply media", async () => {
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [
{
type: "imageGeneration",
id: "ig_123",
status: "completed",
revisedPrompt: "A tiny blue square",
result: "Zm9v",
savedPath: "/tmp/codex-home/generated_images/session-1/ig_123.png",
},
],
},
},
});
await expect(run).resolves.toMatchObject({
assistantTexts: [],
toolMediaUrls: ["/tmp/codex-home/generated_images/session-1/ig_123.png"],
});
});
it("does not complete on unscoped turn/completed notifications", async () => {
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(

View File

@@ -82,37 +82,6 @@ function createTestThreadBindingManager(
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function expectFields(
value: unknown,
label: string,
fields: Record<string, unknown>,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expected] of Object.entries(fields)) {
expect(record[key]).toEqual(expected);
}
return record;
}
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`Expected ${label} mock calls`);
}
const call = calls[callIndex];
if (!call) {
throw new Error(`Expected ${label} call ${callIndex + 1}`);
}
return call[argIndex];
}
describe("thread binding lifecycle", () => {
beforeEach(() => {
__testing.resetThreadBindingsForTests();
@@ -319,7 +288,7 @@ describe("thread binding lifecycle", () => {
webhookToken: "tok-1",
introText: "intro",
});
expectFields(binding, "binding", {
expect(binding).toMatchObject({
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
});
@@ -361,7 +330,7 @@ describe("thread binding lifecycle", () => {
webhookId: "wh-1",
webhookToken: "tok-1",
});
expectFields(binding, "binding", {
expect(binding).toMatchObject({
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
});
@@ -390,7 +359,7 @@ describe("thread binding lifecycle", () => {
await vi.advanceTimersByTimeAsync(120_000);
await __testing.runThreadBindingSweepForAccount("default");
expectFields(requireBinding(manager, "thread-1"), "thread binding", {
expect(requireBinding(manager, "thread-1")).toMatchObject({
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
webhookId: "wh-1",
@@ -552,11 +521,11 @@ describe("thread binding lifecycle", () => {
webhookToken: "tok-1",
});
expectFields(rebound, "rebound binding", {
expect(rebound).toMatchObject({
idleTimeoutMs: 2 * 60 * 60 * 1000,
maxAgeMs: 3 * 60 * 60 * 1000,
});
expectFields(requireBinding(manager, "thread-1"), "thread binding", {
expect(requireBinding(manager, "thread-1")).toMatchObject({
idleTimeoutMs: 2 * 60 * 60 * 1000,
maxAgeMs: 3 * 60 * 60 * 1000,
});
@@ -597,7 +566,7 @@ describe("thread binding lifecycle", () => {
await vi.advanceTimersByTimeAsync(240_000);
await __testing.runThreadBindingSweepForAccount("default");
expectFields(requireBinding(manager, "thread-1"), "thread binding", {
expect(requireBinding(manager, "thread-1")).toMatchObject({
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
idleTimeoutMs: 0,
@@ -661,7 +630,7 @@ describe("thread binding lifecycle", () => {
await vi.advanceTimersByTimeAsync(120_000);
await __testing.runThreadBindingSweepForAccount("default");
expectFields(requireBinding(manager, "thread-2"), "thread binding", {
expect(requireBinding(manager, "thread-2")).toMatchObject({
threadId: "thread-2",
targetSessionKey: "agent:main:subagent:second",
});
@@ -693,7 +662,7 @@ describe("thread binding lifecycle", () => {
vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z"));
const touched = manager.touchThread({ threadId: "thread-1", persist: false });
expectFields(touched, "touched binding", {
expect(touched).toMatchObject({
threadId: "thread-1",
lastActivityAt: new Date("2026-02-20T00:00:30.000Z").getTime(),
});
@@ -786,7 +755,7 @@ describe("thread binding lifecycle", () => {
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
});
expectFields(first, "first binding", {
expect(first).toMatchObject({
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child-1",
});
@@ -804,7 +773,7 @@ describe("thread binding lifecycle", () => {
targetSessionKey: "agent:main:subagent:child-2",
agentId: "main",
});
expectFields(second, "second binding", {
expect(second).toMatchObject({
webhookId: "wh-created",
webhookToken: "tok-created",
});
@@ -840,25 +809,15 @@ describe("thread binding lifecycle", () => {
agentId: "main",
});
expectFields(childBinding, "child binding", {
expect(childBinding).toMatchObject({
threadId: "thread-created-2",
targetSessionKey: "agent:main:subagent:child-2",
});
expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1);
expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1");
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
"thread options",
{
autoArchiveMinutes: 60,
},
);
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
"thread context",
{
accountId: "default",
},
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"parent-1",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "default" }),
);
expect(manager.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:parent");
expect(manager.getByThreadId("thread-created-2")?.targetSessionKey).toBe(
@@ -893,22 +852,12 @@ describe("thread binding lifecycle", () => {
agentId: "main",
});
expectFields(childBinding, "child binding", { channelId: "parent-1" });
expect(childBinding).toMatchObject({ channelId: "parent-1" });
expect(hoisted.restGet).toHaveBeenCalledTimes(1);
expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1");
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
"thread options",
{
autoArchiveMinutes: 60,
},
);
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
"thread context",
{
accountId: "default",
},
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"parent-1",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "default" }),
);
});
@@ -945,14 +894,14 @@ describe("thread binding lifecycle", () => {
agentId: "main",
});
expectFields(childBinding, "child binding", {
expect(childBinding).toMatchObject({
threadId: "thread-created-runtime",
targetSessionKey: "agent:main:subagent:child-runtime",
});
const firstClientArgs = hoisted.createDiscordRestClient.mock.calls[0]?.[0] as
| { accountId?: string; token?: string }
| undefined;
expectFields(firstClientArgs, "first client args", {
expect(firstClientArgs).toMatchObject({
accountId: "runtime",
token: "runtime-token",
});
@@ -998,7 +947,7 @@ describe("thread binding lifecycle", () => {
agentId: "main",
});
expectFields(bound, "bound thread", {
expect(bound).toMatchObject({
threadId: "thread-created-runtime-cfg",
targetSessionKey: "agent:main:subagent:runtime-cfg",
});
@@ -1058,27 +1007,14 @@ describe("thread binding lifecycle", () => {
agentId: "main",
});
expectFields(bound, "bound thread", {
expect(bound).toMatchObject({
threadId: "thread-created-token-refresh",
targetSessionKey: "agent:main:subagent:token-refresh",
});
expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe(
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"parent-runtime",
);
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
"thread options",
{
autoArchiveMinutes: 60,
},
);
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
"thread context",
{
accountId: "runtime",
token: "token-new",
},
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "runtime", token: "token-new" }),
);
const usedTokenNew = hoisted.createDiscordRestClient.mock.calls.some(
(call) => (call?.[0] as { token?: string } | undefined)?.token === "token-new",
@@ -1116,31 +1052,17 @@ describe("thread binding lifecycle", () => {
},
});
const boundConversation = requireRecord(
requireRecord(bound, "bound session").conversation,
"bound conversation",
);
expectFields(boundConversation, "bound conversation", {
channel: "discord",
accountId: "default",
conversationId: "thread-created-parent-normalized",
});
expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe(
"1491611525914558667",
);
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
"thread options",
{
autoArchiveMinutes: 60,
},
);
expectFields(
mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
"thread context",
{
expect(bound).toMatchObject({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-created-parent-normalized",
},
});
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"1491611525914558667",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "default" }),
);
expect(hoisted.restGet).not.toHaveBeenCalled();
});
@@ -1172,26 +1094,22 @@ describe("thread binding lifecycle", () => {
},
});
const boundConversation = requireRecord(
requireRecord(bound, "bound session").conversation,
"bound conversation",
);
expectFields(boundConversation, "bound conversation", {
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558667",
expect(bound).toMatchObject({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558667",
},
});
expectFields(
expect(
service.resolveByConversation({
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558667",
}),
"resolved binding",
{
targetSessionKey: "agent:codex:acp:current-channel",
},
);
).toMatchObject({
targetSessionKey: "agent:codex:acp:current-channel",
});
expect(
service.resolveByConversation({
channel: "discord",
@@ -1231,27 +1149,25 @@ describe("thread binding lifecycle", () => {
},
});
const boundConversation = requireRecord(
requireRecord(bound, "bound session").conversation,
"bound conversation",
);
expectFields(boundConversation, "bound conversation", {
channel: "discord",
accountId: "default",
conversationId: "user:1177378744822943744",
parentConversationId: "user:1177378744822943744",
expect(bound).toMatchObject({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "user:1177378744822943744",
parentConversationId: "user:1177378744822943744",
},
});
const resolved = requireRecord(
expect(
getSessionBindingService().resolveByConversation({
channel: "discord",
accountId: "default",
conversationId: "user:1177378744822943744",
}),
"resolved binding",
);
expect(requireRecord(resolved.conversation, "resolved conversation").conversationId).toBe(
"user:1177378744822943744",
);
).toMatchObject({
conversation: {
conversationId: "user:1177378744822943744",
},
});
expect(hoisted.restGet).not.toHaveBeenCalled();
expect(hoisted.restPost).not.toHaveBeenCalled();
});
@@ -1297,21 +1213,21 @@ describe("thread binding lifecycle", () => {
},
});
const resolved = requireRecord(
expect(
getSessionBindingService().resolveByConversation({
channel: "discord",
accountId: "default",
conversationId: "user:1177378744822943744",
}),
"resolved binding",
);
expectFields(requireRecord(resolved.metadata, "resolved metadata"), "resolved metadata", {
pluginBindingOwner: "plugin",
pluginId: "openclaw-codex-app-server",
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
agentId: "codex",
boundBy: "system",
label: "codex-dm",
).toMatchObject({
metadata: expect.objectContaining({
pluginBindingOwner: "plugin",
pluginId: "openclaw-codex-app-server",
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
agentId: "codex",
boundBy: "system",
label: "codex-dm",
}),
});
expect(hoisted.restGet).not.toHaveBeenCalled();
expect(hoisted.restPost).not.toHaveBeenCalled();
@@ -1430,13 +1346,13 @@ describe("thread binding lifecycle", () => {
expect(result.checked).toBe(2);
expect(result.removed).toBe(1);
expect(result.staleSessionKeys).toContain("agent:codex:acp:stale");
expectFields(requireBinding(manager, "thread-acp-healthy"), "healthy binding", {
expect(requireBinding(manager, "thread-acp-healthy")).toMatchObject({
threadId: "thread-acp-healthy",
targetKind: "acp",
targetSessionKey: "agent:codex:acp:healthy",
});
expect(manager.getByThreadId("thread-acp-stale")).toBeUndefined();
expectFields(requireBinding(manager, "thread-subagent"), "subagent binding", {
expect(requireBinding(manager, "thread-subagent")).toMatchObject({
threadId: "thread-subagent",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
@@ -1482,7 +1398,7 @@ describe("thread binding lifecycle", () => {
expect(result.checked).toBe(1);
expect(result.removed).toBe(0);
expect(result.staleSessionKeys).toStrictEqual([]);
expectFields(requireBinding(manager, "thread-acp-uncertain"), "uncertain binding", {
expect(requireBinding(manager, "thread-acp-uncertain")).toMatchObject({
threadId: "thread-acp-uncertain",
targetKind: "acp",
targetSessionKey: "agent:codex:acp:uncertain",
@@ -1521,16 +1437,12 @@ describe("thread binding lifecycle", () => {
expect(result.checked).toBe(0);
expect(result.removed).toBe(0);
expect(result.staleSessionKeys).toStrictEqual([]);
const binding = expectFields(
manager.getByThreadId("user:1177378744822943744"),
"plugin direct binding",
{
threadId: "user:1177378744822943744",
expect(manager.getByThreadId("user:1177378744822943744")).toMatchObject({
threadId: "user:1177378744822943744",
metadata: {
pluginBindingOwner: "plugin",
pluginId: "openclaw-codex-app-server",
},
);
expectFields(requireRecord(binding.metadata, "binding metadata"), "binding metadata", {
pluginBindingOwner: "plugin",
pluginId: "openclaw-codex-app-server",
});
});
@@ -1619,15 +1531,11 @@ describe("thread binding lifecycle", () => {
expect(result.checked).toBe(1);
expect(result.removed).toBe(0);
expect(result.staleSessionKeys).toStrictEqual([]);
expectFields(
requireBinding(manager, "thread-acp-running-uncertain"),
"running uncertain binding",
{
threadId: "thread-acp-running-uncertain",
targetKind: "acp",
targetSessionKey: "agent:codex:acp:running-uncertain",
},
);
expect(requireBinding(manager, "thread-acp-running-uncertain")).toMatchObject({
threadId: "thread-acp-running-uncertain",
targetKind: "acp",
targetSessionKey: "agent:codex:acp:running-uncertain",
});
});
it("keeps ACP bindings in stored error state when no explicit stale probe verdict exists", async () => {
@@ -1670,7 +1578,7 @@ describe("thread binding lifecycle", () => {
expect(result.checked).toBe(1);
expect(result.removed).toBe(0);
expect(result.staleSessionKeys).toStrictEqual([]);
expectFields(requireBinding(manager, "thread-acp-error"), "error binding", {
expect(requireBinding(manager, "thread-acp-error")).toMatchObject({
threadId: "thread-acp-error",
targetKind: "acp",
targetSessionKey: "agent:codex:acp:error",

View File

@@ -1,36 +1,19 @@
import { Readable } from "node:stream";
import { describe, expect, it } from "vitest";
import { decodeOpusStream, resolveOpusDecoderPreference } from "./audio.js";
import { decodeOpusStream } from "./audio.js";
describe("discord voice opus decoder selection", () => {
it("defaults to the pure-JS opusscript decoder", async () => {
it("prefers the pure-JS opusscript decoder over optional native opus", async () => {
const verbose: string[] = [];
const warnings: string[] = [];
const previousPreference = process.env.OPENCLAW_DISCORD_OPUS_DECODER;
delete process.env.OPENCLAW_DISCORD_OPUS_DECODER;
try {
const decoded = await decodeOpusStream(Readable.from([]), {
onVerbose: (message) => verbose.push(message),
onWarn: (message) => warnings.push(message),
});
const decoded = await decodeOpusStream(Readable.from([]), {
onVerbose: (message) => verbose.push(message),
onWarn: (message) => warnings.push(message),
});
expect(decoded.length).toBe(0);
expect(verbose).toContain("opus decoder: opusscript");
expect(warnings).toEqual([]);
} finally {
if (previousPreference === undefined) {
delete process.env.OPENCLAW_DISCORD_OPUS_DECODER;
} else {
process.env.OPENCLAW_DISCORD_OPUS_DECODER = previousPreference;
}
}
});
it("requires an explicit preference for native opus", () => {
expect(resolveOpusDecoderPreference()).toBe("opusscript");
expect(resolveOpusDecoderPreference("opusscript")).toBe("opusscript");
expect(resolveOpusDecoderPreference("native")).toBe("native");
expect(resolveOpusDecoderPreference("@discordjs/opus")).toBe("native");
expect(decoded.length).toBe(0);
expect(verbose).toContain("opus decoder: opusscript");
expect(warnings).toEqual([]);
});
});

View File

@@ -21,8 +21,6 @@ type OpusDecoderFactory = {
name: string;
};
type OpusDecoderPreference = "native" | "opusscript";
let warnedOpusMissing = false;
let cachedOpusDecoderFactory: OpusDecoderFactory | null | "unresolved" = "unresolved";
@@ -49,34 +47,32 @@ function buildWavBuffer(pcm: Buffer): Buffer {
function resolveOpusDecoderFactory(params: {
onWarn: (message: string) => void;
}): OpusDecoderFactory | null {
const nativeFactory: OpusDecoderFactory = {
name: "@discordjs/opus",
load: () => {
const DiscordOpus = require("@discordjs/opus") as {
OpusEncoder: new (
sampleRate: number,
channels: number,
) => {
decode: (buffer: Buffer) => Buffer;
const factories: OpusDecoderFactory[] = [
{
name: "opusscript",
load: () => {
const OpusScript = require("opusscript") as {
new (sampleRate: number, channels: number, application: number): OpusDecoder;
Application: { AUDIO: number };
};
};
return new DiscordOpus.OpusEncoder(SAMPLE_RATE, CHANNELS);
return new OpusScript(SAMPLE_RATE, CHANNELS, OpusScript.Application.AUDIO);
},
},
};
const opusscriptFactory: OpusDecoderFactory = {
name: "opusscript",
load: () => {
const OpusScript = require("opusscript") as {
new (sampleRate: number, channels: number, application: number): OpusDecoder;
Application: { AUDIO: number };
};
return new OpusScript(SAMPLE_RATE, CHANNELS, OpusScript.Application.AUDIO);
{
name: "@discordjs/opus",
load: () => {
const DiscordOpus = require("@discordjs/opus") as {
OpusEncoder: new (
sampleRate: number,
channels: number,
) => {
decode: (buffer: Buffer) => Buffer;
};
};
return new DiscordOpus.OpusEncoder(SAMPLE_RATE, CHANNELS);
},
},
};
const factories: OpusDecoderFactory[] =
resolveOpusDecoderPreference() === "native"
? [nativeFactory, opusscriptFactory]
: [opusscriptFactory, nativeFactory];
];
const failures: string[] = [];
for (const factory of factories) {
@@ -97,16 +93,6 @@ function resolveOpusDecoderFactory(params: {
return null;
}
export function resolveOpusDecoderPreference(
value = process.env.OPENCLAW_DISCORD_OPUS_DECODER,
): OpusDecoderPreference {
const normalized = value?.trim().toLowerCase();
if (normalized === "native" || normalized === "@discordjs/opus") {
return "native";
}
return "opusscript";
}
function getOrCreateOpusDecoderFactory(params: {
onWarn: (message: string) => void;
}): OpusDecoderFactory | null {

View File

@@ -1,4 +1,4 @@
import { PassThrough, type Readable } from "node:stream";
import type { Readable } from "node:stream";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType } from "../internal/discord.js";
import { createVoiceCaptureState } from "./capture-state.js";
@@ -1223,70 +1223,9 @@ describe("DiscordVoiceManager", () => {
);
});
it("skips incomplete and non-actionable forced agent-proxy transcripts", async () => {
agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "valid answer" }] });
const manager = createManager({
groupPolicy: "open",
voice: {
enabled: true,
mode: "agent-proxy",
realtime: { provider: "openai" },
},
});
await manager.join({ guildId: "g1", channelId: "1001" });
const entry = getSessionEntry(manager) as {
realtime?: {
beginSpeakerTurn: (
context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
userId: string,
) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
};
};
const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
| {
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
}
| undefined;
const incompleteTurn = entry.realtime?.beginSpeakerTurn(
{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
"u-owner",
);
incompleteTurn?.sendInputAudio(Buffer.alloc(8));
bridgeParams?.onTranscript?.("user", "Get this working and...", true);
const closingTurn = entry.realtime?.beginSpeakerTurn(
{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
"u-owner",
);
closingTurn?.sendInputAudio(Buffer.alloc(8));
bridgeParams?.onTranscript?.("user", "I'll be right back. See you guys. Bye-bye.", true);
await new Promise((resolve) => setTimeout(resolve, 260));
expect(agentCommandMock).not.toHaveBeenCalled();
const validTurn = entry.realtime?.beginSpeakerTurn(
{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
"u-owner",
);
validTurn?.sendInputAudio(Buffer.alloc(8));
bridgeParams?.onTranscript?.("user", "ship it.", true);
await new Promise((resolve) => setTimeout(resolve, 260));
expect(agentCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("ship it.") }),
expect.anything(),
);
expect(realtimeSessionMock.sendUserMessage).toHaveBeenCalledWith(
expect.stringContaining("valid answer"),
);
});
it("queues forced agent-proxy answers until current realtime playback idles", async () => {
let resolveFirst: ((value: { payloads: Array<{ text: string }> }) => void) | undefined;
let resolveSecond: ((value: { payloads: Array<{ text: string }> }) => void) | undefined;
let resolveThird: ((value: { payloads: Array<{ text: string }> }) => void) | undefined;
agentCommandMock
.mockImplementationOnce(
() =>
@@ -1299,12 +1238,6 @@ describe("DiscordVoiceManager", () => {
new Promise<{ payloads: Array<{ text: string }> }>((resolve) => {
resolveSecond = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<{ payloads: Array<{ text: string }> }>((resolve) => {
resolveThird = resolve;
}),
);
const manager = createManager({
groupPolicy: "open",
@@ -1330,7 +1263,6 @@ describe("DiscordVoiceManager", () => {
const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
| {
audioSink?: { sendAudio: (audio: Buffer) => void };
onEvent?: (event: { direction: "server"; type: string }) => void;
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
}
| undefined;
@@ -1347,12 +1279,6 @@ describe("DiscordVoiceManager", () => {
);
secondTurn?.sendInputAudio(Buffer.alloc(8));
bridgeParams?.onTranscript?.("user", "second question", true);
const thirdTurn = entry.realtime?.beginSpeakerTurn(
{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
"u-owner",
);
thirdTurn?.sendInputAudio(Buffer.alloc(8));
bridgeParams?.onTranscript?.("user", "third question", true);
await new Promise((resolve) => setTimeout(resolve, 260));
resolveFirst?.({ payloads: [{ text: "first answer" }] });
@@ -1364,18 +1290,6 @@ describe("DiscordVoiceManager", () => {
bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
resolveSecond?.({ payloads: [{ text: "second answer" }] });
resolveThird?.({ payloads: [{ text: "third answer" }] });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(realtimeSessionMock.sendUserMessage).not.toHaveBeenCalledWith(
expect.stringContaining("second answer"),
);
expect(realtimeSessionMock.sendUserMessage).not.toHaveBeenCalledWith(
expect.stringContaining("third answer"),
);
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
const firstStream = createAudioResourceMock.mock.calls.at(-1)?.[0] as PassThrough | undefined;
await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(realtimeSessionMock.sendUserMessage).not.toHaveBeenCalledWith(
expect.stringContaining("second answer"),
@@ -1388,23 +1302,6 @@ describe("DiscordVoiceManager", () => {
expect(realtimeSessionMock.sendUserMessage).toHaveBeenCalledWith(
expect.stringContaining("second answer"),
);
expect(realtimeSessionMock.sendUserMessage).not.toHaveBeenCalledWith(
expect.stringContaining("third answer"),
);
bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
const secondStream = createAudioResourceMock.mock.calls.at(-1)?.[0] as PassThrough | undefined;
await vi.waitFor(() => expect(secondStream?.writableEnded).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(realtimeSessionMock.sendUserMessage).not.toHaveBeenCalledWith(
expect.stringContaining("third answer"),
);
idleHandler?.();
expect(realtimeSessionMock.sendUserMessage).toHaveBeenCalledWith(
expect.stringContaining("third answer"),
);
});
it("matches agent-proxy consult tool calls to the pending transcript", async () => {

View File

@@ -46,35 +46,6 @@ const DISCORD_REALTIME_LOG_PREVIEW_CHARS = 500;
const DISCORD_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS = 250;
const DISCORD_REALTIME_FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
const REALTIME_PCM16_BYTES_PER_SAMPLE = 2;
const DISCORD_REALTIME_FORCED_CONSULT_TRAILING_FRAGMENT_WORDS = new Set([
"a",
"about",
"an",
"and",
"as",
"at",
"because",
"but",
"by",
"for",
"from",
"in",
"of",
"on",
"or",
"so",
"that",
"the",
"then",
"to",
"with",
]);
const DISCORD_REALTIME_VERBOSE_OMITTED_EVENTS = new Set([
"conversation.output_audio.delta",
"input_audio_buffer.append",
"response.audio.delta",
"response.output_audio.delta",
]);
export type DiscordVoiceMode = "stt-tts" | "agent-proxy" | "bidi";
@@ -152,32 +123,6 @@ function formatRealtimeInterruptionLog(event: RealtimeVoiceBridgeEvent): string
return undefined;
}
function shouldLogRealtimeVerboseEvent(event: RealtimeVoiceBridgeEvent): boolean {
return !DISCORD_REALTIME_VERBOSE_OMITTED_EVENTS.has(event.type);
}
function classifySkippableForcedAgentProxyTranscript(text: string): string | undefined {
const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
if (!normalized) {
return "empty";
}
if (/(\.\.\.|…)\s*$/.test(normalized)) {
return "incomplete-transcript";
}
const lastWord = normalized.match(/[a-z']+$/)?.[0]?.replace(/^'+|'+$/g, "");
if (lastWord && DISCORD_REALTIME_FORCED_CONSULT_TRAILING_FRAGMENT_WORDS.has(lastWord)) {
return "trailing-fragment";
}
if (
!normalized.includes("?") &&
(/^(i'?ll|i will) be (right )?back\b/.test(normalized) ||
/\b(see you|bye(?:-bye)?|goodbye)\b/.test(normalized))
) {
return "non-actionable-closing";
}
return undefined;
}
function readProviderConfigString(
config: RealtimeVoiceProviderConfig,
key: string,
@@ -436,9 +381,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
onToolCall: (event, session) => this.handleToolCall(event, session),
onEvent: (event) => {
const detail = event.detail ? ` ${event.detail}` : "";
if (shouldLogRealtimeVerboseEvent(event)) {
logVoiceVerbose(`realtime ${event.direction}:${event.type}${detail}`);
}
logVoiceVerbose(`realtime ${event.direction}:${event.type}${detail}`);
const responseEnded =
event.direction === "server" &&
(event.type === "response.done" || event.type === "response.cancelled");
@@ -596,12 +539,6 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
return;
}
this.syncOutputAudioTimestamp();
if (this.outputStreamEnding) {
logVoiceVerbose(
`realtime output audio ignored after stream ending: guild ${this.params.entry.guildId} channel ${this.params.entry.channelId}`,
);
return;
}
const stream = this.ensureOutputStream();
if (this.exactSpeechResponseActive) {
this.exactSpeechAudioStarted = true;
@@ -617,7 +554,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
}
private ensureOutputStream(): PassThrough {
if (this.outputStream && !this.outputStream.destroyed && !this.outputStream.writableEnded) {
if (this.outputStream && !this.outputStream.destroyed) {
return this.outputStream;
}
const voiceSdk = loadDiscordVoiceSdk();
@@ -629,7 +566,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.logOutputAudioStopped("stream-close");
this.outputStream = null;
this.resetOutputAudioStats();
this.completeExactSpeechResponse("stream-close", { drain: false });
this.completeExactSpeechResponse("stream-close");
}
});
const resource = voiceSdk.createAudioResource(stream, {
@@ -692,15 +629,12 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.bridge?.sendUserMessage(buildDiscordSpeakExactUserMessage(text));
}
private completeExactSpeechResponse(reason: string, options?: { drain?: boolean }): void {
private completeExactSpeechResponse(reason: string): void {
if (!this.exactSpeechResponseActive && this.queuedExactSpeechMessages.length === 0) {
return;
}
this.exactSpeechResponseActive = false;
this.exactSpeechAudioStarted = false;
if (options?.drain === false) {
return;
}
this.drainQueuedExactSpeechMessages(reason);
}
@@ -877,13 +811,6 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
return;
}
const context = this.consumePendingSpeakerContext();
const skipReason = classifySkippableForcedAgentProxyTranscript(question);
if (skipReason) {
logger.info(
`discord voice: realtime forced agent consult skipped reason=${skipReason} chars=${question.length} speaker=${context?.speakerLabel ?? "unknown"} transcript=${formatRealtimeLogPreview(question)}`,
);
return;
}
if (!context) {
const recent = this.findRecentAgentProxyConsultContext(question);
if (recent) {

View File

@@ -123,51 +123,6 @@ describe("Feishu Card Action Handler", () => {
resetProcessedFeishuCardActionTokensForTests();
});
function mockCallArg(
mock: { mock: { calls: unknown[][] } },
index: number,
label: string,
): unknown {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`Expected ${label} call ${index + 1}`);
}
return call[0];
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function handleMessageEvent(callIndex = 0) {
const arg = requireRecord(
mockCallArg(vi.mocked(handleFeishuMessage), callIndex, "handleFeishuMessage"),
"handleFeishuMessage args",
);
return requireRecord(arg.event, "Feishu message event");
}
function handleMessage(callIndex = 0) {
return requireRecord(handleMessageEvent(callIndex).message, "Feishu message");
}
function sendMessageCall(callIndex = 0) {
return requireRecord(
mockCallArg(sendMessageFeishuMock, callIndex, "sendMessageFeishu"),
"sendMessageFeishu args",
);
}
function sendCardCall(callIndex = 0) {
return requireRecord(
mockCallArg(sendCardFeishuMock, callIndex, "sendCardFeishu"),
"sendCardFeishu args",
);
}
it("handles card action with text payload", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
@@ -178,9 +133,16 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const message = handleMessage();
expect(message.content).toBe('{"text":"/ping"}');
expect(message.chat_id).toBe("chat1");
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/ping"}',
chat_id: "chat1",
}),
}),
}),
);
});
it("handles card action with JSON object payload", async () => {
@@ -193,9 +155,16 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const message = handleMessage();
expect(message.content).toBe('{"text":"{\\"key\\":\\"val\\"}"}');
expect(message.chat_id).toBe("u123"); // Fallback to open_id
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"{\\"key\\":\\"val\\"}"}',
chat_id: "u123", // Fallback to open_id
}),
}),
}),
);
});
it("routes quick command actions with operator and conversation context", async () => {
@@ -207,15 +176,23 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const eventArg = handleMessageEvent();
const sender = requireRecord(eventArg.sender, "Feishu sender");
const senderId = requireRecord(sender.sender_id, "Feishu sender id");
expect(senderId.open_id).toBe("u123");
expect(senderId.user_id).toBe("uid1");
expect(senderId.union_id).toBe("un1");
const message = requireRecord(eventArg.message, "Feishu message");
expect(message.chat_id).toBe("chat1");
expect(message.content).toBe('{"text":"/help"}');
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
sender: expect.objectContaining({
sender_id: expect.objectContaining({
open_id: "u123",
user_id: "uid1",
union_id: "un1",
}),
}),
message: expect.objectContaining({
chat_id: "chat1",
content: '{"text":"/help"}',
}),
}),
}),
);
});
it("opens an approval card for metadata actions", async () => {
@@ -245,27 +222,39 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
const cardCall = sendCardCall();
expect(cardCall.to).toBe("chat:chat1");
expect(cardCall.accountId).toBe("main");
const card = requireRecord(cardCall.card, "Feishu card");
expect(requireRecord(card.config, "Feishu card config").width_mode).toBe("fill");
const header = requireRecord(card.header, "Feishu card header");
expect(requireRecord(header.title, "Feishu card title").content).toBe("Confirm action");
const body = requireRecord(card.body, "Feishu card body");
const elements = body.elements as Array<Record<string, unknown>>;
const actionElement = elements.find((element) => element.tag === "action");
if (!actionElement) {
throw new Error("Expected action element");
}
const actions = actionElement.actions as Array<Record<string, unknown>>;
const actionValue = requireRecord(actions[0]?.value, "Feishu approval action value");
const approvalContext = requireRecord(actionValue.c, "Feishu approval context");
expect(approvalContext.u).toBe("u123");
expect(approvalContext.h).toBe("chat1");
expect(approvalContext.t).toBe("group");
expect(approvalContext.s).toBe("agent:codex:feishu:chat:chat1");
expect(typeof approvalContext.e).toBe("number");
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
accountId: "main",
card: expect.objectContaining({
config: expect.objectContaining({
width_mode: "fill",
}),
header: expect.objectContaining({
title: expect.objectContaining({ content: "Confirm action" }),
}),
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expectFirstSentCardUsesFillWidthOnly(sendCardFeishuMock);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
@@ -279,7 +268,15 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleMessage().content).toBe('{"text":"/new"}');
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/new"}',
}),
}),
}),
);
});
it("safely rejects stale structured actions", async () => {
@@ -295,9 +292,12 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const sendMessage = sendMessageCall();
expect(sendMessage.to).toBe("chat:chat1");
expect(String(sendMessage.text)).toContain("expired");
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: expect.stringContaining("expired"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
@@ -312,7 +312,11 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
expect(String(sendMessageCall().text)).toContain("different user");
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("different user"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
@@ -333,9 +337,12 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const sendMessage = sendMessageCall();
expect(sendMessage.to).toBe("chat:chat1");
expect(sendMessage.text).toBe("Cancelled.");
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: "Cancelled.",
}),
);
});
it("preserves p2p callbacks for DM quick actions", async () => {
@@ -349,9 +356,16 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const message = handleMessage();
expect(message.chat_id).toBe("p2p-chat-1");
expect(message.chat_type).toBe("p2p");
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "p2p-chat-1",
chat_type: "p2p",
}),
}),
}),
);
});
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
@@ -370,9 +384,16 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
const message = handleMessage();
expect(message.chat_id).toBe("oc_dm_chat_123");
expect(message.chat_type).toBe("p2p");
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "oc_dm_chat_123",
chat_type: "p2p",
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
@@ -424,7 +445,15 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleMessage().chat_type).toBe("p2p");
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("falls back to p2p when Feishu chat API throws", async () => {
@@ -443,7 +472,15 @@ describe("Feishu Card Action Handler", () => {
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleMessage().chat_type).toBe("p2p");
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {

View File

@@ -59,36 +59,6 @@ function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[]
return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])];
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value;
}
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`Expected ${label} mock calls`);
}
const call = calls[callIndex];
if (!call) {
throw new Error(`Expected ${label} call ${callIndex + 1}`);
}
return call[argIndex];
}
function resultDetails(result: unknown) {
return requireRecord(requireRecord(result, "action result").details, "action result details");
}
afterAll(() => {
vi.doUnmock("./probe.js");
vi.doUnmock("./client.js");
@@ -123,16 +93,14 @@ describe("feishuPlugin.status.probeAccount", () => {
});
expect(probeFeishuMock).toHaveBeenCalledTimes(1);
const probeArgs = requireRecord(
mockCallArg(probeFeishuMock, 0, 0, "probeFeishu"),
"probe args",
expect(probeFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main",
appId: "cli_main",
appSecret: "secret_main",
}),
);
expect(probeArgs.accountId).toBe("main");
expect(probeArgs.appId).toBe("cli_main");
expect(probeArgs.appSecret).toBe("secret_main");
const resultRecord = requireRecord(result, "probe result");
expect(resultRecord.ok).toBe(true);
expect(resultRecord.appId).toBe("cli_main");
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});
@@ -163,13 +131,13 @@ describe("feishuPlugin.pairing.notifyApproval", () => {
accountId: "work",
});
const sendArgs = requireRecord(
mockCallArg(sendMessageFeishuMock, 0, 0, "sendMessageFeishu"),
"send args",
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
to: "ou_user",
accountId: "work",
}),
);
expect(sendArgs.cfg).toBe(cfg);
expect(sendArgs.to).toBe("ou_user");
expect(sendArgs.accountId).toBe("work");
});
});
@@ -343,10 +311,7 @@ describe("feishuPlugin actions", () => {
replyToMessageId: undefined,
replyInThread: false,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.messageId).toBe("om_sent");
expect(details.chatId).toBe("oc_group_1");
expect(result?.details).toMatchObject({ ok: true, messageId: "om_sent", chatId: "oc_group_1" });
});
it("renders presentation messages as cards", async () => {
@@ -366,33 +331,29 @@ describe("feishuPlugin actions", () => {
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
expect(sendCardArgs.cfg).toBe(cfg);
expect(sendCardArgs.to).toBe("chat:oc_group_1");
expect(sendCardArgs.accountId).toBeUndefined();
expect(sendCardArgs.replyToMessageId).toBeUndefined();
expect(sendCardArgs.replyInThread).toBe(false);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.schema).toBe("2.0");
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Status" },
template: "blue",
});
expect(card.body).toEqual({
elements: [
{
tag: "markdown",
content: "Build completed",
expect(sendCardFeishuMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
card: expect.objectContaining({
schema: "2.0",
header: {
title: { tag: "plain_text", content: "Status" },
template: "blue",
},
],
body: {
elements: [
{
tag: "markdown",
content: "Build completed",
},
],
},
}),
accountId: undefined,
replyToMessageId: undefined,
replyInThread: false,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.messageId).toBe("om_card");
expect(details.chatId).toBe("oc_group_1");
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
});
it("renders presentation button labels into the card fallback", async () => {
@@ -416,17 +377,20 @@ describe("feishuPlugin actions", () => {
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card: expect.objectContaining({
body: {
elements: [
{
tag: "markdown",
content: "- Run help",
},
],
},
}),
}),
);
const card = requireRecord(sendCardArgs.card, "card");
expect(requireRecord(card.body, "card body").elements).toEqual([
{
tag: "markdown",
content: "- Run help",
},
]);
});
it("renders presentation select labels into the card fallback", async () => {
@@ -451,17 +415,20 @@ describe("feishuPlugin actions", () => {
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card: expect.objectContaining({
body: {
elements: [
{
tag: "markdown",
content: "Pick one:\n- Option A",
},
],
},
}),
}),
);
const card = requireRecord(sendCardArgs.card, "card");
expect(requireRecord(card.body, "card body").elements).toEqual([
{
tag: "markdown",
content: "Pick one:\n- Option A",
},
]);
});
it("sends media through the outbound adapter", async () => {
@@ -493,7 +460,7 @@ describe("feishuPlugin actions", () => {
mediaLocalRoots: ["/tmp"],
replyToId: undefined,
});
expect(resultDetails(result).messageId).toBe("om_media");
expect(result?.details).toMatchObject({ messageId: "om_media" });
});
it("passes asVoice through media sends", async () => {
@@ -516,12 +483,12 @@ describe("feishuPlugin actions", () => {
mediaLocalRoots: [],
} as never);
const mediaArgs = requireRecord(
mockCallArg(feishuOutboundSendMediaMock, 0, 0, "feishuOutbound.sendMedia"),
"media args",
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
expect(mediaArgs.mediaUrl).toBe("https://example.com/reply.mp3");
expect(mediaArgs.audioAsVoice).toBe(true);
});
it("reads messages", async () => {
@@ -543,11 +510,10 @@ describe("feishuPlugin actions", () => {
messageId: "om_1",
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
const message = requireRecord(details.message, "read message");
expect(message.messageId).toBe("om_1");
expect(message.content).toBe("hello");
expect(result?.details).toMatchObject({
ok: true,
message: expect.objectContaining({ messageId: "om_1", content: "hello" }),
});
});
it("returns an error result when message reads fail", async () => {
@@ -583,10 +549,7 @@ describe("feishuPlugin actions", () => {
card: undefined,
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.messageId).toBe("om_2");
expect(details.contentType).toBe("post");
expect(result?.details).toMatchObject({ ok: true, messageId: "om_2", contentType: "post" });
});
it("sends explicit thread replies with reply_in_thread semantics", async () => {
@@ -608,10 +571,11 @@ describe("feishuPlugin actions", () => {
replyToMessageId: "om_parent",
replyInThread: true,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.action).toBe("thread-reply");
expect(details.messageId).toBe("om_reply");
expect(result?.details).toMatchObject({
ok: true,
action: "thread-reply",
messageId: "om_reply",
});
});
it("auto-threads `send` text against the inbound trigger in group_topic sessions", async () => {
@@ -654,12 +618,12 @@ describe("feishuPlugin actions", () => {
toolContext: { currentMessageId: "om_inbound" },
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_inbound",
replyInThread: true,
}),
);
expect(sendCardArgs.replyToMessageId).toBe("om_inbound");
expect(sendCardArgs.replyInThread).toBe(true);
});
it("auto-threads `send` media against the inbound trigger in group_topic sessions", async () => {
@@ -683,12 +647,14 @@ describe("feishuPlugin actions", () => {
mediaLocalRoots: ["/tmp"],
} as never);
const mediaArgs = requireRecord(
mockCallArg(feishuOutboundSendMediaMock, 0, 0, "feishuOutbound.sendMedia"),
"media args",
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "om_inbound",
}),
);
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
expect.not.objectContaining({ replyToId: expect.anything() }),
);
expect(mediaArgs.threadId).toBe("om_inbound");
expect("replyToId" in mediaArgs).toBe(false);
});
it("auto-threads `send` in group_topic_sender sessions too", async () => {
@@ -703,12 +669,12 @@ describe("feishuPlugin actions", () => {
toolContext: { currentMessageId: "om_inbound" },
} as never);
const sendArgs = requireRecord(
mockCallArg(sendMessageFeishuMock, 0, 0, "sendMessageFeishu"),
"send args",
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_inbound",
replyInThread: true,
}),
);
expect(sendArgs.replyToMessageId).toBe("om_inbound");
expect(sendArgs.replyInThread).toBe(true);
});
it("does not auto-thread `send` in plain group sessions (no topic)", async () => {
@@ -770,9 +736,10 @@ describe("feishuPlugin actions", () => {
messageId: "om_pin",
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(requireRecord(details.pin, "pin").messageId).toBe("om_pin");
expect(result?.details).toMatchObject({
ok: true,
pin: expect.objectContaining({ messageId: "om_pin" }),
});
});
it("lists pins", async () => {
@@ -800,10 +767,10 @@ describe("feishuPlugin actions", () => {
pageToken: undefined,
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
const pins = requireArray(details.pins, "pins");
expect(requireRecord(pins[0], "pin").messageId).toBe("om_pin");
expect(result?.details).toMatchObject({
ok: true,
pins: [expect.objectContaining({ messageId: "om_pin" })],
});
});
it("removes pins", async () => {
@@ -819,9 +786,7 @@ describe("feishuPlugin actions", () => {
messageId: "om_pin",
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.messageId).toBe("om_pin");
expect(result?.details).toMatchObject({ ok: true, messageId: "om_pin" });
});
it("fetches channel info", async () => {
@@ -837,11 +802,10 @@ describe("feishuPlugin actions", () => {
expect(createFeishuClientMock).toHaveBeenCalled();
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1");
const details = resultDetails(result);
expect(details.ok).toBe(true);
const channel = requireRecord(details.channel, "channel");
expect(channel.chat_id).toBe("oc_group_1");
expect(channel.name).toBe("Eng");
expect(result?.details).toMatchObject({
ok: true,
channel: expect.objectContaining({ chat_id: "oc_group_1", name: "Eng" }),
});
});
it("fetches member lists from a chat", async () => {
@@ -866,12 +830,10 @@ describe("feishuPlugin actions", () => {
undefined,
"open_id",
);
const details = resultDetails(result);
expect(details.ok).toBe(true);
const members = requireArray(details.members, "members");
const member = requireRecord(members[0], "member");
expect(member.member_id).toBe("ou_1");
expect(member.name).toBe("Alice");
expect(result?.details).toMatchObject({
ok: true,
members: [expect.objectContaining({ member_id: "ou_1", name: "Alice" })],
});
});
it("fetches individual member info", async () => {
@@ -886,11 +848,10 @@ describe("feishuPlugin actions", () => {
} as never);
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_1", "open_id");
const details = resultDetails(result);
expect(details.ok).toBe(true);
const member = requireRecord(details.member, "member");
expect(member.member_id).toBe("ou_1");
expect(member.name).toBe("Alice");
expect(result?.details).toMatchObject({
ok: true,
member: expect.objectContaining({ member_id: "ou_1", name: "Alice" }),
});
});
it("infers user_id lookups from the userId alias", async () => {
@@ -946,12 +907,11 @@ describe("feishuPlugin actions", () => {
fallbackToStatic: false,
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
const groups = requireArray(details.groups, "groups");
const peers = requireArray(details.peers, "peers");
expect(requireRecord(groups[0], "group").id).toBe("oc_group_1");
expect(requireRecord(peers[0], "peer").id).toBe("ou_1");
expect(result?.details).toMatchObject({
ok: true,
groups: [expect.objectContaining({ id: "oc_group_1" })],
peers: [expect.objectContaining({ id: "ou_1" })],
});
});
it("fails channel-list when live discovery fails", async () => {
@@ -999,9 +959,7 @@ describe("feishuPlugin actions", () => {
accountId: undefined,
});
expect(removeReactionFeishuMock).toHaveBeenCalledTimes(2);
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.removed).toBe(2);
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
});
it("fails for missing params on supported actions", async () => {
@@ -1034,13 +992,13 @@ describe("feishuPlugin actions", () => {
mediaLocalRoots: [],
} as never);
const mediaArgs = requireRecord(
mockCallArg(feishuOutboundSendMediaMock, 0, 0, "feishuOutbound.sendMedia"),
"media args",
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_group_1",
mediaUrl: "https://example.com/image.png",
}),
);
expect(mediaArgs.to).toBe("chat:oc_group_1");
expect(mediaArgs.mediaUrl).toBe("https://example.com/image.png");
expect(resultDetails(result).messageId).toBe("om_media_only");
expect(result?.details).toMatchObject({ messageId: "om_media_only" });
});
it("fails for unsupported action names", async () => {

View File

@@ -115,57 +115,6 @@ function buildDelayedSecondSseResponse(params: {
});
}
function requireMockCall<TArgs extends unknown[]>(
mock: { mock: { calls: TArgs[] } },
index: number,
label: string,
): TArgs {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`Expected ${label} mock call ${index}`);
}
return call;
}
function requireRequestInit(call: unknown[], label: string): RequestInit {
const init = call[1];
if (!init || typeof init !== "object") {
throw new Error(`Expected ${label} request init`);
}
return init as RequestInit;
}
function expectHeaders(init: RequestInit, expected: Record<string, string>): void {
const headers = new Headers(init.headers);
for (const [key, value] of Object.entries(expected)) {
expect(headers.get(key)).toBe(value);
}
}
function parseRequestJsonBody(init: RequestInit): Record<string, unknown> {
const requestBody = init.body;
if (typeof requestBody !== "string") {
throw new Error("Expected request body to be serialized JSON");
}
return JSON.parse(requestBody) as Record<string, unknown>;
}
function requireGenerationConfig(params: { generationConfig?: unknown }): Record<string, unknown> {
const config = params.generationConfig;
if (!config || typeof config !== "object") {
throw new Error("Expected generationConfig");
}
return config as Record<string, unknown>;
}
function requireThinkingConfig(config: Record<string, unknown>): Record<string, unknown> {
const thinkingConfig = config.thinkingConfig;
if (!thinkingConfig || typeof thinkingConfig !== "object") {
throw new Error("Expected thinkingConfig");
}
return thinkingConfig as Record<string, unknown>;
}
describe("google transport stream", () => {
beforeAll(async () => {
({
@@ -277,53 +226,57 @@ describe("google transport stream", () => {
const result = await stream.result();
expect(buildGuardedModelFetchMock).toHaveBeenCalledWith(model);
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
expect(guardedCall[0]).toBe(
expect(guardedFetchMock).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
accept: "text/event-stream",
"Content-Type": "application/json",
"x-goog-api-key": "gemini-api-key",
"X-Provider": "google",
}),
}),
);
const init = requireRequestInit(guardedCall, "guarded fetch");
expect(init.method).toBe("POST");
expectHeaders(init, {
accept: "text/event-stream",
"Content-Type": "application/json",
"x-goog-api-key": "gemini-api-key",
"X-Provider": "google",
});
const payload = parseRequestJsonBody(init);
const init = guardedFetchMock.mock.calls[0]?.[1] as RequestInit;
const requestBody = init.body;
if (typeof requestBody !== "string") {
throw new Error("Expected Google transport request body to be serialized JSON");
}
const payload = JSON.parse(requestBody) as Record<string, unknown>;
expect(payload.systemInstruction).toEqual({
parts: [{ text: "Follow policy." }],
});
expect(payload.cachedContent).toBe("cachedContents/request-cache");
expect((payload.generationConfig as { thinkingConfig?: unknown }).thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "HIGH",
expect(payload.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" },
});
expect(
(payload.toolConfig as { functionCallingConfig?: unknown }).functionCallingConfig,
).toEqual({
mode: "AUTO",
expect(payload.toolConfig).toMatchObject({
functionCallingConfig: { mode: "AUTO" },
});
expect(result.api).toBe("google-generative-ai");
expect(result.provider).toBe("google");
expect(result.responseId).toBe("resp_1");
expect(result.stopReason).toBe("toolUse");
expect(result.usage.input).toBe(8);
expect(result.usage.output).toBe(8);
expect(result.usage.cacheRead).toBe(2);
expect(result.usage.totalTokens).toBe(18);
expect(result.content).toHaveLength(3);
expect(result.content[0]).toEqual({
type: "thinking",
thinking: "draft",
thinkingSignature: "sig_1",
expect(result).toMatchObject({
api: "google-generative-ai",
provider: "google",
responseId: "resp_1",
stopReason: "toolUse",
usage: {
input: 8,
output: 8,
cacheRead: 2,
totalTokens: 18,
},
content: [
{ type: "thinking", thinking: "draft", thinkingSignature: "sig_1" },
{ type: "text", text: "answer" },
{
type: "toolCall",
name: "lookup",
arguments: { q: "hello" },
thoughtSignature: "call_sig_1",
},
],
});
expect(result.content[1]?.type).toBe("text");
expect(result.content[1]).toHaveProperty("text", "answer");
expect(result.content[2]?.type).toBe("toolCall");
expect(result.content[2]).toHaveProperty("name", "lookup");
expect(result.content[2]).toHaveProperty("arguments", { q: "hello" });
expect(result.content[2]).toHaveProperty("thoughtSignature", "call_sig_1");
});
it("builds a lean Gemini 3 first-response retry payload", () => {
@@ -400,7 +353,7 @@ describe("google transport stream", () => {
expect(guardedFetchMock).toHaveBeenCalledTimes(2);
const firstBody = JSON.parse(guardedFetchMock.mock.calls[0]?.[1]?.body as string);
const retryBody = JSON.parse(guardedFetchMock.mock.calls[1]?.[1]?.body as string);
expect(firstBody.generationConfig.thinkingConfig).toEqual({
expect(firstBody.generationConfig.thinkingConfig).toMatchObject({
includeThoughts: true,
thinkingLevel: "HIGH",
});
@@ -481,13 +434,15 @@ describe("google transport stream", () => {
);
await stream.result();
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
expect(typeof guardedCall[0]).toBe("string");
const init = requireRequestInit(guardedCall, "guarded fetch");
expectHeaders(init, {
Authorization: "Bearer oauth-token",
"Content-Type": "application/json",
});
expect(guardedFetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer oauth-token",
"Content-Type": "application/json",
}),
}),
);
});
it("refreshes authorized_user ADC before Google Vertex requests", async () => {
@@ -539,25 +494,27 @@ describe("google transport stream", () => {
);
const result = await stream.result();
const tokenCall = requireMockCall(tokenFetchMock, 0, "token fetch");
expect(tokenCall[0]).toBe("https://oauth2.googleapis.com/token");
expect(requireRequestInit(tokenCall, "token fetch").method).toBe("POST");
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
expect(guardedCall[0]).toBe(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse",
expect(tokenFetchMock).toHaveBeenCalledWith(
"https://oauth2.googleapis.com/token",
expect.objectContaining({ method: "POST" }),
);
const guardedInit = requireRequestInit(guardedCall, "guarded fetch");
expect(guardedInit.method).toBe("POST");
expectHeaders(guardedInit, {
Authorization: "Bearer ya29.vertex-token",
"Content-Type": "application/json",
accept: "text/event-stream",
expect(guardedFetchMock).toHaveBeenCalledWith(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer ya29.vertex-token",
"Content-Type": "application/json",
accept: "text/event-stream",
}),
}),
);
expect(result).toMatchObject({
api: "google-vertex",
provider: "google-vertex",
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
});
expect(result.api).toBe("google-vertex");
expect(result.provider).toBe("google-vertex");
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
});
it("refreshes authorized_user ADC from the Windows APPDATA fallback for Google Vertex requests", async () => {
@@ -613,18 +570,25 @@ describe("google transport stream", () => {
);
await stream.result();
const tokenCall = requireMockCall(tokenFetchMock, 0, "token fetch");
expect(tokenCall[0]).toBe("https://oauth2.googleapis.com/token");
const tokenInit = requireRequestInit(tokenCall, "token fetch");
expect(tokenInit.method).toBe("POST");
expect(tokenInit.body).toBeInstanceOf(URLSearchParams);
const requestBody = tokenInit.body as URLSearchParams;
expect(tokenFetchMock).toHaveBeenCalledWith(
"https://oauth2.googleapis.com/token",
expect.objectContaining({
body: expect.objectContaining({
get: expect.any(Function),
}),
method: "POST",
}),
);
const requestBody = tokenFetchMock.mock.calls[0]?.[1]?.body as URLSearchParams | undefined;
expect(requestBody?.get("refresh_token")).toBe("appdata-refresh-token");
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
expect(typeof guardedCall[0]).toBe("string");
expectHeaders(requireRequestInit(guardedCall, "guarded fetch"), {
Authorization: "Bearer ya29.appdata-token",
});
expect(guardedFetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer ya29.appdata-token",
}),
}),
);
});
it("coerces replayed malformed tool-call args to an object for Google payloads", () => {
@@ -649,7 +613,7 @@ describe("google transport stream", () => {
],
} as never);
expect(params.contents[0]).toEqual({
expect(params.contents[0]).toMatchObject({
role: "model",
parts: [{ functionCall: { name: "lookup", args: {} } }],
});
@@ -683,7 +647,7 @@ describe("google transport stream", () => {
],
} as never);
expect(params.contents[0]).toEqual({
expect(params.contents[0]).toMatchObject({
role: "model",
parts: [
{
@@ -721,7 +685,7 @@ describe("google transport stream", () => {
],
} as never);
expect(params.contents[0]).toEqual({
expect(params.contents[0]).toMatchObject({
role: "model",
parts: [
{
@@ -760,7 +724,7 @@ describe("google transport stream", () => {
],
} as never);
expect(params.contents[0]).toEqual({
expect(params.contents[0]).toMatchObject({
role: "model",
parts: [{ functionCall: { name: "lookup", args: { q: "hello" } } }],
});
@@ -792,10 +756,12 @@ describe("google transport stream", () => {
},
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(thinkingConfig.includeThoughts).toBe(true);
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: -1 },
});
});
it("omits disabled thinkingBudget=0 for Gemini 2.5 Pro direct payloads", () => {
@@ -809,9 +775,10 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
expect(generationConfig.maxOutputTokens).toBe(128);
expect(generationConfig).not.toHaveProperty("thinkingConfig");
expect(params.generationConfig).toMatchObject({
maxOutputTokens: 128,
});
expect(params.generationConfig).not.toHaveProperty("thinkingConfig");
});
it("strips explicit thinkingBudget=0 but preserves includeThoughts for Gemini 2.5 Pro", () => {
@@ -828,10 +795,12 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(thinkingConfig.includeThoughts).toBe(true);
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: 0 },
});
});
it.each([
@@ -851,11 +820,13 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(generationConfig.maxOutputTokens).toBe(128);
expect(thinkingConfig.thinkingLevel).toBe(level);
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
expect(params.generationConfig).toMatchObject({
maxOutputTokens: 128,
thinkingConfig: { thinkingLevel: level },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: 0 },
});
},
);
@@ -873,13 +844,12 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "MEDIUM",
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingLevel: "MEDIUM" },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: 8192 },
});
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
});
it("keeps adaptive Gemini 3 thinking on provider dynamic defaults", () => {
@@ -893,11 +863,15 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(thinkingConfig.includeThoughts).toBe(true);
expect(thinkingConfig).not.toHaveProperty("thinkingLevel");
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingLevel: expect.any(String) },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: expect.any(Number) },
});
});
it("maps adaptive Gemini 2.5 thinking to dynamic thinkingBudget", () => {
@@ -911,10 +885,8 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
expect(requireThinkingConfig(generationConfig)).toEqual({
includeThoughts: true,
thinkingBudget: -1,
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },
});
});
@@ -932,10 +904,8 @@ describe("google transport stream", () => {
} as never,
);
const generationConfig = requireGenerationConfig(params);
expect(requireThinkingConfig(generationConfig)).toEqual({
includeThoughts: true,
thinkingLevel: "LOW",
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingLevel: "LOW" },
});
});
@@ -1021,10 +991,8 @@ describe("google transport stream", () => {
{ reasoning },
);
const generationConfig = requireGenerationConfig(params);
expect(requireThinkingConfig(generationConfig)).toEqual({
includeThoughts: true,
thinkingBudget: expectedBudget,
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingBudget: expectedBudget },
});
});
@@ -1091,8 +1059,7 @@ describe("google transport stream", () => {
"text_end",
"done",
]);
expect(events[3]?.type).toBe("thinking_delta");
expect(events[3]).toHaveProperty("delta", "");
expect(events[3]).toMatchObject({ type: "thinking_delta", delta: "" });
});
it("starts a thinking block for thoughtSignature-only parts that arrive before any text", async () => {

View File

@@ -127,95 +127,6 @@ function createReactionHarness(params?: {
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
expect(value, label).toBeTypeOf("object");
expect(value, label).not.toBeNull();
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): Array<unknown> {
expect(Array.isArray(value), label).toBe(true);
return value as Array<unknown>;
}
function mockCalls(mock: unknown, label: string): Array<Array<unknown>> {
const mockState = (mock as { mock?: { calls?: Array<Array<unknown>> } }).mock;
expect(mockState, `${label}.mock`).toBeDefined();
expect(Array.isArray(mockState?.calls), `${label}.mock.calls`).toBe(true);
return mockState?.calls ?? [];
}
function callArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const call = mockCalls(mock, label).at(callIndex);
expect(call, label).toBeDefined();
return call?.[argIndex];
}
function lastCallArg(mock: unknown, argIndex: number, label: string) {
const calls = mockCalls(mock, label);
return callArg(mock, calls.length - 1, argIndex, label);
}
function expectMockCallWithFields(mock: unknown, fields: Record<string, unknown>) {
const matched = mockCalls(mock, "mock calls").some(([value]) => {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return Object.entries(fields).every(([key, expected]) => Object.is(record[key], expected));
});
expect(matched).toBe(true);
}
function expectNoticeSent(mock: unknown) {
const message = requireRecord(callArg(mock, 0, 1, "notice content"), "notice content");
expect(message.msgtype).toBe("m.notice");
expect(String(message.body)).toContain("channels.matrix.dm.sessionScope");
}
function findMockCall(mock: unknown, label: string, predicate: (call: Array<unknown>) => boolean) {
const call = mockCalls(mock, label).find(predicate);
expect(call, label).toBeDefined();
return call as Array<unknown>;
}
function expectFinalizedPreviewEdit(eventId: string, text: string) {
const call = findMockCall(
editMessageMatrixMock,
`edit call for ${eventId}`,
([room, editedEventId, body]) =>
room === "!room:example.org" && editedEventId === eventId && body === text,
);
const options = requireRecord(call[3], "edit options");
expect(options.extraContent).toEqual({ [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true });
}
function expectEditLiveFlag(eventId: string, text: string, expected: boolean | undefined) {
const call = findMockCall(
editMessageMatrixMock,
`edit live flag call for ${eventId}`,
([room, editedEventId, body]) =>
room === "!room:example.org" && editedEventId === eventId && body === text,
);
const options = requireRecord(call[3], "edit options");
if (expected === undefined) {
expect(Object.hasOwn(options, "live")).toBe(false);
} else {
expect(options.live).toBe(expected);
}
}
function expectDeliveredMediaReply() {
const payload = requireRecord(
lastCallArg(deliverMatrixRepliesMock, 0, "deliver replies payload"),
"deliver replies payload",
);
const replies = requireArray(payload.replies, "deliver replies");
const reply = requireRecord(replies[0], "media reply");
expect(reply.mediaUrl).toBe("https://example.com/image.png");
expect(reply.text).toBeUndefined();
}
describe("matrix monitor handler pairing account scope", () => {
it("caches account-scoped allowFrom store reads on hot path", async () => {
const readAllowFromStore = vi.fn(async () => [] as string[]);
@@ -303,16 +214,18 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
const inbound = requireRecord(
callArg(recordInboundSession, 0, 0, "record inbound session"),
"record inbound session",
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
updateLastRoute: expect.objectContaining({
channel: "matrix",
to: "room:!dm:example.org",
mainDmOwnerPin: expect.objectContaining({
ownerRecipient: "@owner:example.org",
senderRecipient: "@owner:example.org",
}),
}),
}),
);
const route = requireRecord(inbound.updateLastRoute, "last route update");
expect(route.channel).toBe("matrix");
expect(route.to).toBe("room:!dm:example.org");
const ownerPin = requireRecord(route.mainDmOwnerPin, "main DM owner pin");
expect(ownerPin.ownerRecipient).toBe("@owner:example.org");
expect(ownerPin.senderRecipient).toBe("@owner:example.org");
});
it("uses live dmScope when deciding whether to pin main DM route updates", async () => {
@@ -350,12 +263,13 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
const inbound = requireRecord(
callArg(recordInboundSession, 0, 0, "record inbound session"),
"record inbound session",
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
updateLastRoute: expect.objectContaining({
mainDmOwnerPin: undefined,
}),
}),
);
const route = requireRecord(inbound.updateLastRoute, "last route update");
expect(route.mainDmOwnerPin).toBeUndefined();
});
it("sends pairing reminders for pending requests with cooldown", async () => {
@@ -456,7 +370,12 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expectMockCallWithFields(resolveAgentRoute, { channel: "matrix", accountId: "ops" });
expect(resolveAgentRoute).toHaveBeenCalledWith(
expect.objectContaining({
channel: "matrix",
accountId: "ops",
}),
);
});
it("does not enqueue delivered text messages into system events", async () => {
@@ -735,14 +654,14 @@ describe("matrix monitor handler pairing account scope", () => {
);
expect(hasControlCommand).toHaveBeenCalledWith("/new", expect.anything());
const context = requireRecord(
callArg(finalizeInboundContext, 0, 0, "finalized context"),
"finalized context",
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
RawBody: "@bot:example.org /new",
CommandBody: "/new",
BodyForAgent: "@bot:example.org /new",
BodyForCommands: "/new",
}),
);
expect(context.RawBody).toBe("@bot:example.org /new");
expect(context.CommandBody).toBe("/new");
expect(context.BodyForAgent).toBe("@bot:example.org /new");
expect(context.BodyForCommands).toBe("/new");
expect(recordInboundSession).toHaveBeenCalled();
});
@@ -979,13 +898,17 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
const context = requireRecord(
callArg(finalizeInboundContext, 0, 0, "finalized context"),
"finalized context",
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
MessageThreadId: "$root",
ThreadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:ops:main:thread:$root",
}),
);
expect(context.MessageThreadId).toBe("$root");
expect(context.ThreadStarterBody).toBe("Matrix thread root $root from Alice:\nRoot topic");
expectMockCallWithFields(recordInboundSession, { sessionKey: "agent:ops:main:thread:$root" });
});
it("keeps threaded DMs flat when dm threadReplies is off", async () => {
@@ -1021,14 +944,18 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
const context = requireRecord(
callArg(finalizeInboundContext, 0, 0, "finalized context"),
"finalized context",
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
MessageThreadId: undefined,
ReplyToId: "$root",
ThreadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:ops:main",
}),
);
expect(context.MessageThreadId).toBeUndefined();
expect(context.ReplyToId).toBe("$root");
expect(context.ThreadStarterBody).toBe("Matrix thread root $root from Alice:\nRoot topic");
expectMockCallWithFields(recordInboundSession, { sessionKey: "agent:ops:main" });
});
it("posts a one-time notice when another Matrix DM room already owns the shared DM session", async () => {
@@ -1060,8 +987,13 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expect(sendNotice).toHaveBeenCalledWith("!dm:example.org", expect.anything());
expectNoticeSent(sendNotice);
expect(sendNotice).toHaveBeenCalledWith(
"!dm:example.org",
expect.objectContaining({
msgtype: "m.notice",
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
}),
);
await handler(
"!dm:example.org",
@@ -1114,8 +1046,13 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expect(sendNotice).toHaveBeenCalledWith("!dm:example.org", expect.anything());
expectNoticeSent(sendNotice);
expect(sendNotice).toHaveBeenCalledWith(
"!dm:example.org",
expect.objectContaining({
msgtype: "m.notice",
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
}),
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -1166,8 +1103,13 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expect(sendNotice).toHaveBeenCalledWith("!dm:example.org", expect.anything());
expectNoticeSent(sendNotice);
expect(sendNotice).toHaveBeenCalledWith(
"!dm:example.org",
expect.objectContaining({
msgtype: "m.notice",
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
}),
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -1208,8 +1150,13 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expect(sendNotice).toHaveBeenCalledWith("!dm:example.org", expect.anything());
expectNoticeSent(sendNotice);
expect(sendNotice).toHaveBeenCalledWith(
"!dm:example.org",
expect.objectContaining({
msgtype: "m.notice",
body: expect.stringContaining("channels.matrix.dm.sessionScope"),
}),
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -1289,9 +1236,11 @@ describe("matrix monitor handler pairing account scope", () => {
);
expect(sendNotice).not.toHaveBeenCalled();
expectMockCallWithFields(recordInboundSession, {
sessionKey: "agent:ops:matrix:channel:!dm:example.org",
});
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:ops:matrix:channel:!dm:example.org",
}),
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -1390,13 +1339,14 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
const finalized = requireRecord(
lastCallArg(finalizeInboundContext, 0, "finalized context"),
"finalized context",
const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0];
expect(finalized).toEqual(
expect.objectContaining({
GroupChannel: "!room:example.org",
GroupSubject: "Ops Room",
GroupId: "!room:example.org",
}),
);
expect(finalized.GroupChannel).toBe("!room:example.org");
expect(finalized.GroupSubject).toBe("Ops Room");
expect(finalized.GroupId).toBe("!room:example.org");
});
it("routes bound Matrix threads to the target session key", async () => {
@@ -1454,7 +1404,11 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expectMockCallWithFields(recordInboundSession, { sessionKey: "agent:bound:session-1" });
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:bound:session-1",
}),
);
expect(touch).toHaveBeenCalledTimes(1);
});
@@ -1659,7 +1613,12 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expectMockCallWithFields(resolveAgentRoute, { channel: "matrix", accountId: "ops" });
expect(resolveAgentRoute).toHaveBeenCalledWith(
expect.objectContaining({
channel: "matrix",
accountId: "ops",
}),
);
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"Matrix reaction added: 👍 by sender on msg $msg1",
{
@@ -2186,12 +2145,12 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again",
});
const liveAllowlistRequest = requireRecord(
lastCallArg(resolveLiveUserAllowlist, 0, "live allowlist request"),
"live allowlist request",
expect(resolveLiveUserAllowlist).toHaveBeenLastCalledWith(
expect.objectContaining({
accountId: "ops",
entries: ["Alice"],
}),
);
expect(liveAllowlistRequest.accountId).toBe("ops");
expect(liveAllowlistRequest.entries).toEqual(["Alice"]);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
@@ -2765,7 +2724,14 @@ describe("matrix monitor handler draft streaming", () => {
await deliver({ text: "Single block" }, { kind: "final" });
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
expectFinalizedPreviewEdit("$draft1", "Single block");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Single block",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -2787,7 +2753,14 @@ describe("matrix monitor handler draft streaming", () => {
await deliver({ text: "Done" }, { kind: "final" });
expectFinalizedPreviewEdit("$draft1", "Done");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Done",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -2895,19 +2868,26 @@ describe("matrix monitor handler draft streaming", () => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
const draftOptions = requireRecord(
callArg(sendSingleTextMessageMatrixMock, 0, 2, "draft options"),
"draft options",
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"Single block",
expect.not.objectContaining({
msgtype: "m.notice",
includeMentions: false,
}),
);
expect(draftOptions.msgtype).not.toBe("m.notice");
expect(draftOptions.includeMentions).not.toBe(false);
await deliver({ text: "Single block" }, { kind: "final" });
// MSC4357: even when text is unchanged, a finalize edit is sent to clear
// the live marker so supporting clients stop the streaming animation.
expect(editMessageMatrixMock).toHaveBeenCalledTimes(1);
expectEditLiveFlag("$draft1", "Single block", false);
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Single block",
expect.objectContaining({ live: false }),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -2928,7 +2908,12 @@ describe("matrix monitor handler draft streaming", () => {
await deliver({ text: "Single block" }, { kind: "final" });
expect(editMessageMatrixMock).toHaveBeenCalledTimes(1);
expectEditLiveFlag("$draft1", "Single block", undefined);
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Single block",
expect.not.objectContaining({ live: false }),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -2946,7 +2931,14 @@ describe("matrix monitor handler draft streaming", () => {
deliverMatrixRepliesMock.mockClear();
await deliver({ text: "Block one" }, { kind: "block" });
expectFinalizedPreviewEdit("$draft1", "Block one");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Block one",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
@@ -2962,7 +2954,14 @@ describe("matrix monitor handler draft streaming", () => {
await deliver({ text: "Block two" }, { kind: "final" });
expectFinalizedPreviewEdit("$draft2", "Block two");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft2",
"Block two",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
@@ -3260,7 +3259,14 @@ describe("matrix monitor handler draft streaming", () => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
expectFinalizedPreviewEdit("$draft1", "Alpha");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Alpha",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
@@ -3274,7 +3280,14 @@ describe("matrix monitor handler draft streaming", () => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Gamma");
expectFinalizedPreviewEdit("$draft2", "Beta");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft2",
"Beta",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
await finish();
});
@@ -3552,9 +3565,23 @@ describe("matrix monitor handler draft streaming", () => {
);
expect(editMessageMatrixMock).toHaveBeenCalledTimes(1);
expectEditLiveFlag("$draft1", "@room screenshot ready", false);
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"@room screenshot ready",
expect.objectContaining({ live: false }),
);
expect(redactEventMock).not.toHaveBeenCalled();
expectDeliveredMediaReply();
expect(deliverMatrixRepliesMock).toHaveBeenCalledWith(
expect.objectContaining({
replies: [
expect.objectContaining({
mediaUrl: "https://example.com/image.png",
text: undefined,
}),
],
}),
);
await finish();
});
@@ -3576,9 +3603,25 @@ describe("matrix monitor handler draft streaming", () => {
{ kind: "final" },
);
expectFinalizedPreviewEdit("$draft1", "@room screenshot ready");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"@room screenshot ready",
expect.objectContaining({
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
}),
);
expect(redactEventMock).not.toHaveBeenCalled();
expectDeliveredMediaReply();
expect(deliverMatrixRepliesMock).toHaveBeenCalledWith(
expect.objectContaining({
replies: [
expect.objectContaining({
mediaUrl: "https://example.com/image.png",
text: undefined,
}),
],
}),
);
await finish();
});

View File

@@ -28,46 +28,6 @@ type GuardedFetchCall = {
auditContext?: string;
};
function requireRecord(value: unknown, label: string): Record<string, unknown> {
expect(value, label).toBeTypeOf("object");
expect(value, label).not.toBeNull();
return value as Record<string, unknown>;
}
function requireHeaders(value: unknown): Record<string, string> {
return requireRecord(value, "request headers") as Record<string, string>;
}
function expectToolCallContent(
value: unknown,
expected: { name: string; arguments: Record<string, unknown> },
) {
const content = requireRecord(value, "tool call content");
expect(content.type).toBe("toolCall");
expect(content.name).toBe(expected.name);
expect(content.arguments).toEqual(expected.arguments);
}
function expectIteratorEvent(
value: unknown,
expected: { type?: string; delta?: string; content?: string; done: boolean },
) {
const result = requireRecord(value, "iterator result");
expect(result.done).toBe(expected.done);
if (expected.type !== undefined) {
const event = requireRecord(result.value, "iterator result value");
expect(event.type).toBe(expected.type);
if (expected.delta !== undefined) {
expect(event.delta).toBe(expected.delta);
}
if (expected.content !== undefined) {
expect(event.content).toBe(expected.content);
}
} else {
expect(result.value).toBeUndefined();
}
}
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
@@ -89,29 +49,38 @@ describe("buildOllamaChatRequest", () => {
});
it("strips the ollama/ prefix from chat model ids", () => {
const request = buildOllamaChatRequest({
modelId: "ollama/qwen3:14b-q8_0",
messages: [{ role: "user", content: "hello" }],
expect(
buildOllamaChatRequest({
modelId: "ollama/qwen3:14b-q8_0",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "qwen3:14b-q8_0",
});
expect(request.model).toBe("qwen3:14b-q8_0");
});
it("strips the active custom provider prefix from chat model ids", () => {
const request = buildOllamaChatRequest({
modelId: "ollama-spark/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
expect(
buildOllamaChatRequest({
modelId: "ollama-spark/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "qwen3:32b",
});
expect(request.model).toBe("qwen3:32b");
});
it("keeps unrelated slash-containing Ollama model ids intact", () => {
const request = buildOllamaChatRequest({
modelId: "library/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
expect(
buildOllamaChatRequest({
modelId: "library/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "library/qwen3:32b",
});
expect(request.model).toBe("library/qwen3:32b");
});
});
@@ -149,9 +118,10 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
} as never,
);
const payload = requireRecord(patchedPayload, "patched payload");
expect(payload.thinking).toEqual({ type: "enabled" });
expect(payload.options).toEqual({ num_ctx: 65536 });
expect(patchedPayload).toMatchObject({
thinking: { type: "enabled" },
options: { num_ctx: 65536 },
});
});
it("falls back to contextWindow when configured num_ctx is invalid", async () => {
@@ -185,8 +155,9 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
} as never,
);
const payload = requireRecord(patchedPayload, "patched payload");
expect(payload.options).toEqual({ num_ctx: 131072 });
expect(patchedPayload).toMatchObject({
options: { num_ctx: 131072 },
});
});
it("forwards think=false on native Ollama chat requests when thinking is off", async () => {
@@ -237,7 +208,7 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
};
expect(requestBody.think).toBe(false);
expect(requestBody.options?.think).toBeUndefined();
expect(requestBody.options?.num_ctx).toBeUndefined();
expect(requestBody.options?.num_ctx).toBe(131072);
},
);
});
@@ -339,7 +310,7 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
};
expect(requestBody.think).toBe("low");
expect(requestBody.options?.think).toBeUndefined();
expect(requestBody.options?.num_ctx).toBeUndefined();
expect(requestBody.options?.num_ctx).toBe(131072);
},
);
});
@@ -434,7 +405,7 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
};
expect(requestBody.think).toBe("high");
expect(requestBody.options?.think).toBeUndefined();
expect(requestBody.options?.num_ctx).toBeUndefined();
expect(requestBody.options?.num_ctx).toBe(131072);
},
);
});
@@ -888,9 +859,14 @@ describe("buildAssistantMessage", () => {
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expect(result.content).toHaveLength(2);
expectToolCallContent(result.content[0], { name: "exec", arguments: { command: "pwd" } });
expectToolCallContent(result.content[1], { name: "read", arguments: { path: "README.md" } });
expect(result.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "exec", arguments: { command: "pwd" } }),
expect.objectContaining({
type: "toolCall",
name: "read",
arguments: { path: "README.md" },
}),
]);
});
it("preserves exact allowlisted tool-prefix names in Ollama responses", () => {
@@ -911,13 +887,19 @@ describe("buildAssistantMessage", () => {
const result = buildAssistantMessage(response, modelInfo, undefined, {
availableToolNames: new Set(["tool_a", "tools_invoke_test", "function-run"]),
});
expect(result.content).toHaveLength(3);
expectToolCallContent(result.content[0], { name: "tool_a", arguments: { value: 1 } });
expectToolCallContent(result.content[1], {
name: "tools_invoke_test",
arguments: { value: 2 },
});
expectToolCallContent(result.content[2], { name: "function-run", arguments: { value: 3 } });
expect(result.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "tool_a", arguments: { value: 1 } }),
expect.objectContaining({
type: "toolCall",
name: "tools_invoke_test",
arguments: { value: 2 },
}),
expect.objectContaining({
type: "toolCall",
name: "function-run",
arguments: { value: 3 },
}),
]);
});
it("keeps non-prefixed Ollama response tool names intact", () => {
@@ -937,11 +919,12 @@ describe("buildAssistantMessage", () => {
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expect(result.content).toHaveLength(4);
expectToolCallContent(result.content[0], { name: "functionshell", arguments: {} });
expectToolCallContent(result.content[1], { name: "tooling", arguments: {} });
expectToolCallContent(result.content[2], { name: "tools", arguments: {} });
expectToolCallContent(result.content[3], { name: "tool_a", arguments: {} });
expect(result.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "functionshell", arguments: {} }),
expect.objectContaining({ type: "toolCall", name: "tooling", arguments: {} }),
expect.objectContaining({ type: "toolCall", name: "tools", arguments: {} }),
expect.objectContaining({ type: "toolCall", name: "tool_a", arguments: {} }),
]);
});
it("parses stringified tool call arguments from Ollama responses", () => {
@@ -956,7 +939,8 @@ describe("buildAssistantMessage", () => {
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expectToolCallContent(result.content[0], {
expect(result.content[0]).toMatchObject({
type: "toolCall",
name: "bash",
arguments: { command: "ls", path: "/tmp" },
});
@@ -981,7 +965,8 @@ describe("buildAssistantMessage", () => {
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expectToolCallContent(result.content[0], {
expect(result.content[0]).toMatchObject({
type: "toolCall",
name: "send",
arguments: {
target: "9223372036854775807",
@@ -1002,7 +987,11 @@ describe("buildAssistantMessage", () => {
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expectToolCallContent(result.content[0], { name: "bash", arguments: {} });
expect(result.content[0]).toMatchObject({
type: "toolCall",
name: "bash",
arguments: {},
});
});
it("sets all costs to zero for local models", () => {
@@ -1287,15 +1276,12 @@ describe("createOllamaStreamFn streaming events", () => {
// text_delta events carry incremental deltas
const deltas = events.filter((e) => e.type === "text_delta");
expect(deltas[0]?.contentIndex).toBe(0);
expect(deltas[0]?.delta).toBe("Hello");
expect(deltas[1]?.contentIndex).toBe(0);
expect(deltas[1]?.delta).toBe(" world");
expect(deltas[0]).toMatchObject({ contentIndex: 0, delta: "Hello" });
expect(deltas[1]).toMatchObject({ contentIndex: 0, delta: " world" });
// text_end carries the full accumulated content
const textEnd = events.find((e) => e.type === "text_end");
expect(textEnd?.contentIndex).toBe(0);
expect(textEnd?.content).toBe("Hello world");
expect(textEnd).toMatchObject({ contentIndex: 0, content: "Hello world" });
// start/text_start carry empty partials (before any content accumulates)
const startEvent = events.find((e) => e.type === "start");
@@ -1437,11 +1423,10 @@ describe("createOllamaStreamFn streaming events", () => {
expect(startEvent).not.toBe("timeout");
expect(textStartEvent).not.toBe("timeout");
expect(textDeltaEvent).not.toBe("timeout");
expectIteratorEvent(startEvent, { type: "start", done: false });
expectIteratorEvent(textStartEvent, { type: "text_start", done: false });
expectIteratorEvent(textDeltaEvent, {
type: "text_delta",
delta: "Let me check.",
expect(startEvent).toMatchObject({ value: { type: "start" }, done: false });
expect(textStartEvent).toMatchObject({ value: { type: "text_start" }, done: false });
expect(textDeltaEvent).toMatchObject({
value: { type: "text_delta", delta: "Let me check." },
done: false,
});
@@ -1451,18 +1436,17 @@ describe("createOllamaStreamFn streaming events", () => {
const textEndEvent = await nextEventWithin(iterator);
expect(textEndEvent).not.toBe("timeout");
expectIteratorEvent(textEndEvent, {
type: "text_end",
content: "Let me check.",
expect(textEndEvent).toMatchObject({
value: {
type: "text_end",
contentIndex: 0,
content: "Let me check.",
partial: {
content: [{ type: "text", text: "Let me check." }],
},
},
done: false,
});
if (textEndEvent !== "timeout") {
const textEndValue = requireRecord(textEndEvent.value, "text_end value");
expect(textEndValue.contentIndex).toBe(0);
expect(requireRecord(textEndValue.partial, "text_end partial").content).toEqual([
{ type: "text", text: "Let me check." },
]);
}
controlledFetch.pushLine(
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":10,"eval_count":5}',
@@ -1472,14 +1456,16 @@ describe("createOllamaStreamFn streaming events", () => {
const doneEvent = await nextEventWithin(iterator);
expect(doneEvent).not.toBe("timeout");
if (doneEvent !== "timeout" && doneEvent.done === false) {
expectIteratorEvent(doneEvent, { type: "done", done: false });
expect(requireRecord(doneEvent.value, "done value").reason).toBe("toolUse");
expect(doneEvent).toMatchObject({
value: { type: "done", reason: "toolUse" },
done: false,
});
const streamEnd = await nextEventWithin(iterator);
expect(streamEnd).not.toBe("timeout");
expectIteratorEvent(streamEnd, { done: true });
expect(streamEnd).toMatchObject({ value: undefined, done: true });
} else {
expectIteratorEvent(doneEvent, { done: true });
expect(doneEvent).toMatchObject({ value: undefined, done: true });
}
} finally {
fetchWithSsrFGuardMock.mockReset();
@@ -1530,10 +1516,12 @@ describe("createOllamaStreamFn streaming events", () => {
const types = events.map((e) => e.type);
expect(types).toEqual(["start", "text_start", "text_delta", "error"]);
const errorEvent = events.at(-1);
expect(errorEvent?.type).toBe("error");
if (errorEvent?.type === "error") {
expect(errorEvent.error.errorMessage).toContain("garbled visible text");
}
expect(errorEvent).toMatchObject({
type: "error",
error: expect.objectContaining({
errorMessage: expect.stringContaining("garbled visible text"),
}),
});
},
);
});
@@ -1581,7 +1569,7 @@ describe("createOllamaStreamFn streaming events", () => {
expect(types).toEqual(["start", "text_start", "text_delta", "text_end", "done"]);
const delta = events.find((e) => e.type === "text_delta");
expect(delta?.delta).toBe("one shot");
expect(delta).toMatchObject({ delta: "one shot" });
},
);
});
@@ -1621,7 +1609,9 @@ describe("createOllamaStreamFn", () => {
if (!requestBody.options) {
throw new Error("Expected Ollama request options");
}
expect(requestBody.options?.num_ctx).toBeUndefined();
// Catalog `contextWindow` flows through as `num_ctx` so the request
// does not silently truncate to Ollama's small Modelfile default.
expect(requestBody.options?.num_ctx).toBe(131072);
expect(requestBody.options.num_predict).toBe(123);
},
);
@@ -1704,7 +1694,7 @@ describe("createOllamaStreamFn", () => {
);
});
it("does not fall back to catalog contextWindow as native Ollama num_ctx", async () => {
it("falls back to catalog contextWindow as num_ctx when params.num_ctx is unset", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
@@ -1725,12 +1715,12 @@ describe("createOllamaStreamFn", () => {
const requestBody = JSON.parse(requestInit.body) as {
options?: { num_ctx?: number };
};
expect(requestBody.options?.num_ctx).toBeUndefined();
expect(requestBody.options?.num_ctx).toBe(32768);
},
);
});
it("does not fall back to catalog maxTokens as native Ollama num_ctx", async () => {
it("falls back to catalog maxTokens as num_ctx when contextWindow is absent", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
@@ -1754,7 +1744,7 @@ describe("createOllamaStreamFn", () => {
const requestBody = JSON.parse(requestInit.body) as {
options?: { num_ctx?: number };
};
expect(requestBody.options?.num_ctx).toBeUndefined();
expect(requestBody.options?.num_ctx).toBe(65536);
},
);
});
@@ -1802,9 +1792,10 @@ describe("createOllamaStreamFn", () => {
const request = getGuardedFetchCall(fetchMock);
expect(request.url).toBe("http://127.0.0.1:11434/api/chat");
const policy = requireRecord(request.policy, "ssrf policy");
expect(policy.hostnameAllowlist).toEqual(["127.0.0.1"]);
expect(policy.allowPrivateNetwork).toBe(true);
expect(request.policy).toMatchObject({
hostnameAllowlist: ["127.0.0.1"],
allowPrivateNetwork: true,
});
},
);
});
@@ -1834,11 +1825,12 @@ describe("createOllamaStreamFn", () => {
expect(events.at(-1)?.type).toBe("done");
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
const headers = requireHeaders(requestInit.headers);
expect(headers["Content-Type"]).toBe("application/json");
expect(headers["X-OLLAMA-KEY"]).toBe("provider-secret");
expect(headers["X-Trace"]).toBe("request");
expect(headers["X-Request-Only"]).toBe("1");
expect(requestInit.headers).toMatchObject({
"Content-Type": "application/json",
"X-OLLAMA-KEY": "provider-secret",
"X-Trace": "request",
"X-Request-Only": "1",
});
},
);
});
@@ -1865,7 +1857,9 @@ describe("createOllamaStreamFn", () => {
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
expect(requireHeaders(requestInit.headers).Authorization).toBe("Bearer proxy-token");
expect(requestInit.headers).toMatchObject({
Authorization: "Bearer proxy-token",
});
},
);
});
@@ -1899,7 +1893,9 @@ describe("createOllamaStreamFn", () => {
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
expect(requireHeaders(requestInit.headers).Authorization).toBe("Bearer real-token");
expect(requestInit.headers).toMatchObject({
Authorization: "Bearer real-token",
});
},
);
});
@@ -2041,7 +2037,9 @@ describe("createConfiguredOllamaStreamFn", () => {
const request = getGuardedFetchCall(fetchMock);
expect(request.url).toBe("http://provider-host:11434/api/chat");
const requestInit = request.init ?? {};
expect(requireHeaders(requestInit.headers).Authorization).toBe("Bearer proxy-token");
expect(requestInit.headers).toMatchObject({
Authorization: "Bearer proxy-token",
});
},
);
});

View File

@@ -293,16 +293,29 @@ function resolveOllamaNumCtx(model: ProviderRuntimeModel): number {
/**
* Resolves num_ctx for native /api/chat requests:
* 1. explicit `params.num_ctx` set on the model wins,
* 2. otherwise return undefined so Ollama's model, OLLAMA_CONTEXT_LENGTH,
* VRAM, or Modelfile policy decides.
* 2. otherwise the catalog `contextWindow` / `maxTokens` is forwarded so
* OpenClaw's known model windows survive the trip and `/api/chat` does
* not silently truncate to Ollama's small Modelfile default (typically
* 2048 tokens) — which is too small for a system prompt plus tool
* definitions and produces "model picks wrong tools / says nonsense"
* symptoms on agent turns,
* 3. when neither is known, return undefined so the Modelfile decides.
*
* This intentionally differs from `resolveOllamaNumCtx` by not falling back
* to `DEFAULT_CONTEXT_TOKENS`: that constant is a sane wrapper-side guess for
* the OpenAI-compat path, but native `/api/chat` should not force the full
* advertised catalog context for local models unless the operator opted in.
* the OpenAI-compat path, but on the native path we prefer to leave num_ctx
* absent rather than guess a window for an unknown model.
*/
function resolveOllamaNativeNumCtx(model: ProviderRuntimeModel): number | undefined {
return resolveOllamaConfiguredNumCtx(model);
const configured = resolveOllamaConfiguredNumCtx(model);
if (configured !== undefined) {
return configured;
}
const catalog = model.contextWindow ?? model.maxTokens;
if (typeof catalog === "number" && Number.isFinite(catalog) && catalog > 0) {
return Math.floor(catalog);
}
return undefined;
}
function resolveOllamaModelOptions(model: ProviderRuntimeModel): Record<string, unknown> {

View File

@@ -60,38 +60,6 @@ async function expectResponsesJson<T>(server: { baseUrl: string }, body: unknown
return (await response.json()) as T;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value;
}
function outputItem(payload: unknown, index = 0) {
const output = requireArray(requireRecord(payload, "response payload").output, "response output");
return requireRecord(output[index], `response output ${index}`);
}
function outputContentItem(payload: unknown, outputIndex = 0, contentIndex = 0) {
const content = requireArray(outputItem(payload, outputIndex).content, "response output content");
return requireRecord(content[contentIndex], `response content ${contentIndex}`);
}
function outputText(payload: unknown, outputIndex = 0, contentIndex = 0) {
const text = outputContentItem(payload, outputIndex, contentIndex).text;
if (typeof text !== "string") {
throw new Error("Expected response output text");
}
return text;
}
function makeUserInput(text: string) {
return {
role: "user" as const,
@@ -172,9 +140,18 @@ describe("qa mock openai server", () => {
}),
});
expect(preActionResponse.status).toBe(200);
const preActionPayload = await preActionResponse.json();
expect(outputItem(preActionPayload).type).toBe("message");
expect(outputText(preActionPayload)).toContain("Protocol note: acknowledged.");
expect(await preActionResponse.json()).toMatchObject({
output: [
{
type: "message",
content: [
{
text: expect.stringContaining("Protocol note: acknowledged."),
},
],
},
],
});
const approvalResponse = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
@@ -201,13 +178,13 @@ describe("qa mock openai server", () => {
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
const debugPayload = requireRecord(await debugResponse.json(), "debug request");
expect(debugPayload.model).toBe("gpt-5.5");
expect(debugPayload.prompt).toBe(
"ok do it. read `QA_KICKOFF_TASK.md` now and reply with the QA mission in one short sentence.",
);
expect(String(debugPayload.allInputText)).toContain("ok do it.");
expect(debugPayload.plannedToolName).toBe("read");
expect(await debugResponse.json()).toMatchObject({
model: "gpt-5.5",
prompt:
"ok do it. read `QA_KICKOFF_TASK.md` now and reply with the QA mission in one short sentence.",
allInputText: expect.stringContaining("ok do it."),
plannedToolName: "read",
});
});
it("emits deterministic text deltas for generic streaming QA prompts", async () => {
@@ -507,14 +484,11 @@ describe("qa mock openai server", () => {
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
const debugPayload = requireRecord(await debugResponse.json(), "debug request");
expect(debugPayload.prompt).toBe(
'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
);
expect(debugPayload.allInputText).toBe(
'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
);
expect(debugPayload.plannedToolName).toBe("read");
expect(await debugResponse.json()).toMatchObject({
prompt: 'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
allInputText: 'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
plannedToolName: "read",
});
});
it("drives the Lobster Invaders write flow and memory recall responses", async () => {
@@ -591,9 +565,10 @@ describe("qa mock openai server", () => {
const requests = await fetch(`${server.baseUrl}/debug/requests`);
expect(requests.status).toBe(200);
const requestLog = requireArray(await requests.json(), "debug requests");
expect(requireRecord(requestLog[0], "debug request 0").model).toBe("gpt-5.5");
expect(requireRecord(requestLog[1], "debug request 1").model).toBe("gpt-5.5-alt");
expect((await requests.json()) as Array<{ model?: string }>).toMatchObject([
{ model: "gpt-5.5" },
{ model: "gpt-5.5-alt" },
]);
});
it("keeps remember prompts prose-only even when they mention repo cleanup", async () => {
@@ -1115,13 +1090,15 @@ describe("qa mock openai server", () => {
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
const debugPayload = requireRecord(await debugResponse.json(), "debug request");
expect(debugPayload.plannedToolName).toBe("sessions_spawn");
const plannedToolArgs = requireRecord(debugPayload.plannedToolArgs, "planned tool args");
expect(plannedToolArgs.task).toBe("Report the visible code");
expect(plannedToolArgs.label).toBe("qa-fork-context");
expect(plannedToolArgs.context).toBe("fork");
expect(plannedToolArgs.mode).toBe("run");
expect(await debugResponse.json()).toMatchObject({
plannedToolName: "sessions_spawn",
plannedToolArgs: {
task: "Report the visible code",
label: "qa-fork-context",
context: "fork",
mode: "run",
},
});
});
it("drives yielded-parent subagent fallback QA through sessions_spawn and sessions_yield", async () => {
@@ -1135,15 +1112,16 @@ describe("qa mock openai server", () => {
input: [makeUserInput(prompt)],
});
const spawnDebug = requireRecord(
await (await fetch(`${server.baseUrl}/debug/last-request`)).json(),
"spawn debug request",
);
expect(spawnDebug.plannedToolName).toBe("sessions_spawn");
const spawnArgs = requireRecord(spawnDebug.plannedToolArgs, "spawn planned tool args");
expect(spawnArgs.label).toBe("qa-direct-fallback-worker");
expect(spawnArgs.thread).toBe(false);
expect(spawnArgs.mode).toBe("run");
await expect(
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
).resolves.toMatchObject({
plannedToolName: "sessions_spawn",
plannedToolArgs: {
label: "qa-direct-fallback-worker",
thread: false,
mode: "run",
},
});
const body = await expectResponsesText(server, {
stream: true,
@@ -1164,11 +1142,11 @@ describe("qa mock openai server", () => {
expect(body).toContain('"name":"sessions_yield"');
expect(body).toContain("QA-SUBAGENT-DIRECT-FALLBACK-OK");
const yieldDebug = requireRecord(
await (await fetch(`${server.baseUrl}/debug/last-request`)).json(),
"yield debug request",
);
expect(yieldDebug.plannedToolName).toBe("sessions_yield");
await expect(
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
).resolves.toMatchObject({
plannedToolName: "sessions_yield",
});
});
it("returns no visible announce output for the direct fallback QA marker", async () => {
@@ -1269,13 +1247,18 @@ describe("qa mock openai server", () => {
const server = await startMockServer();
const childToken = "QA_SUBAGENT_CHILD_DIRECT";
const childPayload = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
input: [makeUserInput(threadSubagentTask(childToken))],
await expect(
expectResponsesJson<{ output?: Array<{ content?: Array<{ text?: string }> }> }>(server, {
stream: false,
input: [makeUserInput(threadSubagentTask(childToken))],
}),
).resolves.toMatchObject({
output: [
{
content: [{ text: childToken }],
},
],
});
expect(outputText(childPayload)).toBe(childToken);
});
it("plans memory tools and serves mock image generations", async () => {
@@ -1323,18 +1306,20 @@ describe("qa mock openai server", () => {
}),
});
expect(image.status).toBe(200);
const imagePayload = requireRecord(await image.json(), "image response");
const imageData = requireArray(imagePayload.data, "image data");
expect(typeof requireRecord(imageData[0], "image data 0").b64_json).toBe("string");
expect(await image.json()).toMatchObject({
data: [{ b64_json: expect.any(String) }],
});
const imageRequests = await fetch(`${server.baseUrl}/debug/image-generations`);
expect(imageRequests.status).toBe(200);
const imageRequestLog = requireArray(await imageRequests.json(), "image generation requests");
const imageRequest = requireRecord(imageRequestLog[0], "image generation request 0");
expect(imageRequest.model).toBe("gpt-image-1");
expect(imageRequest.prompt).toBe("Draw a QA lighthouse");
expect(imageRequest.n).toBe(1);
expect(imageRequest.size).toBe("1024x1024");
expect(await imageRequests.json()).toMatchObject([
{
model: "gpt-image-1",
prompt: "Draw a QA lighthouse",
n: 1,
size: "1024x1024",
},
]);
});
it("supports advanced QA memory and subagent recovery prompts", async () => {
@@ -1666,9 +1651,10 @@ describe("qa mock openai server", () => {
);
const lastRequest = await fetch(`${server.baseUrl}/debug/last-request`);
expect(lastRequest.status).toBe(200);
const lastRequestPayload = requireRecord(await lastRequest.json(), "last request");
expect(String(lastRequestPayload.instructions)).toContain("<active_memory_plugin>");
expect(String(lastRequestPayload.allInputText)).toContain("<active_memory_plugin>");
expect(await lastRequest.json()).toMatchObject({
instructions: expect.stringContaining("<active_memory_plugin>"),
allInputText: expect.stringContaining("<active_memory_plugin>"),
});
const spawn = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
@@ -1754,7 +1740,17 @@ describe("qa mock openai server", () => {
}),
});
expect(final.status).toBe(200);
expect(outputText(await final.json())).toBe("subagent-1: ok\nsubagent-2: ok");
expect(await final.json()).toMatchObject({
output: [
{
content: [
{
text: "subagent-1: ok\nsubagent-2: ok",
},
],
},
],
});
});
it("completes subagent fanout from a continuation turn without tool output", async () => {
@@ -1821,7 +1817,17 @@ describe("qa mock openai server", () => {
}),
});
expect(phaseOnlyFinal.status).toBe(200);
expect(outputText(await phaseOnlyFinal.json())).toBe("subagent-1: ok\nsubagent-2: ok");
expect(await phaseOnlyFinal.json()).toMatchObject({
output: [
{
content: [
{
text: "subagent-1: ok\nsubagent-2: ok",
},
],
},
],
});
});
it("does not let fanout completion state hijack child worker replies", async () => {
@@ -1885,7 +1891,17 @@ describe("qa mock openai server", () => {
}),
});
expect(childReply.status).toBe(200);
expect(outputText(await childReply.json())).toBe("ALPHA-OK");
expect(await childReply.json()).toMatchObject({
output: [
{
content: [
{
text: "ALPHA-OK",
},
],
},
],
});
});
it("keeps subagent fanout state isolated per mock server instance", async () => {
@@ -1963,7 +1979,13 @@ describe("qa mock openai server", () => {
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe("HEARTBEAT_OK");
expect(await response.json()).toMatchObject({
output: [
{
content: [{ text: "HEARTBEAT_OK" }],
},
],
});
});
it("returns exact markers for visible and hot-installed skills", async () => {
@@ -1996,7 +2018,13 @@ describe("qa mock openai server", () => {
}),
});
expect(visible.status).toBe(200);
expect(outputText(await visible.json())).toBe("VISIBLE-SKILL-OK");
expect(await visible.json()).toMatchObject({
output: [
{
content: [{ text: "VISIBLE-SKILL-OK" }],
},
],
});
const hot = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
@@ -2019,7 +2047,13 @@ describe("qa mock openai server", () => {
}),
});
expect(hot.status).toBe(200);
expect(outputText(await hot.json())).toBe("HOT-INSTALL-OK");
expect(await hot.json()).toMatchObject({
output: [
{
content: [{ text: "HOT-INSTALL-OK" }],
},
],
});
});
it("uses the latest exact marker directive from conversation history", async () => {
@@ -2062,7 +2096,13 @@ describe("qa mock openai server", () => {
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe("NEW_TOKEN");
expect(await response.json()).toMatchObject({
output: [
{
content: [{ text: "NEW_TOKEN" }],
},
],
});
});
it("lets the latest exact marker prompt beat stale Telegram session_status history", async () => {
@@ -2105,7 +2145,13 @@ describe("qa mock openai server", () => {
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe("QA-TELEGRAM-REPLY-CHAIN-OK");
expect(await response.json()).toMatchObject({
output: [
{
content: [{ text: "QA-TELEGRAM-REPLY-CHAIN-OK" }],
},
],
});
});
it("does not repeat stale Telegram session_status for later ordinary prompts", async () => {
@@ -2192,7 +2238,13 @@ describe("qa mock openai server", () => {
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe("QA_CANARY_TEST");
expect(await response.json()).toMatchObject({
output: [
{
content: [{ text: "QA_CANARY_TEST" }],
},
],
});
});
it("uses image generation directives from request context when the latest user text is generic", async () => {
@@ -2221,10 +2273,15 @@ describe("qa mock openai server", () => {
});
expect(toolPlan.status).toBe(200);
const toolPlanOutput = outputItem(await toolPlan.json());
expect(toolPlanOutput.type).toBe("function_call");
expect(toolPlanOutput.name).toBe("image_generate");
expect(String(toolPlanOutput.arguments)).toContain("qa-lighthouse.png");
expect(await toolPlan.json()).toMatchObject({
output: [
{
type: "function_call",
name: "image_generate",
arguments: expect.stringContaining("qa-lighthouse.png"),
},
],
});
const toolResult = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
@@ -2255,7 +2312,13 @@ describe("qa mock openai server", () => {
});
expect(toolResult.status).toBe(200);
expect(outputText(await toolResult.json())).toContain("MEDIA:/tmp/qa-lighthouse.png");
expect(await toolResult.json()).toMatchObject({
output: [
{
content: [{ text: expect.stringContaining("MEDIA:/tmp/qa-lighthouse.png") }],
},
],
});
});
it("records image inputs and describes attached images", async () => {
@@ -2301,8 +2364,11 @@ describe("qa mock openai server", () => {
const debug = await fetch(`${server.baseUrl}/debug/requests`);
expect(debug.status).toBe(200);
const requestLog = requireArray(await debug.json(), "debug requests");
expect(requireRecord(requestLog[0], "debug request 0").imageInputCount).toBe(1);
expect(await debug.json()).toMatchObject([
expect.objectContaining({
imageInputCount: 1,
}),
]);
});
it("recognizes OpenAI-compatible image_url parts as image inputs", async () => {
@@ -2346,7 +2412,9 @@ describe("qa mock openai server", () => {
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debug.status).toBe(200);
expect(requireRecord(await debug.json(), "debug request").imageInputCount).toBe(1);
expect(await debug.json()).toMatchObject({
imageInputCount: 1,
});
});
it("handles deeply nested image input shapes without recursive traversal failure", async () => {
@@ -2388,7 +2456,9 @@ describe("qa mock openai server", () => {
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debug.status).toBe(200);
expect(requireRecord(await debug.json(), "debug request").imageInputCount).toBe(1);
expect(await debug.json()).toMatchObject({
imageInputCount: 1,
});
});
it("describes reattached generated images in the roundtrip flow", async () => {
@@ -2512,7 +2582,17 @@ describe("qa mock openai server", () => {
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toContain("model switch handoff confirmed");
expect(await response.json()).toMatchObject({
output: [
{
content: [
{
text: expect.stringContaining("model switch handoff confirmed"),
},
],
},
],
});
});
it("returns NO_REPLY for unmentioned group chatter", async () => {
@@ -2545,7 +2625,13 @@ describe("qa mock openai server", () => {
}),
});
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe("NO_REPLY");
expect(await response.json()).toMatchObject({
output: [
{
content: [{ text: "NO_REPLY" }],
},
],
});
});
it("advertises Anthropic claude-opus-4-6 baseline model on /v1/models", async () => {
@@ -2613,9 +2699,10 @@ describe("qa mock openai server", () => {
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
const debugPayload = requireRecord(await debugResponse.json(), "debug request");
expect(debugPayload.model).toBe("claude-opus-4-6");
expect(debugPayload.plannedToolName).toBe("read");
expect(await debugResponse.json()).toMatchObject({
model: "claude-opus-4-6",
plannedToolName: "read",
});
});
it("preserves Anthropic /v1/messages declared tools for explicit sessions_spawn prompts", async () => {
@@ -2656,17 +2743,20 @@ describe("qa mock openai server", () => {
| { name: string; input: Record<string, unknown> }
| undefined;
expect(toolUseBlock?.name).toBe("sessions_spawn");
expect(toolUseBlock?.input.task).toBe(threadSubagentTask("QA_SUBAGENT_CHILD_ANTHROPIC"));
expect(toolUseBlock?.input.label).toBe("qa-thread-subagent");
expect(toolUseBlock?.input.thread).toBe(true);
expect(toolUseBlock?.input.mode).toBe("session");
expect(toolUseBlock?.input.runTimeoutSeconds).toBe(30);
expect(toolUseBlock?.input).toMatchObject({
task: threadSubagentTask("QA_SUBAGENT_CHILD_ANTHROPIC"),
label: "qa-thread-subagent",
thread: true,
mode: "session",
runTimeoutSeconds: 30,
});
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
const debugPayload = requireRecord(await debugResponse.json(), "debug request");
expect(debugPayload.model).toBe("claude-opus-4-6");
expect(debugPayload.plannedToolName).toBe("sessions_spawn");
expect(await debugResponse.json()).toMatchObject({
model: "claude-opus-4-6",
plannedToolName: "sessions_spawn",
});
});
it("dispatches Anthropic /v1/messages tool_result follow-ups through the shared scenario logic", async () => {
@@ -3094,86 +3184,108 @@ describe("qa mock openai server", () => {
expect(toolPlan).toContain('"name":"read"');
expect(toolPlan).toContain("QA_KICKOFF_TASK.md");
const reasoningPayload = await expectResponsesJson<{
output?: Array<{ type?: string; id?: string; summary?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_REASONING_ONLY_RECOVERY_PROMPT),
expect(
await expectResponsesJson<{
output?: Array<{ type?: string; id?: string; summary?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_REASONING_ONLY_RECOVERY_PROMPT),
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
},
],
}),
).toMatchObject({
output: [
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
type: "reasoning",
id: "rs_mock_reasoning_recovery",
summary: [{ text: expect.stringContaining("Need visible answer") }],
},
],
});
const reasoningOutput = outputItem(reasoningPayload);
expect(reasoningOutput.type).toBe("reasoning");
expect(reasoningOutput.id).toBe("rs_mock_reasoning_recovery");
const reasoningSummary = requireArray(reasoningOutput.summary, "reasoning summary");
expect(String(requireRecord(reasoningSummary[0], "reasoning summary 0").text)).toContain(
"Need visible answer",
);
const recoveredPayload = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_REASONING_ONLY_RECOVERY_PROMPT),
makeUserInput(QA_REASONING_ONLY_RETRY_INSTRUCTION),
expect(
await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_REASONING_ONLY_RECOVERY_PROMPT),
makeUserInput(QA_REASONING_ONLY_RETRY_INSTRUCTION),
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
},
],
}),
).toMatchObject({
output: [
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
content: [{ text: "REASONING-RECOVERED-OK" }],
},
],
});
expect(outputText(recoveredPayload)).toBe("REASONING-RECOVERED-OK");
const requests = await fetch(`${server.baseUrl}/debug/requests`);
expect(requests.status).toBe(200);
const requestLog = requireArray(await requests.json(), "debug requests");
expect(requireRecord(requestLog[0], "debug request 0").plannedToolName).toBe("read");
expect(String(requireRecord(requestLog[1], "debug request 1").allInputText)).toContain(
QA_REASONING_ONLY_RECOVERY_PROMPT,
);
expect(String(requireRecord(requestLog[2], "debug request 2").allInputText)).toContain(
QA_REASONING_ONLY_RETRY_INSTRUCTION,
);
expect(await requests.json()).toMatchObject([
{ plannedToolName: "read" },
{ allInputText: expect.stringContaining(QA_REASONING_ONLY_RECOVERY_PROMPT) },
{ allInputText: expect.stringContaining(QA_REASONING_ONLY_RETRY_INSTRUCTION) },
]);
});
it("scripts the GPT-5.5 thinking visibility switch prompts", async () => {
const server = await startMockServer();
const offPayload = await expectResponsesJson<{
output?: Array<{ type?: string; content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [makeUserInput(QA_THINKING_VISIBILITY_OFF_PROMPT)],
expect(
await expectResponsesJson<{
output?: Array<{ type?: string; content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [makeUserInput(QA_THINKING_VISIBILITY_OFF_PROMPT)],
}),
).toMatchObject({
output: [
{
type: "message",
content: [{ text: "THINKING-OFF-OK" }],
},
],
});
expect(outputItem(offPayload).type).toBe("message");
expect(outputText(offPayload)).toBe("THINKING-OFF-OK");
const maxPayload = await expectResponsesJson<{
output?: Array<{
type?: string;
id?: string;
summary?: Array<{ text?: string }>;
content?: Array<{ text?: string }>;
}>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [makeUserInput(QA_THINKING_VISIBILITY_MAX_PROMPT)],
expect(
await expectResponsesJson<{
output?: Array<{
type?: string;
id?: string;
summary?: Array<{ text?: string }>;
content?: Array<{ text?: string }>;
}>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [makeUserInput(QA_THINKING_VISIBILITY_MAX_PROMPT)],
}),
).toMatchObject({
output: [
{
type: "reasoning",
id: "rs_mock_thinking_visibility_max",
summary: [],
},
{
type: "message",
content: [{ text: "THINKING-MAX-OK" }],
},
],
});
const maxReasoning = outputItem(maxPayload);
expect(maxReasoning.type).toBe("reasoning");
expect(maxReasoning.id).toBe("rs_mock_thinking_visibility_max");
expect(maxReasoning.summary).toEqual([]);
expect(outputItem(maxPayload, 1).type).toBe("message");
expect(outputText(maxPayload, 1)).toBe("THINKING-MAX-OK");
});
it("keeps the reasoning-only side-effect path ready for no-auto-retry QA coverage", async () => {
@@ -3187,22 +3299,23 @@ describe("qa mock openai server", () => {
expect(toolPlan).toContain('"name":"write"');
expect(toolPlan).toContain("reasoning-only-side-effect.txt");
const sideEffectPayload = await expectResponsesJson<{
output?: Array<{ type?: string; id?: string }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_REASONING_ONLY_SIDE_EFFECT_PROMPT),
{
type: "function_call_output",
output: "Successfully wrote 28 bytes to reasoning-only-side-effect.txt.",
},
],
expect(
await expectResponsesJson<{
output?: Array<{ type?: string; id?: string }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_REASONING_ONLY_SIDE_EFFECT_PROMPT),
{
type: "function_call_output",
output: "Successfully wrote 28 bytes to reasoning-only-side-effect.txt.",
},
],
}),
).toMatchObject({
output: [{ type: "reasoning", id: "rs_mock_reasoning_side_effect" }],
});
const sideEffectOutput = outputItem(sideEffectPayload);
expect(sideEffectOutput.type).toBe("reasoning");
expect(sideEffectOutput.id).toBe("rs_mock_reasoning_side_effect");
const requests = await fetch(`${server.baseUrl}/debug/requests`);
expect(requests.status).toBe(200);
@@ -3219,38 +3332,50 @@ describe("qa mock openai server", () => {
});
expect(toolPlan).toContain('"name":"read"');
const emptyPayload = await expectResponsesJson<{
output?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_EMPTY_RESPONSE_RECOVERY_PROMPT),
expect(
await expectResponsesJson<{
output?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_EMPTY_RESPONSE_RECOVERY_PROMPT),
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
},
],
}),
).toMatchObject({
output: [
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
content: [{ type: "output_text", text: "" }],
},
],
});
const emptyContent = outputContentItem(emptyPayload);
expect(emptyContent.type).toBe("output_text");
expect(emptyContent.text).toBe("");
const recoveredPayload = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_EMPTY_RESPONSE_RECOVERY_PROMPT),
makeUserInput(QA_EMPTY_RESPONSE_RETRY_INSTRUCTION),
expect(
await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(QA_EMPTY_RESPONSE_RECOVERY_PROMPT),
makeUserInput(QA_EMPTY_RESPONSE_RETRY_INSTRUCTION),
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
},
],
}),
).toMatchObject({
output: [
{
type: "function_call_output",
output: "QA mission: Understand this OpenClaw repo from source + docs before acting.",
content: [{ text: "EMPTY-RECOVERED-OK" }],
},
],
});
expect(outputText(recoveredPayload)).toBe("EMPTY-RECOVERED-OK");
});
it("can keep emitting empty GPT turns when the single retry budget should exhaust", async () => {

View File

@@ -172,7 +172,6 @@ const QA_SKILL_WORKSHOP_GIF_PROMPT_RE =
/externally sourced animated GIF asset|animated GIF asset in a product UI/i;
const QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE = /Review transcript for durable skill updates/i;
const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small project/i;
const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i;
type MockScenarioState = {
subagentFanoutPhase: number;
@@ -673,11 +672,6 @@ function extractActiveMemorySummary(text: string) {
return match?.[1] ? decodeXmlEntities(match[1]).trim() : null;
}
function extractToolSearchTarget(text: string): string | null {
const match = /\btarget=([A-Za-z0-9_.:-]+)\b/.exec(text);
return match?.[1]?.trim() || null;
}
function isActiveMemorySubagentPrompt(text: string) {
return text.includes("You are a memory search agent.");
}
@@ -1422,22 +1416,6 @@ async function buildResponsesPayload(
path: readTargetFromPrompt(toolProgressPrompt || prompt || allInputText),
});
};
if (QA_TOOL_SEARCH_PROMPT_RE.test(allInputText) && !toolOutput) {
const targetTool = extractToolSearchTarget(allInputText);
if (targetTool && hasDeclaredTool(body, "tool_search_code")) {
return buildToolCallEventsWithArgs("tool_search_code", {
code: [
`const hits = await openclaw.tools.search(${JSON.stringify(targetTool)}, { limit: 1 });`,
"const match = hits.find((tool) => tool.name === " + JSON.stringify(targetTool) + ");",
"if (!match) throw new Error('target tool not found');",
"return await openclaw.tools.call(match.id, { marker: 'code-mode' });",
].join("\n"),
});
}
if (targetTool && hasDeclaredTool(body, targetTool)) {
return buildToolCallEventsWithArgs(targetTool, { marker: "normal" });
}
}
if (
allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) &&
/Internal task completion event/i.test(allInputText)

View File

@@ -306,62 +306,6 @@ function createContext(overrides?: {
};
}
type UnknownMock = { mock: { calls: unknown[][] } };
function mockCallArg(mock: unknown, index: number, label: string, argIndex = 0): unknown {
const calls = (mock as UnknownMock).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`Expected ${label} to be a mock`);
}
const call = calls[index];
if (!call) {
throw new Error(`Expected ${label} call ${index + 1}`);
}
return call[argIndex];
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(
actual: Record<string, unknown>,
expected: Record<string, unknown>,
): void {
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
}
function slackInteractionPayload(callIndex = 0): Record<string, unknown> {
const eventText = mockCallArg(enqueueSystemEventMock, callIndex, "enqueueSystemEvent");
if (typeof eventText !== "string") {
throw new Error("Expected Slack interaction event text");
}
return JSON.parse(eventText.replace("Slack interaction: ", "")) as Record<string, unknown>;
}
function chatUpdateCall(app: { client: { chat: { update: unknown } } }, callIndex = 0) {
return requireRecord(
mockCallArg(app.client.chat.update, callIndex, "chat.update"),
"chat.update",
);
}
function inputByActionId(
inputs: Array<Record<string, unknown>>,
actionId: string,
): Record<string, unknown> {
const input = inputs.find((entry) => entry.actionId === actionId);
if (!input) {
throw new Error(`Expected input ${actionId}`);
}
return input;
}
describe("registerSlackInteractionEvents", () => {
beforeAll(async () => {
({ registerSlackInteractionEvents } = await import("./interactions.js"));
@@ -427,10 +371,21 @@ describe("registerSlackInteractionEvents", () => {
expect(ack).toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
const eventText = mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent");
expect(typeof eventText === "string" && eventText.startsWith("Slack interaction: ")).toBe(true);
const payload = slackInteractionPayload();
expectRecordFields(payload, {
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
expect(eventText.startsWith("Slack interaction: ")).toBe(true);
const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as {
actionId: string;
actionType: string;
value: string;
userId: string;
teamId?: string;
triggerId?: string;
responseUrl?: string;
channelId: string;
messageTs: string;
threadTs?: string;
};
expect(payload).toMatchObject({
actionId: "openclaw:verify",
actionType: "button",
value: "approved",
@@ -519,7 +474,7 @@ describe("registerSlackInteractionEvents", () => {
}) => Promise<unknown>;
}
| undefined;
expectRecordFields(requireRecord(dispatchCall, "dispatch call"), {
expect(dispatchCall).toMatchObject({
channel: "slack",
data: "codex:approve:thread-1",
dedupeId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
@@ -530,24 +485,24 @@ describe("registerSlackInteractionEvents", () => {
namespace: "codex",
payload: "approve:thread-1",
});
const registrationCtx = requireRecord(
mockCallArg(registrationHandler, 0, "registration handler"),
"registration handler ctx",
expect(registrationHandler).toHaveBeenCalledWith(
expect.objectContaining({
accountId: ctx.accountId,
conversationId: "C1",
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
threadId: "100.100",
auth: expect.objectContaining({
isAuthorizedSender: true,
}),
interaction: expect.objectContaining({
actionId: "codex",
value: "approve:thread-1",
data: "codex:approve:thread-1",
namespace: "codex",
payload: "approve:thread-1",
}),
}),
);
expectRecordFields(registrationCtx, {
accountId: ctx.accountId,
conversationId: "C1",
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
threadId: "100.100",
});
expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(true);
expectRecordFields(requireRecord(registrationCtx.interaction, "registration interaction"), {
actionId: "codex",
value: "approve:thread-1",
data: "codex:approve:thread-1",
namespace: "codex",
payload: "approve:thread-1",
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(app.client.chat.update).not.toHaveBeenCalled();
});
@@ -614,11 +569,13 @@ describe("registerSlackInteractionEvents", () => {
payload: "approve:thread-1",
});
const registrationCtx = requireRecord(
mockCallArg(registrationHandler, 0, "registration handler"),
"registration handler ctx",
expect(registrationHandler).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
isAuthorizedSender: false,
}),
}),
);
expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(false);
});
it("passes true command auth to Slack plugin interactions for allowlisted senders", async () => {
@@ -683,11 +640,13 @@ describe("registerSlackInteractionEvents", () => {
payload: "approve:thread-1",
});
const registrationCtx = requireRecord(
mockCallArg(registrationHandler, 0, "registration handler"),
"registration handler ctx",
expect(registrationHandler).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
isAuthorizedSender: true,
}),
}),
);
expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(true);
});
it("treats Slack reply buttons as plain interaction events instead of plugin dispatch", async () => {
@@ -726,14 +685,9 @@ describe("registerSlackInteractionEvents", () => {
expect(ack).toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
const eventText = mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent");
expect(eventText).toContain('"actionId":"openclaw:reply_button"');
expectRecordFields(
requireRecord(
mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent", 1),
"event options",
),
{
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining('"actionId":"openclaw:reply_button"'),
expect.objectContaining({
contextKey: "slack:interaction:C1:100.200:openclaw:reply_button",
deliveryContext: {
accountId: "default",
@@ -743,7 +697,7 @@ describe("registerSlackInteractionEvents", () => {
},
sessionKey: "agent:ops:slack:channel:C1",
trusted: false,
},
}),
);
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "C1",
@@ -897,12 +851,14 @@ describe("registerSlackInteractionEvents", () => {
senderId: "U123",
});
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
expectRecordFields(chatUpdateCall(app), {
channel: "C1",
ts: "100.200",
text: "Approve this bind?",
blocks: [],
});
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C1",
ts: "100.200",
text: "Approve this bind?",
blocks: [],
}),
);
expect(respond).toHaveBeenCalledWith({
text: "Binding updated.",
response_type: "ephemeral",
@@ -958,12 +914,14 @@ describe("registerSlackInteractionEvents", () => {
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expectRecordFields(chatUpdateCall(app), {
channel: "C1",
ts: "100.200",
text: "Exec approval required",
blocks: [],
});
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C1",
ts: "100.200",
text: "Exec approval required",
blocks: [],
}),
);
expect(respond).not.toHaveBeenCalled();
});
@@ -1186,16 +1144,18 @@ describe("registerSlackInteractionEvents", () => {
expect(payload.selectedValues).toEqual(["canary"]);
expect(payload.selectedLabels).toEqual(["Canary"]);
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
expectRecordFields(chatUpdateCall(app), {
channel: "C1",
ts: "111.222",
blocks: [
{
type: "context",
elements: [{ type: "mrkdwn", text: ":white_check_mark: *Canary* selected by <@U555>" }],
},
],
});
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C1",
ts: "111.222",
blocks: [
{
type: "context",
elements: [{ type: "mrkdwn", text: ":white_check_mark: *Canary* selected by <@U555>" }],
},
],
}),
);
});
it("blocks block actions from users outside configured channel users allowlist", async () => {
@@ -1483,21 +1443,23 @@ describe("registerSlackInteractionEvents", () => {
});
expect(ack).toHaveBeenCalled();
expectRecordFields(chatUpdateCall(app), {
channel: "C1",
ts: "111.223",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: ":white_check_mark: *Canary\\_\\*\\`\\~&lt;&amp;&gt;* selected by <@U556>",
},
],
},
],
});
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C1",
ts: "111.223",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: ":white_check_mark: *Canary\\_\\*\\`\\~&lt;&amp;&gt;* selected by <@U556>",
},
],
},
],
}),
);
});
it("falls back to container channel and message timestamps", async () => {
@@ -1538,7 +1500,7 @@ describe("registerSlackInteractionEvents", () => {
threadTs?: string;
teamId?: string;
};
expectRecordFields(payload as unknown as Record<string, unknown>, {
expect(payload).toMatchObject({
channelId: "C222",
messageTs: "222.333",
threadTs: "222.111",
@@ -1586,21 +1548,23 @@ describe("registerSlackInteractionEvents", () => {
expect(ack).toHaveBeenCalled();
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
expectRecordFields(chatUpdateCall(app), {
channel: "C2",
ts: "333.444",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: ":white_check_mark: *Alpha, Beta, Gamma +1* selected by <@U222>",
},
],
},
],
});
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C2",
ts: "333.444",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: ":white_check_mark: *Alpha, Beta, Gamma +1* selected by <@U222>",
},
],
},
],
}),
);
});
it("renders date/time/datetime picker selections in confirmation rows", async () => {
@@ -1695,42 +1659,56 @@ describe("registerSlackInteractionEvents", () => {
},
});
const firstUpdate = chatUpdateCall(app, 0);
const firstBlocks = firstUpdate.blocks as unknown[];
expectRecordFields(firstUpdate, { channel: "C3", ts: "555.666" });
expect(firstBlocks).toHaveLength(3);
expect(firstBlocks[0]).toEqual({
type: "context",
elements: [{ type: "mrkdwn", text: ":white_check_mark: *2026-02-16* selected by <@U333>" }],
});
expectRecordFields(chatUpdateCall(app, 1), {
channel: "C3",
ts: "555.667",
blocks: [
{
type: "context",
elements: [{ type: "mrkdwn", text: ":white_check_mark: *14:30* selected by <@U333>" }],
},
],
});
expectRecordFields(chatUpdateCall(app, 2), {
channel: "C3",
ts: "555.668",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `:white_check_mark: *${new Date(
selectedDateTimeEpoch * 1000,
).toISOString()}* selected by <@U333>`,
},
],
},
],
});
expect(app.client.chat.update).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
channel: "C3",
ts: "555.666",
blocks: [
{
type: "context",
elements: [
{ type: "mrkdwn", text: ":white_check_mark: *2026-02-16* selected by <@U333>" },
],
},
expect.anything(),
expect.anything(),
],
}),
);
expect(app.client.chat.update).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
channel: "C3",
ts: "555.667",
blocks: [
{
type: "context",
elements: [{ type: "mrkdwn", text: ":white_check_mark: *14:30* selected by <@U333>" }],
},
],
}),
);
expect(app.client.chat.update).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
channel: "C3",
ts: "555.668",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `:white_check_mark: *${new Date(
selectedDateTimeEpoch * 1000,
).toISOString()}* selected by <@U333>`,
},
],
},
],
}),
);
});
it("captures expanded selection and temporal payload fields", async () => {
@@ -1838,7 +1816,7 @@ describe("registerSlackInteractionEvents", () => {
teamId?: string;
channelId?: string;
};
expectRecordFields(payload as unknown as Record<string, unknown>, {
expect(payload).toMatchObject({
actionType: "workflow_button",
workflowTriggerUrl: "[redacted]",
workflowId: "Wf12345",
@@ -1925,7 +1903,7 @@ describe("registerSlackInteractionEvents", () => {
isStackedView?: boolean;
inputs: Array<{ actionId: string; selectedValues?: string[]; inputValue?: string }>;
};
expectRecordFields(payload as unknown as Record<string, unknown>, {
expect(payload).toMatchObject({
interactionType: "view_submission",
actionId: "view:openclaw:deploy_form",
callbackId: "openclaw:deploy_form",
@@ -1938,10 +1916,12 @@ describe("registerSlackInteractionEvents", () => {
viewHash: "[redacted]",
isStackedView: true,
});
const envInput = payload.inputs.find((input) => input.actionId === "env_select");
const notesInput = payload.inputs.find((input) => input.actionId === "notes_input");
expect(envInput?.selectedValues).toEqual(["prod"]);
expect(notesInput?.inputValue).toBe("ship now");
expect(payload.inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ actionId: "env_select", selectedValues: ["prod"] }),
expect.objectContaining({ actionId: "notes_input", inputValue: "ship now" }),
]),
);
expect(trackEvent).toHaveBeenCalledTimes(1);
});
@@ -2165,62 +2145,75 @@ describe("registerSlackInteractionEvents", () => {
richTextPreview?: string;
}>;
};
const inputs = payload.inputs as Array<Record<string, unknown>>;
expectRecordFields(inputByActionId(inputs, "env_select"), {
selectedValues: ["prod"],
selectedLabels: ["Production"],
});
expectRecordFields(inputByActionId(inputs, "assignee_select"), {
selectedValues: ["U900"],
selectedUsers: ["U900"],
});
expectRecordFields(inputByActionId(inputs, "channel_select"), {
selectedValues: ["C900"],
selectedChannels: ["C900"],
});
expectRecordFields(inputByActionId(inputs, "convo_select"), {
selectedValues: ["G900"],
selectedConversations: ["G900"],
});
expect(inputByActionId(inputs, "date_select").selectedDate).toBe("2026-02-16");
expect(inputByActionId(inputs, "time_select").selectedTime).toBe("12:45");
expect(inputByActionId(inputs, "datetime_select").selectedDateTime).toBe(1_771_632_300);
expectRecordFields(inputByActionId(inputs, "radio_select"), {
selectedValues: ["blue"],
selectedLabels: ["Blue"],
});
expectRecordFields(inputByActionId(inputs, "checks_select"), {
selectedValues: ["a", "b"],
selectedLabels: ["A", "B"],
});
expectRecordFields(inputByActionId(inputs, "number_input"), {
inputKind: "number",
inputNumber: 42.5,
});
expectRecordFields(inputByActionId(inputs, "email_input"), {
inputKind: "email",
inputEmail: "team@openclaw.ai",
});
expectRecordFields(inputByActionId(inputs, "url_input"), {
inputKind: "url",
inputUrl: "https://docs.openclaw.ai/",
});
expectRecordFields(inputByActionId(inputs, "richtext_input"), {
inputKind: "rich_text",
richTextPreview: "Ship this now with canary metrics",
richTextValue: {
type: "rich_text",
elements: [
{
type: "rich_text_section",
expect(payload.inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({
actionId: "env_select",
selectedValues: ["prod"],
selectedLabels: ["Production"],
}),
expect.objectContaining({
actionId: "assignee_select",
selectedValues: ["U900"],
selectedUsers: ["U900"],
}),
expect.objectContaining({
actionId: "channel_select",
selectedValues: ["C900"],
selectedChannels: ["C900"],
}),
expect.objectContaining({
actionId: "convo_select",
selectedValues: ["G900"],
selectedConversations: ["G900"],
}),
expect.objectContaining({ actionId: "date_select", selectedDate: "2026-02-16" }),
expect.objectContaining({ actionId: "time_select", selectedTime: "12:45" }),
expect.objectContaining({ actionId: "datetime_select", selectedDateTime: 1_771_632_300 }),
expect.objectContaining({
actionId: "radio_select",
selectedValues: ["blue"],
selectedLabels: ["Blue"],
}),
expect.objectContaining({
actionId: "checks_select",
selectedValues: ["a", "b"],
selectedLabels: ["A", "B"],
}),
expect.objectContaining({
actionId: "number_input",
inputKind: "number",
inputNumber: 42.5,
}),
expect.objectContaining({
actionId: "email_input",
inputKind: "email",
inputEmail: "team@openclaw.ai",
}),
expect.objectContaining({
actionId: "url_input",
inputKind: "url",
inputUrl: "https://docs.openclaw.ai/",
}),
expect.objectContaining({
actionId: "richtext_input",
inputKind: "rich_text",
richTextPreview: "Ship this now with canary metrics",
richTextValue: {
type: "rich_text",
elements: [
{ type: "text", text: "Ship this now" },
{ type: "text", text: "with canary metrics" },
{
type: "rich_text_section",
elements: [
{ type: "text", text: "Ship this now" },
{ type: "text", text: "with canary metrics" },
],
},
],
},
],
},
});
}),
]),
);
});
it("truncates rich text preview to keep payload summaries compact", async () => {
@@ -2337,7 +2330,7 @@ describe("registerSlackInteractionEvents", () => {
isStackedView?: boolean;
inputs: Array<{ actionId: string; selectedValues?: string[] }>;
};
expectRecordFields(payload as unknown as Record<string, unknown>, {
expect(payload).toMatchObject({
interactionType: "view_closed",
actionId: "view:openclaw:deploy_form",
callbackId: "openclaw:deploy_form",
@@ -2351,10 +2344,11 @@ describe("registerSlackInteractionEvents", () => {
viewHash: "[redacted]",
isStackedView: true,
});
expect(
inputByActionId(payload.inputs as Array<Record<string, unknown>>, "env_select")
.selectedValues,
).toEqual(["canary"]);
expect(payload.inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ actionId: "env_select", selectedValues: ["canary"] }),
]),
);
expect(trackEvent).toHaveBeenCalledTimes(1);
expect(options.sessionKey).toBe("agent:main:slack:channel:C99");
});

View File

@@ -107,11 +107,6 @@ let resolveTelegramTransport: typeof import("./fetch.js").resolveTelegramTranspo
type TelegramDispatcherPolicy = NonNullable<
ReturnType<typeof resolveTelegramTransport>["dispatcherAttempts"]
>[number]["dispatcherPolicy"];
type DirectTelegramDispatcherPolicy = Extract<TelegramDispatcherPolicy, { mode: "direct" }>;
type ExplicitProxyTelegramDispatcherPolicy = Extract<
TelegramDispatcherPolicy,
{ mode: "explicit-proxy" }
>;
beforeAll(async () => {
({ resolveTelegramApiBase, resolveTelegramFetch, resolveTelegramTransport } =
@@ -226,9 +221,12 @@ function expectStickyAutoSelectDispatcher(
| undefined,
field: "connect" | "proxyTls" | "requestTls" = "connect",
): void {
const options = dispatcher?.options?.[field];
expect(options?.autoSelectFamily).toBe(true);
expect(options?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(dispatcher?.options?.[field]).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
}
function expectHttp1OnlyDispatcher(
@@ -240,7 +238,11 @@ function expectHttp1OnlyDispatcher(
}
| undefined,
): void {
expect(dispatcher?.options?.allowH2).toBe(false);
expect(dispatcher?.options).toEqual(
expect.objectContaining({
allowH2: false,
}),
);
}
function expectPinnedIpv4ConnectDispatcher(args: {
@@ -249,8 +251,12 @@ function expectPinnedIpv4ConnectDispatcher(args: {
followupCall?: number;
}): void {
const pinnedDispatcher = getDispatcherFromUndiciCall(args.pinnedCall);
expect(pinnedDispatcher?.options?.connect?.family).toBe(4);
expect(pinnedDispatcher?.options?.connect?.autoSelectFamily).toBe(false);
expect(pinnedDispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
}),
);
if (args.firstCall) {
expect(getDispatcherFromUndiciCall(args.firstCall)).not.toBe(pinnedDispatcher);
}
@@ -261,9 +267,13 @@ function expectPinnedIpv4ConnectDispatcher(args: {
function expectPinnedFallbackIpDispatcher(callIndex: number) {
const dispatcher = getDispatcherFromUndiciCall(callIndex);
expect(dispatcher?.options?.connect?.family).toBe(4);
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(false);
expect(typeof dispatcher?.options?.connect?.lookup).toBe("function");
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
);
const callback = vi.fn();
(
dispatcher?.options?.connect?.lookup as
@@ -358,9 +368,13 @@ describe("resolveTelegramFetch", () => {
const dispatcher = getDispatcherFromUndiciCall(1);
expectHttp1OnlyDispatcher(dispatcher);
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(true);
expect(dispatcher?.options?.connect?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(typeof dispatcher?.options?.connect?.lookup).toBe("function");
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
);
});
it("emits default transport decisions at debug level", () => {
@@ -386,15 +400,27 @@ describe("resolveTelegramFetch", () => {
await resolved("https://api.telegram.org/botx/getMe");
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
expect(EnvHttpProxyAgentCtor.mock.calls[0]?.[0]?.httpsProxy).toBe("http://127.0.0.1:7890");
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
httpsProxy: "http://127.0.0.1:7890",
}),
);
expect(AgentCtor).not.toHaveBeenCalled();
const dispatcher = getDispatcherFromUndiciCall(1);
expectHttp1OnlyDispatcher(dispatcher);
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(false);
expect(dispatcher?.options?.connect?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(dispatcher?.options?.proxyTls?.autoSelectFamily).toBe(false);
expect(dispatcher?.options?.proxyTls?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: false,
autoSelectFamilyAttemptTimeout: 300,
}),
);
expect(dispatcher?.options?.proxyTls).toEqual(
expect.objectContaining({
autoSelectFamily: false,
autoSelectFamilyAttemptTimeout: 300,
}),
);
});
it("uses the OpenClaw debug proxy URL when no explicit proxy fetch is provided", async () => {
@@ -406,11 +432,12 @@ describe("resolveTelegramFetch", () => {
await resolved("https://api.telegram.org/botTOKEN/getMe");
expect(ProxyAgentCtor).toHaveBeenCalledTimes(1);
const proxyOptions = ProxyAgentCtor.mock.calls[0]?.[0] as
| { allowH2?: boolean; uri?: string }
| undefined;
expect(proxyOptions?.allowH2).toBe(false);
expect(proxyOptions?.uri).toBe("http://127.0.0.1:7777");
expect(ProxyAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
allowH2: false,
uri: "http://127.0.0.1:7777",
}),
);
});
it("uses OPENCLAW_PROXY_URL as a Telegram explicit proxy when proxy env is absent", async () => {
@@ -427,19 +454,23 @@ describe("resolveTelegramFetch", () => {
await transport.fetch("https://api.telegram.org/botTOKEN/getMe");
expect(ProxyAgentCtor).toHaveBeenCalledTimes(1);
const proxyOptions = ProxyAgentCtor.mock.calls[0]?.[0] as
| { allowH2?: boolean; uri?: string; requestTls?: { autoSelectFamily?: boolean } }
| undefined;
expect(proxyOptions?.allowH2).toBe(false);
expect(proxyOptions?.uri).toBe("http://127.0.0.1:7788");
expect(proxyOptions?.requestTls?.autoSelectFamily).toBe(false);
expect(ProxyAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
allowH2: false,
uri: "http://127.0.0.1:7788",
requestTls: expect.objectContaining({
autoSelectFamily: false,
}),
}),
);
expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled();
expect(AgentCtor).not.toHaveBeenCalled();
const dispatcherPolicy = transport.dispatcherAttempts?.[0]?.dispatcherPolicy as
| ExplicitProxyTelegramDispatcherPolicy
| undefined;
expect(dispatcherPolicy?.mode).toBe("explicit-proxy");
expect(dispatcherPolicy?.proxyUrl).toBe("http://127.0.0.1:7788");
expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7788",
}),
);
});
it("preserves caller-provided custom fetch when OPENCLAW_PROXY_URL is present", async () => {
@@ -498,10 +529,18 @@ describe("resolveTelegramFetch", () => {
const dispatcher = getDispatcherFromUndiciCall(1);
expectHttp1OnlyDispatcher(dispatcher);
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(true);
expect(dispatcher?.options?.connect?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(dispatcher?.options?.proxyTls?.autoSelectFamily).toBe(true);
expect(dispatcher?.options?.proxyTls?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
expect(dispatcher?.options?.proxyTls).toEqual(
expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
}),
);
});
it("keeps resolver-scoped transport policy for OpenClaw proxy fetches", async () => {
@@ -524,10 +563,16 @@ describe("resolveTelegramFetch", () => {
expect(AgentCtor).not.toHaveBeenCalled();
const dispatcher = getDispatcherFromUndiciCall(1);
expectHttp1OnlyDispatcher(dispatcher);
expect((dispatcher?.options as { uri?: string } | undefined)?.uri).toBe(
"http://127.0.0.1:7890",
expect(dispatcher?.options).toEqual(
expect.objectContaining({
uri: "http://127.0.0.1:7890",
}),
);
expect(dispatcher?.options?.requestTls).toEqual(
expect.objectContaining({
autoSelectFamily: false,
}),
);
expect(dispatcher?.options?.requestTls?.autoSelectFamily).toBe(false);
});
it("exports fallback dispatcher attempts for Telegram media downloads", async () => {
@@ -550,28 +595,43 @@ describe("resolveTelegramFetch", () => {
expect(transport.dispatcherAttempts).toHaveLength(3);
const [defaultAttempt, ipv4Attempt, pinnedAttempt] = transport.dispatcherAttempts as Array<{
dispatcherPolicy?: DirectTelegramDispatcherPolicy;
dispatcherPolicy?: TelegramDispatcherPolicy;
}>;
const defaultPolicy = defaultAttempt.dispatcherPolicy;
const ipv4Policy = ipv4Attempt.dispatcherPolicy;
const pinnedPolicy = pinnedAttempt.dispatcherPolicy;
expect(defaultPolicy?.mode).toBe("direct");
expect(defaultPolicy?.connect?.autoSelectFamily).toBe(true);
expect(defaultPolicy?.connect?.autoSelectFamilyAttemptTimeout).toBe(300);
expect(typeof defaultPolicy?.connect?.lookup).toBe("function");
expect(ipv4Policy?.mode).toBe("direct");
expect(ipv4Policy?.connect?.family).toBe(4);
expect(ipv4Policy?.connect?.autoSelectFamily).toBe(false);
expect(typeof ipv4Policy?.connect?.lookup).toBe("function");
expect(pinnedPolicy?.mode).toBe("direct");
expect(pinnedPolicy?.pinnedHostname).toEqual({
hostname: "api.telegram.org",
addresses: ["149.154.167.220"],
});
expect(pinnedPolicy?.connect?.family).toBe(4);
expect(pinnedPolicy?.connect?.autoSelectFamily).toBe(false);
expect(typeof pinnedPolicy?.connect?.lookup).toBe("function");
expect(defaultAttempt.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
connect: expect.objectContaining({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
lookup: expect.any(Function),
}),
}),
);
expect(ipv4Attempt.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
connect: expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
}),
);
expect(pinnedAttempt.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "direct",
pinnedHostname: {
hostname: "api.telegram.org",
addresses: ["149.154.167.220"],
},
connect: expect.objectContaining({
family: 4,
autoSelectFamily: false,
lookup: expect.any(Function),
}),
}),
);
});
it("does not blind-retry when sticky IPv4 fallback is disallowed for explicit proxy paths", async () => {
@@ -628,13 +688,20 @@ describe("resolveTelegramFetch", () => {
await resolved("https://api.telegram.org/botx/sendMessage");
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1);
const proxyOptions = EnvHttpProxyAgentCtor.mock.calls[0]?.[0];
expect(proxyOptions?.allowH2).toBe(false);
expect(proxyOptions?.httpProxy).toBe("http://127.0.0.1:7891");
expect(proxyOptions?.httpsProxy).toBe("http://127.0.0.1:7891");
expect(EnvHttpProxyAgentCtor).toHaveBeenCalledWith(
expect.objectContaining({
allowH2: false,
httpProxy: "http://127.0.0.1:7891",
httpsProxy: "http://127.0.0.1:7891",
}),
);
expect(AgentCtor).not.toHaveBeenCalled();
expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy?.mode).toBe("env-proxy");
expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy).toEqual(
expect.objectContaining({
mode: "env-proxy",
}),
);
});
it("arms sticky IPv4 fallback when env proxy init falls back to direct Agent", async () => {
@@ -727,7 +794,11 @@ describe("resolveTelegramFetch", () => {
expect(AgentCtor).toHaveBeenCalledTimes(1);
const dispatcher = getDispatcherFromUndiciCall(1);
expect(dispatcher?.options?.connect?.autoSelectFamily).toBe(false);
expect(dispatcher?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: false,
}),
);
});
it("retries once, keeps sticky IPv4, then recovers to primary dispatcher", async () => {
@@ -763,8 +834,12 @@ describe("resolveTelegramFetch", () => {
expect(eighthDispatcher).toBe(firstDispatcher);
expectStickyAutoSelectDispatcher(firstDispatcher);
expect(secondDispatcher?.options?.connect?.family).toBe(4);
expect(secondDispatcher?.options?.connect?.autoSelectFamily).toBe(false);
expect(secondDispatcher?.options?.connect).toEqual(
expect.objectContaining({
family: 4,
autoSelectFamily: false,
}),
);
expect(loggerDebug).toHaveBeenCalledWith(
expect.stringContaining("fetch fallback: enabling sticky IPv4-only dispatcher"),
);
@@ -1026,8 +1101,16 @@ describe("resolveTelegramFetch", () => {
expect(dispatcherA).not.toBe(dispatcherB);
expect(dispatcherA?.options?.connect?.autoSelectFamily).toBe(false);
expect(dispatcherB?.options?.connect?.autoSelectFamily).toBe(true);
expect(dispatcherA?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: false,
}),
);
expect(dispatcherB?.options?.connect).toEqual(
expect.objectContaining({
autoSelectFamily: true,
}),
);
// Core guarantee: Telegram transport no longer mutates process-global defaults.
expect(setGlobalDispatcher).not.toHaveBeenCalled();
@@ -1047,16 +1130,15 @@ describe("resolveTelegramFetch", () => {
// One direct Agent for the default dispatcher plus two lazy fallbacks not yet touched.
expect(AgentCtor).toHaveBeenCalledTimes(1);
const defaultAgent = AgentCtor.mock.instances[0]?.options;
expect(typeof defaultAgent).toBe("object");
expect((defaultAgent as { allowH2?: boolean } | undefined)?.allowH2).toBe(false);
expect(typeof (defaultAgent as { keepAliveTimeout?: unknown }).keepAliveTimeout).toBe(
"number",
expect(defaultAgent).toEqual(
expect.objectContaining({
allowH2: false,
keepAliveTimeout: expect.any(Number),
keepAliveMaxTimeout: expect.any(Number),
connections: expect.any(Number),
pipelining: expect.any(Number),
}),
);
expect(typeof (defaultAgent as { keepAliveMaxTimeout?: unknown }).keepAliveMaxTimeout).toBe(
"number",
);
expect(typeof (defaultAgent as { connections?: unknown }).connections).toBe("number");
expect(typeof (defaultAgent as { pipelining?: unknown }).pipelining).toBe("number");
const connections = (defaultAgent as { connections?: number }).connections;
expect(connections).toBeGreaterThan(0);
expect(connections).toBeLessThan(100);

View File

@@ -21,7 +21,6 @@ type EmbeddedAgentArgs = {
agentId?: string;
workspaceDir?: string;
sessionFile?: string;
toolsAllow?: string[];
};
function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
@@ -332,39 +331,4 @@ describe("generateVoiceResponse", () => {
expect(args.workspaceDir).toBe("/tmp/openclaw/workspace/voice");
expect(args.sessionFile).toBe("/tmp/openclaw/voice/sessions/session.jsonl");
});
it("passes the routed voice agent explicit tool allowlist to the embedded run", async () => {
const { runtime, runEmbeddedPiAgent } = createAgentRuntime([
{ text: '{"spoken":"No tools needed."}' },
]);
const coreConfig = {
agents: {
list: [
{
id: "voice",
tools: { allow: [] },
},
],
},
} as CoreConfig;
const result = await generateVoiceResponse({
voiceConfig: VoiceCallConfigSchema.parse({
agentId: "voice",
responseModel: "ollama/qwen2.5:1.5b",
responseTimeoutMs: 5000,
}),
coreConfig,
agentRuntime: runtime,
callId: "call-123",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(result.text).toBe("No tools needed.");
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
expect(args.agentId).toBe("voice");
expect(args.toolsAllow).toStrictEqual([]);
});
});

View File

@@ -41,34 +41,6 @@ type VoiceResponsePayload = {
isReasoning?: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readExplicitToolsAllow(value: unknown): string[] | undefined {
if (!isRecord(value)) {
return undefined;
}
const allow = value.allow;
if (!Array.isArray(allow)) {
return undefined;
}
return allow.filter((entry): entry is string => typeof entry === "string");
}
function resolveVoiceAgentToolsAllow(config: CoreConfig, agentId: string): string[] | undefined {
const agents = isRecord(config.agents) ? config.agents : undefined;
const list = Array.isArray(agents?.list) ? agents.list : [];
const agent = list.find((entry) => isRecord(entry) && entry.id === agentId);
if (!isRecord(agent)) {
return undefined;
}
return readExplicitToolsAllow(isRecord(agent.tools) ? agent.tools : undefined);
}
const VOICE_SPOKEN_OUTPUT_CONTRACT = [
"Output format requirements:",
'- Return only valid JSON in this exact shape: {"spoken":"..."}',
@@ -240,7 +212,6 @@ export async function generateVoiceResponse(
explicitSessionKey: sessionKey,
});
const agentId = voiceConfig.agentId ?? "main";
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
// Resolve paths
const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId });
@@ -331,7 +302,6 @@ export async function generateVoiceResponse(
lane: "voice",
extraSystemPrompt,
agentDir,
toolsAllow,
});
const text = extractSpokenTextFromPayloads((result.payloads ?? []) as VoiceResponsePayload[]);

View File

@@ -129,53 +129,6 @@ function expectFirstSendMediaPayload(msg: WebInboundMsg) {
return payload;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`expected ${label} mock calls`);
}
const call = calls[callIndex];
if (!call) {
throw new Error(`expected ${label} call ${callIndex + 1}`);
}
return call[argIndex];
}
function findLoggerContext(mock: unknown, message: string, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`expected ${label} mock calls`);
}
const call = calls.find((entry) => entry[1] === message);
if (!call) {
throw new Error(`expected ${label} message ${message}`);
}
return requireRecord(call[0], `${label} context`);
}
function expectBuffer(value: unknown, label: string) {
expect(Buffer.isBuffer(value), label).toBe(true);
}
function expectQuotedOptions(
options: unknown,
expected: { id: string; fromMe: boolean; participant: string; body: string },
) {
const quoted = requireRecord(requireRecord(options, "reply options").quoted, "quoted message");
const key = requireRecord(quoted.key, "quoted key");
expect(key.id).toBe(expected.id);
expect(key.fromMe).toBe(expected.fromMe);
expect(key.participant).toBe(expected.participant);
expect(quoted.message).toEqual({ conversation: expected.body });
}
function mockSecondReplySuccess(msg: WebInboundMsg) {
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
acceptedSendResult("text", "reply-retry-2"),
@@ -253,14 +206,21 @@ describe("deliverWebReply", () => {
expect(msg.reply).toHaveBeenCalledTimes(2);
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
expect(typeof mockCallArg(replyLogger.info, 0, 0, "replyLogger.info")).toBe("object");
expect(mockCallArg(replyLogger.info, 0, 1, "replyLogger.info")).toBe("auto-reply sent (text)");
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)");
expect(delivery.providerAccepted).toBe(true);
expect(listMessageReceiptPlatformIds(delivery.receipt)).toEqual(["reply-sent-1"]);
expect(delivery.receipt.primaryPlatformMessageId).toBe("reply-sent-1");
expect(delivery.receipt.platformMessageIds).toEqual(["reply-sent-1"]);
expect(delivery.receipt.parts[0]?.platformMessageId).toBe("reply-sent-1");
expect(delivery.receipt.parts[0]?.kind).toBe("text");
expect(delivery.receipt).toEqual(
expect.objectContaining({
primaryPlatformMessageId: "reply-sent-1",
platformMessageIds: ["reply-sent-1"],
}),
);
expect(delivery.receipt.parts).toEqual([
expect.objectContaining({
platformMessageId: "reply-sent-1",
kind: "text",
}),
]);
});
it("reports text replies that Baileys did not accept", async () => {
@@ -277,11 +237,15 @@ describe("deliverWebReply", () => {
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(delivery.receipt.platformMessageIds).toEqual([]);
expect(delivery.receipt.parts).toEqual([]);
expect(delivery.providerAccepted).toBe(false);
expect(typeof mockCallArg(replyLogger.warn, 0, 0, "replyLogger.warn")).toBe("object");
expect(mockCallArg(replyLogger.warn, 0, 1, "replyLogger.warn")).toBe(
expect(delivery).toMatchObject({
receipt: expect.objectContaining({
platformMessageIds: [],
parts: [],
}),
providerAccepted: false,
});
expect(replyLogger.warn).toHaveBeenCalledWith(
expect.any(Object),
"auto-reply text was not accepted by WhatsApp provider",
);
});
@@ -374,20 +338,34 @@ describe("deliverWebReply", () => {
});
expect(msg.reply).toHaveBeenCalledTimes(2);
expect(mockCallArg(msg.reply, 0, 0, "reply")).toBe("aaa");
expectQuotedOptions(mockCallArg(msg.reply, 0, 1, "reply"), {
id: "reply-1",
fromMe: true,
participant: "111@s.whatsapp.net",
body: "quoted body",
});
expect(mockCallArg(msg.reply, 1, 0, "reply")).toBe("aaa");
expectQuotedOptions(mockCallArg(msg.reply, 1, 1, "reply"), {
id: "reply-1",
fromMe: true,
participant: "111@s.whatsapp.net",
body: "quoted body",
});
expect(msg.reply).toHaveBeenNthCalledWith(
1,
"aaa",
expect.objectContaining({
quoted: expect.objectContaining({
key: expect.objectContaining({
id: "reply-1",
fromMe: true,
participant: "111@s.whatsapp.net",
}),
message: { conversation: "quoted body" },
}),
}),
);
expect(msg.reply).toHaveBeenNthCalledWith(
2,
"aaa",
expect.objectContaining({
quoted: expect.objectContaining({
key: expect.objectContaining({
id: "reply-1",
fromMe: true,
participant: "111@s.whatsapp.net",
}),
message: { conversation: "quoted body" },
}),
}),
);
});
it.each(["connection closed", "operation timed out"])(
@@ -449,18 +427,16 @@ describe("deliverWebReply", () => {
localRoots: mediaLocalRoots,
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
image: expect.any(Buffer),
caption: "aaa",
mimetype: "image/jpeg",
}),
undefined,
);
expectBuffer(mediaPayload.image, "sendMedia image");
expect(mediaPayload.caption).toBe("aaa");
expect(mediaPayload.mimetype).toBe("image/jpeg");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(msg.reply).toHaveBeenCalledWith("aaa", undefined);
expect(
findLoggerContext(replyLogger.info, "auto-reply sent (media)", "replyLogger.info"),
).toBeDefined();
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (media)");
expect(logVerbose).toHaveBeenCalled();
});
@@ -502,26 +478,36 @@ describe("deliverWebReply", () => {
skipLog: true,
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
image: expect.any(Buffer),
caption: "caption",
mimetype: "image/jpeg",
}),
expect.objectContaining({
quoted: expect.objectContaining({
key: expect.objectContaining({
id: "reply-2",
fromMe: true,
participant: "111@s.whatsapp.net",
}),
message: { conversation: "quoted media body" },
}),
}),
);
expect(msg.reply).toHaveBeenCalledWith(
"trail",
expect.objectContaining({
quoted: expect.objectContaining({
key: expect.objectContaining({
id: "reply-2",
fromMe: true,
participant: "111@s.whatsapp.net",
}),
message: { conversation: "quoted media body" },
}),
}),
);
expectBuffer(mediaPayload.image, "sendMedia image");
expect(mediaPayload.caption).toBe("caption");
expect(mediaPayload.mimetype).toBe("image/jpeg");
expectQuotedOptions(mockCallArg(msg.sendMedia, 0, 1, "sendMedia"), {
id: "reply-2",
fromMe: true,
participant: "111@s.whatsapp.net",
body: "quoted media body",
});
expect(mockCallArg(msg.reply, 0, 0, "reply")).toBe("trail");
expectQuotedOptions(mockCallArg(msg.reply, 0, 1, "reply"), {
id: "reply-2",
fromMe: true,
participant: "111@s.whatsapp.net",
body: "quoted media body",
});
});
it("retries media send on transient failure", async () => {
@@ -566,12 +552,10 @@ describe("deliverWebReply", () => {
expect(
String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
).not.toContain("boom");
const warnContext = findLoggerContext(
replyLogger.warn,
expect(replyLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ mediaUrl: "http://example.com/img.jpg" }),
"failed to send web media reply",
"replyLogger.warn",
);
expect(warnContext.mediaUrl).toBe("http://example.com/img.jpg");
});
it("still attempts later media after the first media fails", async () => {
@@ -618,15 +602,16 @@ describe("deliverWebReply", () => {
localRoots: undefined,
});
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
const secondPayload = requireRecord(
mockCallArg(msg.sendMedia, 1, 0, "sendMedia"),
"second sendMedia payload",
expect(msg.sendMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
document: expect.any(Buffer),
fileName: "good.pdf",
caption: undefined,
mimetype: "application/pdf",
}),
undefined,
);
expectBuffer(secondPayload.document, "second sendMedia document");
expect(secondPayload.fileName).toBe("good.pdf");
expect(secondPayload.caption).toBeUndefined();
expect(secondPayload.mimetype).toBe("application/pdf");
expect(mockCallArg(msg.sendMedia, 1, 1, "sendMedia")).toBeUndefined();
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(
String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
@@ -707,14 +692,14 @@ describe("deliverWebReply", () => {
localRoots: undefined,
});
expect(msg.sendMedia).toHaveBeenCalledTimes(1);
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.any(Buffer),
ptt: true,
mimetype: "audio/ogg; codecs=opus",
}),
undefined,
);
expectBuffer(mediaPayload.audio, "sendMedia audio");
expect(mediaPayload.ptt).toBe(true);
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
expect(msg.reply).toHaveBeenCalledWith("caption", undefined);
});
@@ -738,14 +723,14 @@ describe("deliverWebReply", () => {
skipLog: true,
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.any(Buffer),
ptt: true,
mimetype: "audio/ogg; codecs=opus",
}),
undefined,
);
expectBuffer(mediaPayload.audio, "sendMedia audio");
expect(mediaPayload.ptt).toBe(true);
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
});
@@ -775,23 +760,17 @@ describe("deliverWebReply", () => {
skipLog: true,
});
const ffmpegArgs = mockCallArg(hoisted.runFfmpeg, 0, 0, "runFfmpeg");
expect(Array.isArray(ffmpegArgs)).toBe(true);
const ffmpegArgList = ffmpegArgs as unknown[];
expect(ffmpegArgList).toContain("-c:a");
expect(ffmpegArgList).toContain("libopus");
expect(ffmpegArgList).toContain("-ar");
expect(ffmpegArgList).toContain("48000");
expect(ffmpegArgList).toContain("-b:a");
expect(ffmpegArgList).toContain("64k");
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(hoisted.runFfmpeg).toHaveBeenCalledWith(
expect.arrayContaining(["-c:a", "libopus", "-ar", "48000", "-b:a", "64k"]),
);
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: Buffer.from("opus-output"),
ptt: true,
mimetype: "audio/ogg; codecs=opus",
}),
undefined,
);
expect(mediaPayload.audio).toEqual(Buffer.from("opus-output"));
expect(mediaPayload.ptt).toBe(true);
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
});
@@ -815,14 +794,14 @@ describe("deliverWebReply", () => {
skipLog: true,
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.any(Buffer),
caption: "cap",
mimetype: "video/mp4",
}),
undefined,
);
expectBuffer(mediaPayload.video, "sendMedia video");
expect(mediaPayload.caption).toBe("cap");
expect(mediaPayload.mimetype).toBe("video/mp4");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
});
it("sends non-audio/image/video media as document", async () => {
@@ -845,15 +824,15 @@ describe("deliverWebReply", () => {
skipLog: true,
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.any(Buffer),
fileName: "x.bin",
caption: "cap",
mimetype: "application/octet-stream",
}),
undefined,
);
expectBuffer(mediaPayload.document, "sendMedia document");
expect(mediaPayload.fileName).toBe("x.bin");
expect(mediaPayload.caption).toBe("cap");
expect(mediaPayload.mimetype).toBe("application/octet-stream");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
});
it("strips URL query and fragment data from derived document file names", async () => {
@@ -878,14 +857,14 @@ describe("deliverWebReply", () => {
skipLog: true,
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.any(Buffer),
fileName: "report.pdf",
caption: "cap",
mimetype: "application/pdf",
}),
undefined,
);
expectBuffer(mediaPayload.document, "sendMedia document");
expect(mediaPayload.fileName).toBe("report.pdf");
expect(mediaPayload.caption).toBe("cap");
expect(mediaPayload.mimetype).toBe("application/pdf");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
});
});

View File

@@ -1367,7 +1367,6 @@
"crabbox:run": "node scripts/crabbox-wrapper.mjs run",
"crabbox:stop": "node scripts/crabbox-wrapper.mjs stop",
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
"discord:opus:install": "node scripts/install-discord-native-opus.mjs",
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-unused",
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx knip@6.8.0 --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
"deadcode:knip": "pnpm dlx knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",

View File

@@ -1,44 +0,0 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const opusDir = path.join(root, "node_modules", "@discordjs", "opus");
if (!existsSync(path.join(opusDir, "package.json"))) {
console.error(
"Missing node_modules/@discordjs/opus. Run pnpm install first, then retry this opt-in installer.",
);
process.exit(1);
}
const install = spawnSync(
"pnpm",
["--dir", opusDir, "exec", "node-pre-gyp", "install", "--fallback-to-build"],
{
cwd: root,
env: process.env,
stdio: "inherit",
},
);
if (install.status !== 0) {
console.error(
"Failed to install @discordjs/opus for the active Node runtime. Use Node 22 for the upstream macOS arm64 prebuild, or install a node-gyp toolchain for source builds.",
);
process.exit(install.status ?? 1);
}
const verify = spawnSync(process.execPath, ["-e", 'require("@discordjs/opus")'], {
cwd: root,
env: process.env,
stdio: "inherit",
});
if (verify.status !== 0) {
console.error("@discordjs/opus installed, but the active Node runtime still cannot load it.");
process.exit(verify.status ?? 1);
}
console.log("native opus ok");

View File

@@ -1,548 +0,0 @@
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { startQaMockOpenAiServer } from "../extensions/qa-lab/src/providers/mock-openai/server.js";
import { stageQaMockAuthProfiles } from "../extensions/qa-lab/src/providers/shared/mock-auth.js";
import { buildQaGatewayConfig } from "../extensions/qa-lab/src/qa-gateway-config.js";
import { resetConfigRuntimeState } from "../src/config/config.js";
import { startGatewayServer } from "../src/gateway/server.js";
type Lane = "normal" | "code";
type LaneResult = {
lane: Lane;
status: string;
providerRequestCount: number;
providerRawBytes: number;
providerSystemPromptChars: number;
providerDeclaredToolCount: number;
providerPlannedTools: string[];
gatewayOutputToolNames: string[];
gatewayOutputText: string;
sessionLogToolMentions: Record<string, number>;
};
const FAKE_PLUGIN_ID = "tool-search-e2e-fixture";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function freePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => (error ? reject(error) : resolve(port)));
});
});
}
function buildFakeTools(count = 36) {
return Array.from({ length: count }, (_, index) => {
const id = `fake_plugin_tool_${String(index + 1).padStart(2, "0")}`;
return {
type: "function",
name: id,
description: [
`Fake plugin tool ${index + 1}.`,
"Used by the Tool Search gateway E2E to prove a large plugin-owned tool catalog can be hidden from the model prompt and still called through the compact bridge.",
"The description is intentionally non-trivial so prompt-size regression is measurable.",
].join(" "),
parameters: {
type: "object",
properties: {
marker: {
type: "string",
description: "Lane marker supplied by the scripted model.",
},
},
required: ["marker"],
additionalProperties: false,
},
strict: true,
};
});
}
function countOccurrences(haystack: string, needle: string): number {
if (!needle) {
return 0;
}
let count = 0;
let offset = 0;
while (true) {
const next = haystack.indexOf(needle, offset);
if (next < 0) {
return count;
}
count += 1;
offset = next + needle.length;
}
}
async function readSessionLogMentions(params: {
stateDir: string;
targetTool: string;
}): Promise<Record<string, number>> {
const sessionsDir = path.join(params.stateDir, "agents", "qa", "sessions");
const mentions: Record<string, number> = {
tool_search_code: 0,
[params.targetTool]: 0,
};
let files: string[] = [];
try {
files = await fs.readdir(sessionsDir);
} catch {
return mentions;
}
for (const file of files.filter((candidate) => candidate.endsWith(".jsonl"))) {
const raw = await fs.readFile(path.join(sessionsDir, file), "utf8").catch(() => "");
mentions.tool_search_code += countOccurrences(raw, "tool_search_code");
mentions[params.targetTool] += countOccurrences(raw, params.targetTool);
}
return mentions;
}
async function fetchJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
const text = await response.text();
let parsed: unknown;
try {
parsed = text ? JSON.parse(text) : {};
} catch {
parsed = text;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}: ${text}`);
}
return parsed;
}
function outputToolNames(response: unknown): string[] {
const output = (response as { output?: Array<{ type?: unknown; name?: unknown }> }).output;
if (!Array.isArray(output)) {
return [];
}
return output
.filter((item) => item.type === "function_call" && typeof item.name === "string")
.map((item) => item.name as string);
}
function outputText(response: unknown): string {
const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output;
if (!Array.isArray(output)) {
return "";
}
return output
.flatMap((item) => {
if (item.type !== "message" || !Array.isArray(item.content)) {
return [];
}
return item.content.flatMap((piece) => {
if (!piece || typeof piece !== "object") {
return [];
}
const record = piece as { text?: unknown };
return typeof record.text === "string" ? [record.text] : [];
});
})
.join("\n");
}
function readContentText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
const record = item as { type?: unknown; text?: unknown };
return typeof record.text === "string" ? record.text : "";
})
.join("\n");
}
function countSystemPromptChars(body: unknown): number {
if (!body || typeof body !== "object") {
return 0;
}
const record = body as { instructions?: unknown; input?: unknown };
let total = typeof record.instructions === "string" ? record.instructions.length : 0;
if (Array.isArray(record.input)) {
for (const item of record.input) {
if (!item || typeof item !== "object") {
continue;
}
const inputRecord = item as { role?: unknown; content?: unknown };
if (inputRecord.role === "system" || inputRecord.role === "developer") {
total += readContentText(inputRecord.content).length;
}
}
}
return total;
}
async function writeConfig(params: {
lane: Lane;
stateDir: string;
configPath: string;
workspaceDir: string;
gatewayPort: number;
providerBaseUrl: string;
fakePluginDir: string;
}) {
let cfg = buildQaGatewayConfig({
bind: "loopback",
gatewayPort: params.gatewayPort,
gatewayToken: "tool-search-e2e",
providerBaseUrl: `${params.providerBaseUrl}/v1`,
workspaceDir: params.workspaceDir,
controlUiEnabled: false,
providerMode: "mock-openai",
});
cfg = {
...cfg,
tools: {
...cfg.tools,
alsoAllow: [...new Set([...(cfg.tools?.alsoAllow ?? []), FAKE_PLUGIN_ID])],
},
};
if (params.lane === "code") {
cfg = {
...cfg,
tools: {
...cfg.tools,
alsoAllow: [
...new Set([
...(cfg.tools?.alsoAllow ?? []),
"tool_search_code",
"tool_search",
"tool_describe",
"tool_call",
]),
],
toolSearch: true,
},
plugins: {
...cfg.plugins,
allow: [...new Set([...(cfg.plugins?.allow ?? []), FAKE_PLUGIN_ID])],
entries: {
...cfg.plugins?.entries,
[FAKE_PLUGIN_ID]: {
enabled: true,
},
},
},
};
} else {
cfg = {
...cfg,
plugins: {
...cfg.plugins,
allow: [...new Set([...(cfg.plugins?.allow ?? []), FAKE_PLUGIN_ID])],
entries: {
...cfg.plugins?.entries,
[FAKE_PLUGIN_ID]: {
enabled: true,
},
},
},
};
}
cfg = {
...cfg,
plugins: {
...cfg.plugins,
load: {
...cfg.plugins?.load,
paths: [...new Set([...(cfg.plugins?.load?.paths ?? []), params.fakePluginDir])],
},
},
};
cfg = await stageQaMockAuthProfiles({
cfg,
stateDir: params.stateDir,
agentIds: ["qa"],
providers: ["mock-openai", "openai", "anthropic"],
});
cfg = {
...cfg,
gateway: {
...cfg.gateway,
http: {
endpoints: {
responses: {
enabled: true,
},
},
},
},
};
await fs.mkdir(path.dirname(params.configPath), { recursive: true });
await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
}
async function writeFakePlugin(params: {
rootDir: string;
repoRoot: string;
fakeTools: ReturnType<typeof buildFakeTools>;
}): Promise<string> {
const pluginDir = path.join(params.rootDir, "fake-plugin");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(
path.join(pluginDir, "package.json"),
`${JSON.stringify(
{
name: "@openclaw/tool-search-e2e-fixture",
version: "0.0.0",
type: "module",
openclaw: {
extensions: ["./index.js"],
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: FAKE_PLUGIN_ID,
activation: {
onStartup: true,
},
name: "Tool Search E2E Fixture",
description: "Fake plugin with a large tool catalog for Tool Search gateway validation.",
contracts: {
tools: params.fakeTools.map((tool) => tool.name),
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
)}\n`,
"utf8",
);
const pluginEntryUrl = pathToFileURL(
path.join(params.repoRoot, "src/plugin-sdk/plugin-entry.ts"),
).href;
await fs.writeFile(
path.join(pluginDir, "index.js"),
[
`import { definePluginEntry } from ${JSON.stringify(pluginEntryUrl)};`,
`const tools = ${JSON.stringify(params.fakeTools, null, 2)};`,
"export default definePluginEntry({",
` id: ${JSON.stringify(FAKE_PLUGIN_ID)},`,
" name: 'Tool Search E2E Fixture',",
" register(api) {",
" for (const spec of tools) {",
" api.registerTool({",
" name: spec.name,",
" label: spec.name,",
" description: spec.description,",
" parameters: spec.parameters,",
" execute: async (_toolCallId, input) => ({",
" content: [{ type: 'text', text: `FAKE_PLUGIN_OK ${spec.name} ${JSON.stringify(input ?? {})}` }],",
" details: { status: 'ok', tool: spec.name, input },",
" }),",
" }, { name: spec.name });",
" }",
" },",
"});",
"",
].join("\n"),
"utf8",
);
return pluginDir;
}
async function runLane(params: {
lane: Lane;
rootDir: string;
providerBaseUrl: string;
targetTool: string;
fakeTools: ReturnType<typeof buildFakeTools>;
fakePluginDir: string;
}): Promise<LaneResult> {
const stateDir = path.join(params.rootDir, params.lane, "state");
const configPath = path.join(stateDir, "openclaw.json");
const workspaceDir = path.join(params.rootDir, params.lane, "workspace");
const gatewayPort = await freePort();
await fs.mkdir(workspaceDir, { recursive: true });
await writeConfig({
lane: params.lane,
stateDir,
configPath,
workspaceDir,
gatewayPort,
providerBaseUrl: params.providerBaseUrl,
fakePluginDir: params.fakePluginDir,
});
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_TEST_FAST = "1";
resetConfigRuntimeState();
const server = await startGatewayServer(gatewayPort, {
host: "127.0.0.1",
auth: { mode: "none" },
controlUiEnabled: false,
openResponsesEnabled: true,
});
try {
const beforeRequests = (await fetchJson(
`${params.providerBaseUrl}/debug/requests`,
)) as unknown[];
const response = await fetchJson(`http://127.0.0.1:${gatewayPort}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-openclaw-scopes": "operator.write",
"x-openclaw-agent": "qa",
},
body: JSON.stringify({
model: "openclaw/qa",
input: [
{
type: "message",
role: "user",
content: [
{
type: "input_text",
text: `tool search qa check target=${params.targetTool}`,
},
],
},
],
max_output_tokens: 256,
stream: false,
}),
});
const requests = (await fetchJson(`${params.providerBaseUrl}/debug/requests`)) as Array<{
raw?: string;
body?: { tools?: unknown[] };
instructions?: string;
plannedToolName?: string;
}>;
const laneRequests = requests.slice(beforeRequests.length);
const lastRequest = laneRequests.at(-1) ?? {};
const responseStatus = (response as { status?: unknown }).status;
return {
lane: params.lane,
status: typeof responseStatus === "string" ? responseStatus : "",
providerRequestCount: laneRequests.length,
providerRawBytes: typeof lastRequest.raw === "string" ? lastRequest.raw.length : 0,
providerSystemPromptChars: countSystemPromptChars(lastRequest.body),
providerDeclaredToolCount: Array.isArray(lastRequest.body?.tools)
? lastRequest.body.tools.length
: 0,
providerPlannedTools: laneRequests
.map((request) => request.plannedToolName)
.filter((name): name is string => typeof name === "string"),
gatewayOutputToolNames: outputToolNames(response),
gatewayOutputText: outputText(response),
sessionLogToolMentions: await readSessionLogMentions({
stateDir,
targetTool: params.targetTool,
}),
};
} finally {
await server.close({ reason: `${params.lane} lane complete` });
resetConfigRuntimeState();
}
}
async function main() {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tool-search-"));
const provider = await startQaMockOpenAiServer();
const fakeTools = buildFakeTools();
const fakePluginDir = await writeFakePlugin({
rootDir,
repoRoot: process.cwd(),
fakeTools,
});
const targetTool = "fake_plugin_tool_17";
try {
const normal = await runLane({
lane: "normal",
rootDir,
providerBaseUrl: provider.baseUrl,
targetTool,
fakeTools,
fakePluginDir,
});
const code = await runLane({
lane: "code",
rootDir,
providerBaseUrl: provider.baseUrl,
targetTool,
fakeTools,
fakePluginDir,
});
assert(
normal.providerPlannedTools.includes(targetTool) &&
normal.gatewayOutputText.includes("FAKE_PLUGIN_OK") &&
normal.gatewayOutputText.includes(targetTool),
`normal lane did not call ${targetTool}`,
);
assert(
code.providerPlannedTools.includes("tool_search_code") &&
code.gatewayOutputText.includes(targetTool) &&
code.sessionLogToolMentions[targetTool] > 0,
`code lane did not bridge-call ${targetTool}`,
);
assert(
normal.providerDeclaredToolCount > code.providerDeclaredToolCount,
`expected Tool Search to expose fewer tools to provider: normal=${normal.providerDeclaredToolCount} code=${code.providerDeclaredToolCount}`,
);
assert(
normal.providerRawBytes > code.providerRawBytes,
`expected Tool Search request to be smaller: normal=${normal.providerRawBytes} code=${code.providerRawBytes}`,
);
assert(
code.sessionLogToolMentions.tool_search_code > 0 &&
code.sessionLogToolMentions[targetTool] > 0,
"code lane session log did not record bridge and target tool mentions",
);
const summary = {
ok: true,
rootDir,
targetTool,
normal,
code,
reduction: {
providerRawBytes: normal.providerRawBytes - code.providerRawBytes,
providerDeclaredTools: normal.providerDeclaredToolCount - code.providerDeclaredToolCount,
providerSystemPromptChars:
normal.providerSystemPromptChars - code.providerSystemPromptChars,
},
};
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
} finally {
await provider.stop();
}
}
await main();

View File

@@ -327,70 +327,8 @@ function expectAcceptedSpawn(result: SpawnResult): Extract<SpawnResult, { status
return result;
}
function expectRecordFields(
record: unknown,
expected: Record<string, unknown>,
): Record<string, unknown> {
expect(record).toBeDefined();
const actual = record as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
return actual;
}
function gatewayRequests(): Array<{ method?: string; params?: Record<string, unknown> }> {
return hoisted.callGatewayMock.mock.calls.map(
(call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> },
);
}
function gatewayRequest(method: string): { method?: string; params?: Record<string, unknown> } {
const request = gatewayRequests().find((candidate) => candidate.method === method);
expect(request).toBeDefined();
return request as { method?: string; params?: Record<string, unknown> };
}
function expectGatewayMethodNotCalled(method: string): void {
expect(gatewayRequests().some((request) => request.method === method)).toBe(false);
}
function expectSessionPatchFields(expected: Record<string, unknown>): void {
expectRecordFields(gatewayRequest("sessions.patch").params, expected);
}
function expectInitializeSessionFields(expected: Record<string, unknown>): Record<string, unknown> {
return expectRecordFields(hoisted.initializeSessionMock.mock.calls[0]?.[0], expected);
}
function expectBindingCallFields(expected: {
conversation?: Record<string, unknown>;
metadata?: Record<string, unknown>;
placement?: string;
targetKind?: string;
}): Record<string, unknown> {
const input = expectRecordFields(hoisted.sessionBindingBindMock.mock.calls.at(-1)?.[0], {
...(expected.placement ? { placement: expected.placement } : {}),
...(expected.targetKind ? { targetKind: expected.targetKind } : {}),
});
if (expected.conversation) {
expectRecordFields(input.conversation, expected.conversation);
}
if (expected.metadata) {
expectRecordFields(input.metadata, expected.metadata);
}
return input;
}
function expectRelayCallFields(expected: Record<string, unknown>, callIndex = 0): void {
expectRecordFields(
hoisted.startAcpSpawnParentStreamRelayMock.mock.calls[callIndex]?.[0],
expected,
);
}
function expectAgentGatewayCall(overrides: AgentCallParams): void {
const agentCall = gatewayRequest("agent");
const agentCall = findAgentGatewayCall();
expect(agentCall?.params?.deliver).toBe(overrides.deliver);
expect(agentCall?.params?.channel).toBe(overrides.channel);
expect(agentCall?.params?.to).toBe(overrides.to);
@@ -751,28 +689,37 @@ describe("spawnAcpDirect", () => {
expect(accepted.runId).toBe("run-1");
expect(accepted.mode).toBe("session");
expect(accepted.inlineDelivery).toBe(true);
expectSessionPatchFields({
const patchCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "sessions.patch");
expect(patchCall?.params).toMatchObject({
key: accepted.childSessionKey,
spawnedBy: "agent:main:main",
});
expectBindingCallFields({
targetKind: "session",
placement: "child",
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
targetKind: "session",
placement: "child",
}),
);
expectResolvedIntroTextInBindMetadata();
const agentCall = gatewayRequest("agent");
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.sessionKey).toMatch(/^agent:codex:acp:/);
expect(agentCall?.params?.to).toBe("channel:child-thread");
expect(agentCall?.params?.threadId).toBe("child-thread");
expect(agentCall?.params?.deliver).toBe(true);
expect(agentCall?.params?.lane).toBe("subagent");
expect(agentCall?.params?.acpTurnSource).toBe("manual_spawn");
const initInput = expectInitializeSessionFields({
agent: "codex",
mode: "persistent",
});
expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
agent: "codex",
mode: "persistent",
}),
);
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
(call: unknown[]) => call[0] as { threadId?: string },
);
@@ -818,7 +765,11 @@ describe("spawnAcpDirect", () => {
);
expectAcceptedSpawn(result);
expectInitializeSessionFields({ resumeSessionId });
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
resumeSessionId,
}),
);
});
it("rejects ACP resume IDs not recorded for the requester session", async () => {
@@ -856,7 +807,7 @@ describe("spawnAcpDirect", () => {
},
);
expectRecordFields(result, {
expect(result).toMatchObject({
status: "forbidden",
errorCode: "resume_forbidden",
});
@@ -878,14 +829,16 @@ describe("spawnAcpDirect", () => {
);
expectAcceptedSpawn(result);
const initInput = expectInitializeSessionFields({
agent: "codex",
runtimeOptions: {
model: "openai-codex/gpt-5.4",
thinking: "high",
},
});
expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
agent: "codex",
runtimeOptions: {
model: "openai-codex/gpt-5.4",
thinking: "high",
},
}),
);
});
it("applies ACP spawn run timeout to runtime options and dispatch", async () => {
@@ -901,13 +854,15 @@ describe("spawnAcpDirect", () => {
);
expectAcceptedSpawn(result);
const initInput = expectInitializeSessionFields({
agent: "codex",
runtimeOptions: {
timeoutSeconds: 45,
},
});
expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
agent: "codex",
runtimeOptions: {
timeoutSeconds: 45,
},
}),
);
const agentCall = findAgentGatewayCall();
expect(agentCall?.params?.lane).toBe("subagent");
expect(agentCall?.params?.timeout).toBe(45);
@@ -942,13 +897,15 @@ describe("spawnAcpDirect", () => {
},
);
expectRecordFields(result, {
expect(result).toMatchObject({
status: "error",
errorCode: "runtime_agent_mismatch",
});
expect(result).toHaveProperty("error", expect.stringContaining("OpenClaw config agent"));
expect(hoisted.initializeSessionMock).not.toHaveBeenCalled();
expectGatewayMethodNotCalled("agent");
expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "agent" }),
);
});
it("maps OpenClaw ACP runtime agent aliases to their configured harness id", async () => {
@@ -986,8 +943,12 @@ describe("spawnAcpDirect", () => {
);
expectAcceptedSpawn(result);
const initInput = expectInitializeSessionFields({ agent: "codex" });
expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
agent: "codex",
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
}),
);
});
it("inherits subagent envelope fields onto ACP children", async () => {
@@ -1010,7 +971,10 @@ describe("spawnAcpDirect", () => {
});
const accepted = expectAcceptedSpawn(result);
expectSessionPatchFields({
const patchCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "sessions.patch");
expect(patchCall?.params).toMatchObject({
key: accepted.childSessionKey,
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
@@ -1297,14 +1261,16 @@ describe("spawnAcpDirect", () => {
},
);
expect(result.status, JSON.stringify(result)).toBe("accepted");
expectBindingCallFields({
placement: "child",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "!room:example",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "!room:example",
}),
}),
);
expectAgentGatewayCall({
deliver: true,
channel: "matrix",
@@ -1355,14 +1321,16 @@ describe("spawnAcpDirect", () => {
);
expect(result.status, JSON.stringify(result)).toBe("accepted");
expectBindingCallFields({
placement: "child",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "!Room:Example.org",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "!Room:Example.org",
}),
}),
);
expectAgentGatewayCall({
deliver: true,
channel: "matrix",
@@ -1414,15 +1382,17 @@ describe("spawnAcpDirect", () => {
);
expect(result.status, JSON.stringify(result)).toBe("accepted");
expectBindingCallFields({
placement: "child",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "$thread-root",
parentConversationId: "!Room:Example.org",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "$thread-root",
parentConversationId: "!Room:Example.org",
}),
}),
);
expectAgentGatewayCall({
deliver: true,
channel: "matrix",
@@ -1448,11 +1418,13 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
const initInput = expectInitializeSessionFields({
agent: "claude-code",
cwd: fixture.targetWorkspace,
});
expect(initInput.sessionKey).toMatch(/^agent:claude-code:acp:/);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:claude-code:acp:/),
agent: "claude-code",
cwd: fixture.targetWorkspace,
}),
);
} finally {
await fs.rm(fixture.workspaceRoot, { recursive: true, force: true });
}
@@ -1478,11 +1450,13 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
const initInput = expectInitializeSessionFields({
agent: "claude-code",
cwd: undefined,
});
expect(initInput.sessionKey).toMatch(/^agent:claude-code:acp:/);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:claude-code:acp:/),
agent: "claude-code",
cwd: undefined,
}),
);
} finally {
await fs.rm(fixture.workspaceRoot, { recursive: true, force: true });
}
@@ -1560,14 +1534,16 @@ describe("spawnAcpDirect", () => {
);
expect(result.status, JSON.stringify(result)).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "line",
accountId: "default",
conversationId: "U1234567890abcdef1234567890abcdef",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "line",
accountId: "default",
conversationId: "U1234567890abcdef1234567890abcdef",
}),
}),
);
expectAgentGatewayCall({
deliver: true,
channel: "line",
@@ -1653,14 +1629,16 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "custom",
accountId: "work",
conversationId: "123456",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "custom",
accountId: "work",
conversationId: "123456",
}),
}),
);
expectAgentGatewayCall({
deliver: true,
channel: "custom",
@@ -1762,15 +1740,17 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "child",
conversation: {
channel: "matrix",
accountId: "bot-alpha",
conversationId: boundRoom,
},
});
expectRecordFields(gatewayRequest("agent").params, {
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "bot-alpha",
conversationId: boundRoom,
}),
}),
);
expect(findAgentGatewayCall()?.params).toMatchObject({
deliver: true,
channel: "matrix",
accountId: "bot-alpha",
@@ -1840,14 +1820,16 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "line",
accountId: "default",
conversationId: expectedConversationId,
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "line",
accountId: "default",
conversationId: expectedConversationId,
}),
}),
);
},
);
@@ -1891,14 +1873,16 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "line",
accountId: "default",
conversationId: "R1234567890abcdef1234567890abcdef",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "line",
accountId: "default",
conversationId: "R1234567890abcdef1234567890abcdef",
}),
}),
);
});
it.each([
@@ -1935,11 +1919,13 @@ describe("spawnAcpDirect", () => {
expect(accepted.streamLogPath).toBeUndefined();
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
if (expectTranscriptPersistence) {
expectRecordFields(hoisted.resolveSessionTranscriptFileMock.mock.calls[0]?.[0], {
sessionId: "sess-123",
storePath: "/tmp/codex-sessions.json",
agentId: "codex",
});
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess-123",
storePath: "/tmp/codex-sessions.json",
agentId: "codex",
}),
);
}
expectAgentGatewayCall(expectedAgentCall);
});
@@ -1988,10 +1974,13 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
const bindInput = expectBindingCallFields({});
const metadata = expectRecordFields(bindInput.metadata, {});
expect(typeof metadata.introText).toBe("string");
expect(metadata.introText).toContain("cwd: /home/bob/clawd");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.stringContaining("cwd: /home/bob/clawd"),
}),
}),
);
});
it("rejects disallowed ACP agents", async () => {
@@ -2014,7 +2003,7 @@ describe("spawnAcpDirect", () => {
},
);
expectRecordFields(result, {
expect(result).toMatchObject({
status: "forbidden",
});
});
@@ -2143,22 +2132,22 @@ describe("spawnAcpDirect", () => {
expect(typeof relayCallOrder).toBe("number");
expect(typeof agentCallOrder).toBe("number");
expect(relayCallOrder < agentCallOrder).toBe(true);
expectRelayCallFields({
parentSessionKey: "agent:main:main",
agentId: "codex",
logPath: "/tmp/sess-main.acp-stream.jsonl",
emitStartNotice: false,
});
expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith(
expect.objectContaining({
parentSessionKey: "agent:main:main",
agentId: "codex",
logPath: "/tmp/sess-main.acp-stream.jsonl",
emitStartNotice: false,
}),
);
const relayRuns = hoisted.startAcpSpawnParentStreamRelayMock.mock.calls.map(
(call: unknown[]) => (call[0] as { runId?: string }).runId,
);
expect(relayRuns).toContain(agentCall?.params?.idempotencyKey);
expect(relayRuns).toContain(accepted.runId);
const streamPathInput = expectRecordFields(
hoisted.resolveAcpSpawnStreamLogPathMock.mock.calls[0]?.[0],
{},
);
expect(streamPathInput.childSessionKey).toMatch(/^agent:codex:acp:/);
expect(hoisted.resolveAcpSpawnStreamLogPathMock).toHaveBeenCalledWith({
childSessionKey: expect.stringMatching(/^agent:codex:acp:/),
});
expect(firstHandle.dispose).toHaveBeenCalledTimes(1);
expect(firstHandle.notifyStarted).not.toHaveBeenCalled();
expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1);
@@ -2231,17 +2220,19 @@ describe("spawnAcpDirect", () => {
expect(agentCall?.params?.channel).toBeUndefined();
expect(agentCall?.params?.to).toBeUndefined();
expect(agentCall?.params?.threadId).toBeUndefined();
expectRelayCallFields({
parentSessionKey: "agent:main:subagent:parent",
agentId: "codex",
logPath: "/tmp/sess-main.acp-stream.jsonl",
deliveryContext: {
channel: "discord",
to: "channel:parent-channel",
accountId: "default",
},
emitStartNotice: false,
});
expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith(
expect.objectContaining({
parentSessionKey: "agent:main:subagent:parent",
agentId: "codex",
logPath: "/tmp/sess-main.acp-stream.jsonl",
deliveryContext: {
channel: "discord",
to: "channel:parent-channel",
accountId: "default",
},
emitStartNotice: false,
}),
);
expect(firstHandle.dispose).toHaveBeenCalledTimes(1);
expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1);
});
@@ -2579,15 +2570,17 @@ describe("spawnAcpDirect", () => {
const accepted = expectAcceptedSpawn(result);
expect(accepted.mode).toBe("session");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "2",
parentConversationId: "-1003342490704",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "telegram",
accountId: "default",
conversationId: "2",
parentConversationId: "-1003342490704",
}),
}),
);
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
@@ -2615,14 +2608,16 @@ describe("spawnAcpDirect", () => {
const accepted = expectAcceptedSpawn(result);
expect(accepted.mode).toBe("session");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "6098642967",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "telegram",
accountId: "default",
conversationId: "6098642967",
}),
}),
);
const bindCall = hoisted.sessionBindingBindMock.mock.calls.at(-1)?.[0] as
| { conversation?: { parentConversationId?: string } }
| undefined;
@@ -2648,14 +2643,16 @@ describe("spawnAcpDirect", () => {
);
expect(result.status).toBe("accepted");
expectBindingCallFields({
placement: "current",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1003342490704:topic:2",
},
});
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "telegram",
accountId: "default",
conversationId: "-1003342490704:topic:2",
}),
}),
);
});
it("disposes pre-registered parent relay when initial ACP dispatch fails", async () => {

View File

@@ -74,30 +74,6 @@ function latestAnthropicRequestHeaders() {
return new Headers(latestAnthropicRequest().init?.headers);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value;
}
function findRecord(items: unknown, predicate: (record: Record<string, unknown>) => boolean) {
for (const item of requireArray(items, "items")) {
const record = requireRecord(item, "item");
if (predicate(record)) {
return record;
}
}
throw new Error("Expected matching record");
}
function makeAnthropicTransportModel(
params: {
id?: string;
@@ -178,19 +154,25 @@ describe("anthropic transport stream", () => {
);
expect(buildGuardedModelFetchMock).toHaveBeenCalledWith(model);
const [url, init] = guardedFetchMock.mock.calls[0] ?? [];
expect(url).toBe("https://api.anthropic.com/v1/messages");
expect(init?.method).toBe("POST");
const headers = new Headers(init?.headers);
expect(headers.get("x-api-key")).toBe("sk-ant-api");
expect(headers.get("anthropic-version")).toBe("2023-06-01");
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("accept")).toBe("application/json");
expect(headers.get("anthropic-dangerous-direct-browser-access")).toBe("true");
expect(headers.get("X-Provider")).toBe("anthropic");
expect(headers.get("X-Call")).toBe("1");
expect(latestAnthropicRequest().payload.model).toBe("claude-sonnet-4-6");
expect(latestAnthropicRequest().payload.stream).toBe(true);
expect(guardedFetchMock).toHaveBeenCalledWith(
"https://api.anthropic.com/v1/messages",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"x-api-key": "sk-ant-api",
"anthropic-version": "2023-06-01",
"content-type": "application/json",
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"X-Provider": "anthropic",
"X-Call": "1",
}),
}),
);
expect(latestAnthropicRequest().payload).toMatchObject({
model: "claude-sonnet-4-6",
stream: true,
});
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBe(
"fine-grained-tool-streaming-2025-05-14",
);
@@ -212,8 +194,10 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
expect(guardedFetchMock.mock.calls[0]?.[0]).toBe("https://custom-proxy.example/v1/messages");
expect(guardedFetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
expect(guardedFetchMock).toHaveBeenCalledWith(
"https://custom-proxy.example/v1/messages",
expect.objectContaining({ method: "POST" }),
);
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBeNull();
});
@@ -294,9 +278,11 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
expect(latestAnthropicRequest().payload.model).toBe("claude-sonnet-4-6");
expect(latestAnthropicRequest().payload.max_tokens).toBe(8192);
expect(latestAnthropicRequest().payload.stream).toBe(true);
expect(latestAnthropicRequest().payload).toMatchObject({
model: "claude-sonnet-4-6",
max_tokens: 8192,
stream: true,
});
});
it("ignores fractional runtime maxTokens overrides that floor to zero", async () => {
@@ -311,9 +297,11 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
expect(latestAnthropicRequest().payload.model).toBe("claude-sonnet-4-6");
expect(latestAnthropicRequest().payload.max_tokens).toBe(8192);
expect(latestAnthropicRequest().payload.stream).toBe(true);
expect(latestAnthropicRequest().payload).toMatchObject({
model: "claude-sonnet-4-6",
max_tokens: 8192,
stream: true,
});
});
it("fails locally when Anthropic maxTokens is non-positive after resolution", async () => {
@@ -439,31 +427,33 @@ describe("anthropic transport stream", () => {
);
const result = await stream.result();
expect(guardedFetchMock.mock.calls[0]?.[0]).toBe("https://api.anthropic.com/v1/messages");
const headers = new Headers(guardedFetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("authorization")).toBe("Bearer sk-ant-oat-example");
expect(headers.get("x-app")).toBe("cli");
expect(headers.get("user-agent")).toContain("claude-cli/");
expect(guardedFetchMock).toHaveBeenCalledWith(
"https://api.anthropic.com/v1/messages",
expect.objectContaining({
headers: expect.objectContaining({
authorization: "Bearer sk-ant-oat-example",
"x-app": "cli",
"user-agent": expect.stringContaining("claude-cli/"),
}),
}),
);
const firstCallParams = latestAnthropicRequest().payload;
const system = requireArray(firstCallParams.system, "system");
expect(
system.some(
(item) =>
requireRecord(item, "system item").text ===
"You are Claude Code, Anthropic's official CLI for Claude.",
),
).toBe(true);
expect(
system.some((item) => requireRecord(item, "system item").text === "Follow policy."),
).toBe(true);
expect(
requireArray(firstCallParams.tools, "tools").some(
(item) => requireRecord(item, "tool").name === "Read",
),
).toBe(true);
expect(firstCallParams.system).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: "You are Claude Code, Anthropic's official CLI for Claude.",
}),
expect.objectContaining({
text: "Follow policy.",
}),
]),
);
expect(firstCallParams.tools).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "Read" })]),
);
expect(result.stopReason).toBe("toolUse");
expect(result.content.some((item) => item.type === "toolCall" && item.name === "read")).toBe(
true,
expect(result.content).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "toolCall", name: "read" })]),
);
});
@@ -526,16 +516,19 @@ describe("anthropic transport stream", () => {
}
const result = await stream.result();
const thinkingContent = requireRecord(result.content[0], "thinking content");
expect(thinkingContent.type).toBe("thinking");
expect(thinkingContent.thinking).toBe("checking");
expect(thinkingContent.thinkingSignature).toBe("sig_2");
expect(result.content[1]).toEqual({ type: "text", text: "NO_REPLY" });
expect(events.some((event) => event.type === "text_delta" && event.delta === "NO_REPLY")).toBe(
true,
);
expect(events.some((event) => event.type === "text_end" && event.content === "NO_REPLY")).toBe(
true,
expect(result.content).toEqual([
expect.objectContaining({
type: "thinking",
thinking: "checking",
thinkingSignature: "sig_2",
}),
{ type: "text", text: "NO_REPLY" },
]);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "text_delta", delta: "NO_REPLY" }),
expect.objectContaining({ type: "text_end", content: "NO_REPLY" }),
]),
);
expect(result.usage.output).toBe(9);
});
@@ -590,12 +583,12 @@ describe("anthropic transport stream", () => {
expect(result.content).toEqual([{ type: "text", text: "你好" }]);
expect(result.stopReason).toBe("stop");
expect(events.some((event) => event.type === "text_start")).toBe(true);
expect(events.some((event) => event.type === "text_delta" && event.delta === "你好")).toBe(
true,
);
expect(events.some((event) => event.type === "text_end" && event.content === "你好")).toBe(
true,
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "text_start" }),
expect.objectContaining({ type: "text_delta", delta: "你好" }),
expect.objectContaining({ type: "text_end", content: "你好" }),
]),
);
});
@@ -628,13 +621,16 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
const tools = requireArray(latestAnthropicRequest().payload.tools, "tools");
expect(tools).toHaveLength(1);
const tool = requireRecord(tools[0], "tool");
expect(tool.name).toBe("good_plugin_tool");
expect(requireRecord(tool.input_schema, "input schema").properties).toEqual({
query: { type: "string" },
});
expect(latestAnthropicRequest().payload.tools).toEqual([
expect.objectContaining({
name: "good_plugin_tool",
input_schema: expect.objectContaining({
properties: {
query: { type: "string" },
},
}),
}),
]);
});
it("coerces replayed malformed tool-call args to an object for Anthropic payloads", async () => {
@@ -678,15 +674,20 @@ describe("anthropic transport stream", () => {
await stream.result();
const firstCallParams = latestAnthropicRequest().payload;
const assistantMessage = findRecord(
firstCallParams.messages,
(record) => record.role === "assistant",
expect(firstCallParams.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([
expect.objectContaining({
type: "tool_use",
name: "lookup",
input: {},
}),
]),
}),
]),
);
const toolUse = findRecord(
assistantMessage.content,
(record) => record.type === "tool_use" && record.name === "lookup",
);
expect(toolUse.input).toEqual({});
});
it.each([
@@ -720,16 +721,21 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
const userMessage = findRecord(
latestAnthropicRequest().payload.messages,
(record) => record.role === "user",
expect(latestAnthropicRequest().payload.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "user",
content: expect.arrayContaining([
expect.objectContaining({
type: "tool_result",
tool_use_id: "tool_1",
content: "(no output)",
is_error: false,
}),
]),
}),
]),
);
const toolResult = findRecord(
userMessage.content,
(record) => record.type === "tool_result" && record.tool_use_id === "tool_1",
);
expect(toolResult.content).toBe("(no output)");
expect(toolResult.is_error).toBe(false);
});
it("drops empty text blocks from image tool results before Anthropic payloads", async () => {
@@ -764,26 +770,31 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
const userMessage = findRecord(
latestAnthropicRequest().payload.messages,
(record) => record.role === "user",
expect(latestAnthropicRequest().payload.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "user",
content: expect.arrayContaining([
expect.objectContaining({
type: "tool_result",
tool_use_id: "tool_1",
content: [
{ type: "text", text: "(see attached image)" },
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: imageData,
},
},
],
is_error: false,
}),
]),
}),
]),
);
const toolResult = findRecord(
userMessage.content,
(record) => record.type === "tool_result" && record.tool_use_id === "tool_1",
);
expect(toolResult.content).toEqual([
{ type: "text", text: "(see attached image)" },
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: imageData,
},
},
]);
expect(toolResult.is_error).toBe(false);
});
it("cancels stalled SSE body reads when the abort signal fires mid-stream", async () => {
@@ -862,9 +873,10 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.output_config).toEqual({ effort: "max" });
expect(latestAnthropicRequest().payload).toMatchObject({
thinking: { type: "adaptive" },
output_config: { effort: "max" },
});
});
it("maps xhigh thinking effort for Claude Opus 4.7 transport runs", async () => {
@@ -885,8 +897,9 @@ describe("anthropic transport stream", () => {
} as AnthropicStreamOptions,
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
expect(latestAnthropicRequest().payload).toMatchObject({
thinking: { type: "adaptive" },
output_config: { effort: "xhigh" },
});
});
});

View File

@@ -25,9 +25,6 @@ export type SessionStdin = {
// When backed by a real Node stream (child.stdin), this exists; for PTY wrappers it may not.
destroy?: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
export interface ProcessSession {

View File

@@ -66,7 +66,7 @@ export function describeExecTool(params?: { agentId?: string; hasCronTool?: bool
export function describeProcessTool(params?: { hasCronTool?: boolean }): string {
return [
"Manage running exec sessions for commands already started: list, poll, log, write, send-keys, submit, paste, kill.",
"Use poll/log when you need status, logs, quiet-success confirmation, or completion confirmation when automatic completion wake is unavailable. Use poll/log also for input-wait hints. Use write/send-keys/submit/paste/kill for input or intervention.",
"Use poll/log when you need status, logs, quiet-success confirmation, or completion confirmation when automatic completion wake is unavailable. Use write/send-keys/submit/paste/kill for input or intervention.",
params?.hasCronTool
? "Do not use process polling to emulate timers or reminders; use cron for scheduled follow-ups."
: undefined,

View File

@@ -3,30 +3,16 @@ import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { ExecElevatedDefaults } from "./bash-tools.exec-types.js";
const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX = "exec-approval-followup:";
const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER = ":nonce:";
const EXEC_APPROVAL_FOLLOWUP_RUNTIME_HANDOFF_TTL_MS = 5 * 60 * 1000;
const EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER = ":elevated:";
const EXEC_APPROVAL_FOLLOWUP_ELEVATED_TTL_MS = 5 * 60 * 1000;
export type ExecApprovalFollowupRuntimeHandoff = {
kind: "exec-approval-followup";
approvalId: string;
type ExecApprovalFollowupElevatedEntry = {
sessionKey: string;
idempotencyKey: string;
bashElevated: ExecElevatedDefaults;
};
export type ExecApprovalFollowupRuntimeHandoffRegistration = {
handoffId: string;
idempotencyKey: string;
};
type ExecApprovalFollowupRuntimeHandoffEntry = ExecApprovalFollowupRuntimeHandoff & {
expiresAtMs: number;
};
const execApprovalFollowupRuntimeHandoffs = new Map<
string,
ExecApprovalFollowupRuntimeHandoffEntry
>();
const execApprovalFollowupElevatedDefaults = new Map<string, ExecApprovalFollowupElevatedEntry>();
function cloneExecElevatedDefaults(value: ExecElevatedDefaults): ExecElevatedDefaults {
return {
@@ -42,109 +28,96 @@ function cloneExecElevatedDefaults(value: ExecElevatedDefaults): ExecElevatedDef
};
}
function cloneExecApprovalFollowupRuntimeHandoff(
value: ExecApprovalFollowupRuntimeHandoff,
): ExecApprovalFollowupRuntimeHandoff {
return {
kind: value.kind,
approvalId: value.approvalId,
sessionKey: value.sessionKey,
idempotencyKey: value.idempotencyKey,
bashElevated: cloneExecElevatedDefaults(value.bashElevated),
};
}
function pruneExpiredExecApprovalFollowupRuntimeHandoffs(nowMs: number): void {
for (const [handoffId, entry] of execApprovalFollowupRuntimeHandoffs) {
function pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs: number): void {
for (const [token, entry] of execApprovalFollowupElevatedDefaults) {
if (entry.expiresAtMs <= nowMs) {
execApprovalFollowupRuntimeHandoffs.delete(handoffId);
execApprovalFollowupElevatedDefaults.delete(token);
}
}
}
export function buildExecApprovalFollowupIdempotencyKey(params: {
approvalId: string;
nonce?: string;
execApprovalFollowupToken?: string;
}): string {
const base = `${EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX}${params.approvalId}`;
const nonce = normalizeOptionalString(params.nonce);
return nonce ? `${base}${EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER}${nonce}` : base;
return params.execApprovalFollowupToken
? `${base}${EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER}${params.execApprovalFollowupToken}`
: base;
}
export function parseExecApprovalFollowupApprovalId(idempotencyKey: string): string | undefined {
const normalized = normalizeOptionalString(idempotencyKey);
if (!normalized?.startsWith(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX)) {
function parseExecApprovalFollowupToken(idempotencyKey: string): string | undefined {
if (!idempotencyKey.startsWith(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX)) {
return undefined;
}
const body = normalized.slice(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX.length);
const nonceMarker = body.lastIndexOf(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER);
return normalizeOptionalString(nonceMarker >= 0 ? body.slice(0, nonceMarker) : body);
const tokenMarker = idempotencyKey.lastIndexOf(EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER);
if (tokenMarker < EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX.length) {
return undefined;
}
return normalizeOptionalString(
idempotencyKey.slice(tokenMarker + EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER.length),
);
}
export function registerExecApprovalFollowupRuntimeHandoff(params: {
approvalId: string;
export function registerExecApprovalFollowupElevatedDefaults(params: {
sessionKey: string;
bashElevated?: ExecElevatedDefaults;
nowMs?: number;
}): ExecApprovalFollowupRuntimeHandoffRegistration | undefined {
const approvalId = normalizeOptionalString(params.approvalId);
}): string | undefined {
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!approvalId || !sessionKey || !params.bashElevated) {
if (!params.bashElevated || !sessionKey) {
return undefined;
}
const nowMs = params.nowMs ?? Date.now();
pruneExpiredExecApprovalFollowupRuntimeHandoffs(nowMs);
const handoffId = randomUUID();
const idempotencyKey = buildExecApprovalFollowupIdempotencyKey({
approvalId,
nonce: randomUUID(),
});
execApprovalFollowupRuntimeHandoffs.set(handoffId, {
kind: "exec-approval-followup",
approvalId,
pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs);
const token = randomUUID();
execApprovalFollowupElevatedDefaults.set(token, {
sessionKey,
idempotencyKey,
bashElevated: cloneExecElevatedDefaults(params.bashElevated),
expiresAtMs: nowMs + EXEC_APPROVAL_FOLLOWUP_RUNTIME_HANDOFF_TTL_MS,
expiresAtMs: nowMs + EXEC_APPROVAL_FOLLOWUP_ELEVATED_TTL_MS,
});
return { handoffId, idempotencyKey };
return token;
}
export function consumeExecApprovalFollowupRuntimeHandoff(params: {
handoffId?: string;
approvalId?: string;
idempotencyKey?: string;
export function consumeExecApprovalFollowupElevatedDefaults(params: {
token?: string;
sessionKey?: string;
nowMs?: number;
}): ExecApprovalFollowupRuntimeHandoff | undefined {
const handoffId = normalizeOptionalString(params.handoffId);
const approvalId = normalizeOptionalString(params.approvalId);
const idempotencyKey = normalizeOptionalString(params.idempotencyKey);
if (!handoffId || !approvalId || !idempotencyKey) {
}): ExecElevatedDefaults | undefined {
const token = normalizeOptionalString(params.token);
if (!token) {
return undefined;
}
const nowMs = params.nowMs ?? Date.now();
pruneExpiredExecApprovalFollowupRuntimeHandoffs(nowMs);
const entry = execApprovalFollowupRuntimeHandoffs.get(handoffId);
pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs);
const entry = execApprovalFollowupElevatedDefaults.get(token);
if (!entry) {
return undefined;
}
if (entry.expiresAtMs <= nowMs) {
execApprovalFollowupRuntimeHandoffs.delete(handoffId);
execApprovalFollowupElevatedDefaults.delete(token);
return undefined;
}
const sessionKey = normalizeOptionalString(params.sessionKey);
if (
entry.approvalId !== approvalId ||
entry.idempotencyKey !== idempotencyKey ||
entry.sessionKey !== sessionKey
) {
if (entry.sessionKey !== sessionKey) {
return undefined;
}
execApprovalFollowupRuntimeHandoffs.delete(handoffId);
return cloneExecApprovalFollowupRuntimeHandoff(entry);
execApprovalFollowupElevatedDefaults.delete(token);
return cloneExecElevatedDefaults(entry.bashElevated);
}
export function resetExecApprovalFollowupRuntimeHandoffsForTests(): void {
execApprovalFollowupRuntimeHandoffs.clear();
export function consumeExecApprovalFollowupElevatedDefaultsFromIdempotencyKey(params: {
idempotencyKey: string;
sessionKey?: string;
nowMs?: number;
}): ExecElevatedDefaults | undefined {
return consumeExecApprovalFollowupElevatedDefaults({
token: parseExecApprovalFollowupToken(params.idempotencyKey),
sessionKey: params.sessionKey,
nowMs: params.nowMs,
});
}
export function resetExecApprovalFollowupElevatedDefaultsForTests(): void {
execApprovalFollowupElevatedDefaults.clear();
}

View File

@@ -283,14 +283,13 @@ describe("exec approval followup", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("carries the runtime handoff separately from idempotency without exposing elevated defaults", async () => {
it("carries the elevated followup token through idempotency without exposing elevated defaults", async () => {
await sendExecApprovalFollowup({
approvalId: "req-elevated-75832",
sessionKey: "agent:main:telegram:direct:123",
turnSourceChannel: "telegram",
resultText: "Exec finished (gateway id=req-elevated-75832, code 0)\nok",
internalRuntimeHandoffId: "handoff-75832",
idempotencyKey: "exec-approval-followup:req-elevated-75832:nonce:nonce-75832",
execApprovalFollowupToken: "token-75832",
});
expect(callGatewayTool).toHaveBeenCalledWith(
@@ -299,8 +298,7 @@ describe("exec approval followup", () => {
expect.objectContaining({
sessionKey: "agent:main:telegram:direct:123",
channel: "telegram",
idempotencyKey: "exec-approval-followup:req-elevated-75832:nonce:nonce-75832",
internalRuntimeHandoffId: "handoff-75832",
idempotencyKey: "exec-approval-followup:req-elevated-75832:elevated:token-75832",
}),
{ expectFinal: true },
);

View File

@@ -24,8 +24,7 @@ type ExecApprovalFollowupParams = {
turnSourceThreadId?: string | number;
resultText: string;
direct?: boolean;
internalRuntimeHandoffId?: string;
idempotencyKey?: string;
execApprovalFollowupToken?: string;
};
function buildExecDeniedFollowupPrompt(resultText: string): string {
@@ -152,8 +151,7 @@ function buildAgentFollowupArgs(params: {
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
internalRuntimeHandoffId?: string;
idempotencyKey?: string;
execApprovalFollowupToken?: string;
}) {
const { deliveryTarget, sessionOnlyOriginChannel } = params;
// When the followup run has no deliverable route and no gateway-internal channel,
@@ -181,14 +179,10 @@ function buildAgentFollowupArgs(params: {
: sessionOnlyOriginChannel
? params.turnSourceThreadId
: undefined,
idempotencyKey:
params.idempotencyKey ??
buildExecApprovalFollowupIdempotencyKey({
approvalId: params.approvalId,
}),
...(params.internalRuntimeHandoffId
? { internalRuntimeHandoffId: params.internalRuntimeHandoffId }
: {}),
idempotencyKey: buildExecApprovalFollowupIdempotencyKey({
approvalId: params.approvalId,
execApprovalFollowupToken: params.execApprovalFollowupToken,
}),
};
}
@@ -262,8 +256,7 @@ export async function sendExecApprovalFollowup(
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
internalRuntimeHandoffId: params.internalRuntimeHandoffId,
idempotencyKey: params.idempotencyKey,
execApprovalFollowupToken: params.execApprovalFollowupToken,
}),
{ expectFinal: true },
);

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
consumeExecApprovalFollowupRuntimeHandoff,
resetExecApprovalFollowupRuntimeHandoffsForTests,
consumeExecApprovalFollowupElevatedDefaults,
resetExecApprovalFollowupElevatedDefaultsForTests,
} from "./bash-tools.exec-approval-followup-state.js";
import {
buildExecApprovalPendingToolResult,
@@ -63,7 +63,7 @@ describe("sendExecApprovalFollowupResult", () => {
allowlist: [],
file: { version: 1, agents: {} },
});
resetExecApprovalFollowupRuntimeHandoffsForTests();
resetExecApprovalFollowupElevatedDefaultsForTests();
});
it("logs repeated followup dispatch failures once per approval id and error message", async () => {
@@ -132,65 +132,22 @@ describe("sendExecApprovalFollowupResult", () => {
);
const call = sendExecApprovalFollowup.mock.calls[0]?.[0] as
| {
internalRuntimeHandoffId?: string;
idempotencyKey?: string;
execApprovalFollowupToken?: string;
bashElevated?: unknown;
}
| { execApprovalFollowupToken?: string; bashElevated?: unknown }
| undefined;
expect(call?.internalRuntimeHandoffId).toEqual(expect.any(String));
expect(call?.idempotencyKey).toMatch(/^exec-approval-followup:approval-elevated-75832:nonce:/);
expect(call?.idempotencyKey).not.toContain(call?.internalRuntimeHandoffId ?? "");
expect(call?.execApprovalFollowupToken).toEqual(expect.any(String));
expect(call).not.toHaveProperty("bashElevated");
expect(call).not.toHaveProperty("execApprovalFollowupToken");
expect(
consumeExecApprovalFollowupRuntimeHandoff({
handoffId: call?.internalRuntimeHandoffId ?? "",
approvalId: "approval-elevated-75832",
idempotencyKey: call?.idempotencyKey ?? "",
consumeExecApprovalFollowupElevatedDefaults({
token: call?.execApprovalFollowupToken ?? "",
sessionKey: "agent:main:telegram:direct:wrong",
}),
).toBeUndefined();
expect(
consumeExecApprovalFollowupRuntimeHandoff({
handoffId: call?.internalRuntimeHandoffId ?? "",
approvalId: "approval-elevated-75832",
idempotencyKey: call?.idempotencyKey ?? "",
consumeExecApprovalFollowupElevatedDefaults({
token: call?.execApprovalFollowupToken ?? "",
sessionKey: "agent:main:telegram:direct:123",
}),
).toEqual({
kind: "exec-approval-followup",
approvalId: "approval-elevated-75832",
sessionKey: "agent:main:telegram:direct:123",
idempotencyKey: call?.idempotencyKey,
bashElevated,
});
});
it("keeps non-elevated agent followups on the deterministic idempotency path", async () => {
sendExecApprovalFollowup.mockResolvedValue(true);
await sendExecApprovalFollowupResult(
{
approvalId: "approval-normal-75832",
sessionKey: "agent:main:telegram:direct:123",
turnSourceChannel: "telegram",
},
"Exec finished",
{ sendExecApprovalFollowup, logWarn },
);
const call = sendExecApprovalFollowup.mock.calls[0]?.[0] as
| {
internalRuntimeHandoffId?: string;
idempotencyKey?: string;
bashElevated?: unknown;
}
| undefined;
expect(call).not.toHaveProperty("internalRuntimeHandoffId");
expect(call).not.toHaveProperty("idempotencyKey");
expect(call).not.toHaveProperty("bashElevated");
).toEqual(bashElevated);
});
});

View File

@@ -16,7 +16,7 @@ import {
type ExecSecurity,
} from "../infra/exec-approvals.js";
import { logWarn } from "../logger.js";
import { registerExecApprovalFollowupRuntimeHandoff } from "./bash-tools.exec-approval-followup-state.js";
import { registerExecApprovalFollowupElevatedDefaults } from "./bash-tools.exec-approval-followup-state.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
type ExecApprovalRegistration,
@@ -411,11 +411,10 @@ export async function sendExecApprovalFollowupResult(
): Promise<void> {
const send = deps.sendExecApprovalFollowup ?? sendExecApprovalFollowup;
const warn = deps.logWarn ?? logWarn;
const runtimeHandoff =
const execApprovalFollowupToken =
target.direct === true || !target.sessionKey
? undefined
: registerExecApprovalFollowupRuntimeHandoff({
approvalId: target.approvalId,
: registerExecApprovalFollowupElevatedDefaults({
sessionKey: target.sessionKey,
bashElevated: target.bashElevated,
});
@@ -428,12 +427,7 @@ export async function sendExecApprovalFollowupResult(
turnSourceThreadId: target.turnSourceThreadId,
resultText,
direct: target.direct,
...(runtimeHandoff
? {
internalRuntimeHandoffId: runtimeHandoff.handoffId,
idempotencyKey: runtimeHandoff.idempotencyKey,
}
: {}),
execApprovalFollowupToken,
}).catch((error) => {
const message = formatErrorMessage(error);
const key = `${target.approvalId}:${message}`;

View File

@@ -1610,7 +1610,7 @@ export function createExecTool(
type: "text",
text: `${getWarningText()}Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a"
}). Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.`,
}). Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
},
],
details: {

View File

@@ -7,9 +7,6 @@ export type WritableStdin = {
write: (data: string, cb?: (err?: Error | null) => void) => void;
end: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
function failText(text: string): AgentToolResult<unknown> {

View File

@@ -1,225 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
addSession,
appendOutput,
markExited,
resetProcessRegistryForTests,
} from "./bash-process-registry.js";
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
import { createProcessTool } from "./bash-tools.process.js";
type ProcessTool = ReturnType<typeof createProcessTool>;
type ProcessToolResult = Awaited<ReturnType<ProcessTool["execute"]>>;
afterEach(() => {
resetProcessRegistryForTests();
vi.useRealTimers();
});
async function runProcessAction(
processTool: ProcessTool,
args: Record<string, unknown>,
): Promise<ProcessToolResult> {
return processTool.execute("toolcall", args as Parameters<ProcessTool["execute"]>[1], undefined);
}
function textOf(result: ProcessToolResult): string {
const item = result.content[0];
return item?.type === "text" ? item.text : "";
}
function installWritableStdin(
session: ReturnType<typeof createProcessSessionFixture>,
state?: { writableEnded?: boolean; writableFinished?: boolean; destroyed?: boolean },
) {
session.stdin = {
write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => cb?.(null)),
end: vi.fn(),
destroyed: state?.destroyed ?? false,
writableEnded: state?.writableEnded,
writableFinished: state?.writableFinished,
} as NonNullable<typeof session.stdin> & {
writableEnded?: boolean;
writableFinished?: boolean;
};
}
describe("process input-wait hints", () => {
it("adds output and input-wait metadata to log for an idle writable session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:20.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-log-hint",
command: "node cli.js",
backgrounded: true,
startedAt: Date.now() - 20_000,
});
installWritableStdin(session);
appendOutput(session, "stdout", "Name? ");
addSession(session);
const result = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-log-hint",
});
const text = textOf(result);
expect(text).toContain("Name? ");
expect(text).toContain("No new output for 20s");
expect(text).toContain("Use process write, send-keys, submit, or paste to provide input.");
expect(result.details).toMatchObject({
status: "running",
sessionId: "sess-log-hint",
stdinWritable: true,
waitingForInput: true,
idleMs: 20_000,
lastOutputAt: Date.now() - 20_000,
});
});
it("adds input-wait hints to poll when no new output arrives", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:16.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-poll",
command: "python prompt.py",
backgrounded: true,
startedAt: Date.now() - 16_000,
});
installWritableStdin(session);
addSession(session);
const result = await runProcessAction(processTool, {
action: "poll",
sessionId: "sess-poll",
});
expect(textOf(result)).toContain("(no new output)");
expect(textOf(result)).toContain("may be waiting for input");
expect(result.details).toMatchObject({
status: "running",
sessionId: "sess-poll",
stdinWritable: true,
waitingForInput: true,
idleMs: 16_000,
lastOutputAt: Date.now() - 16_000,
});
});
it("marks idle writable sessions in process list", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-list",
command: "npm run interactive",
backgrounded: true,
startedAt: Date.now() - 30_000,
});
installWritableStdin(session);
addSession(session);
const result = await runProcessAction(processTool, { action: "list" });
expect(textOf(result)).toContain("sess-list");
expect(textOf(result)).toContain("[input-wait]");
const sessions = (result.details as { sessions?: Array<Record<string, unknown>> }).sessions;
expect(sessions?.[0]).toMatchObject({
sessionId: "sess-list",
stdinWritable: true,
waitingForInput: true,
idleMs: 30_000,
});
});
it("adds input-wait metadata and hint text to log", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:25.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-log",
command: "node prompt.js",
backgrounded: true,
startedAt: Date.now() - 25_000,
});
installWritableStdin(session);
appendOutput(session, "stdout", "Password: ");
addSession(session);
const result = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-log",
});
expect(textOf(result)).toContain("Password: ");
expect(textOf(result)).toContain("No new output for 25s");
expect(textOf(result)).toContain("Use process write, send-keys, submit, or paste");
expect(result.details).toMatchObject({
status: "running",
sessionId: "sess-log",
stdinWritable: true,
waitingForInput: true,
idleMs: 25_000,
});
});
it("does not treat ended stdin as writable input-wait state", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:01:00.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-ended",
command: "node closed-stdin.js",
backgrounded: true,
startedAt: Date.now() - 60_000,
});
installWritableStdin(session, { writableEnded: true });
addSession(session);
const log = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-ended",
});
expect(textOf(log)).not.toContain("provide input");
expect(log.details).toMatchObject({
status: "running",
stdinWritable: false,
waitingForInput: false,
});
const write = await runProcessAction(processTool, {
action: "write",
sessionId: "sess-ended",
data: "answer\n",
});
expect(textOf(write)).toContain("stdin is not writable");
expect(write.details).toMatchObject({ status: "failed" });
});
it("can read finished session logs without exposing input controls", async () => {
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-finished",
command: "echo done",
backgrounded: true,
});
appendOutput(session, "stdout", "done\n");
addSession(session);
markExited(session, 0, null, "completed");
const result = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-finished",
});
expect(textOf(result)).toContain("done");
expect(textOf(result)).not.toContain("provide input");
expect(result.details).toMatchObject({
status: "completed",
sessionId: "sess-finished",
exitCode: 0,
});
});
});

View File

@@ -17,14 +17,7 @@ import {
import { describeProcessTool } from "./bash-tools.descriptions.js";
import { handleProcessSendKeys, type WritableStdin } from "./bash-tools.process-send-keys.js";
import { processSchema } from "./bash-tools.schemas.js";
import {
clampWithDefault,
deriveSessionName,
pad,
readEnvInt,
sliceLogLines,
truncateMiddle,
} from "./bash-tools.shared.js";
import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js";
import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js";
import { encodePaste } from "./pty-keys.js";
import { PROCESS_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets.js";
@@ -33,14 +26,10 @@ import type { AgentToolWithMeta } from "./tools/common.js";
export type ProcessToolDefaults = {
cleanupMs?: number;
hasCronTool?: boolean;
inputWaitIdleMs?: number;
scopeKey?: string;
};
const DEFAULT_LOG_TAIL_LINES = 200;
const DEFAULT_INPUT_WAIT_IDLE_MS = 15_000;
const MIN_INPUT_WAIT_IDLE_MS = 1_000;
const MAX_INPUT_WAIT_IDLE_MS = 10 * 60 * 1000;
function resolveLogSliceWindow(offset?: number, limit?: number) {
const usingDefaultTail = offset === undefined && limit === undefined;
@@ -62,36 +51,6 @@ function defaultTailNote(totalLines: number, usingDefaultTail: boolean) {
const MAX_POLL_WAIT_MS = 30_000;
type RunningSessionRuntime = {
stdinWritable: boolean;
waitingForInput: boolean;
idleMs: number;
lastOutputAt: number;
};
function resolveSessionStdin(session: ProcessSession): WritableStdin | undefined {
return (session.stdin ?? session.child?.stdin) as WritableStdin | undefined;
}
function isWritableStdin(stdin: WritableStdin | undefined): stdin is WritableStdin {
if (!stdin || stdin.destroyed) {
return false;
}
if (stdin.writable === false || stdin.writableEnded === true || stdin.writableFinished === true) {
return false;
}
return true;
}
function runningSessionInputDetails(runtime: RunningSessionRuntime) {
return {
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
};
}
function resolvePollWaitMs(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.min(MAX_POLL_WAIT_MS, Math.floor(value)));
@@ -181,36 +140,9 @@ export function createProcessTool(
}
const scopeKey = defaults?.scopeKey;
const supervisor = getProcessSupervisor();
const inputWaitIdleMs = clampWithDefault(
defaults?.inputWaitIdleMs ?? readEnvInt("OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS"),
DEFAULT_INPUT_WAIT_IDLE_MS,
MIN_INPUT_WAIT_IDLE_MS,
MAX_INPUT_WAIT_IDLE_MS,
);
const isInScope = (session?: { scopeKey?: string } | null) =>
!scopeKey || session?.scopeKey === scopeKey;
const describeRunningSession = (session: ProcessSession): RunningSessionRuntime => {
const record = supervisor.getRecord(session.id);
const lastOutputAt = record?.lastOutputAtMs ?? session.startedAt;
const idleMs = Math.max(0, Date.now() - lastOutputAt);
const stdinWritable = isWritableStdin(resolveSessionStdin(session));
return {
stdinWritable,
waitingForInput: stdinWritable && idleMs >= inputWaitIdleMs,
idleMs,
lastOutputAt,
};
};
const buildInputWaitHint = (runtime: RunningSessionRuntime | undefined) => {
if (!runtime?.waitingForInput) {
return "";
}
const idle = formatDurationCompact(runtime.idleMs) ?? `${runtime.idleMs}ms`;
return `\n\nNo new output for ${idle}; this session may be waiting for input. Use process write, send-keys, submit, or paste to provide input.`;
};
const cancelManagedSession = (sessionId: string) => {
const record = supervisor.getRecord(sessionId);
if (!record || record.state === "exited") {
@@ -264,25 +196,18 @@ export function createProcessTool(
if (params.action === "list") {
const running = listRunningSessions()
.filter((s) => isInScope(s))
.map((s) => {
const runtime = describeRunningSession(s);
return {
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
};
});
.map((s) => ({
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
}));
const finished = listFinishedSessions()
.filter((s) => isInScope(s))
.map((s) => ({
@@ -303,10 +228,7 @@ export function createProcessTool(
.toSorted((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
const marker = "waitingForInput" in s && s.waitingForInput ? " [input-wait]" : "";
return `${s.sessionId} ${pad(s.status, 9)} ${
formatDurationCompact(s.runtimeMs) ?? "n/a"
}${marker} :: ${label}`;
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
});
return {
content: [
@@ -349,14 +271,14 @@ export function createProcessTool(
result: failedResult(`Session ${params.sessionId} is not backgrounded.`),
};
}
const stdin = resolveSessionStdin(scopedSession);
if (!isWritableStdin(stdin)) {
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
ok: false as const,
result: failedResult(`Session ${params.sessionId} stdin is not writable.`),
};
}
return { ok: true as const, session: scopedSession, stdin };
return { ok: true as const, session: scopedSession, stdin: stdin as WritableStdin };
};
const writeToStdin = async (stdin: WritableStdin, data: string) => {
@@ -452,7 +374,6 @@ export function createProcessTool(
if (exited) {
resetPollRetrySuggestion(params.sessionId);
}
const runtime = exited ? undefined : describeRunningSession(scopedSession);
return {
content: [
{
@@ -463,7 +384,7 @@ export function createProcessTool(
? `\n\nProcess exited with ${
exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`
}.`
: buildInputWaitHint(runtime) || "\n\nProcess still running."),
: "\n\nProcess still running."),
},
],
details: {
@@ -472,7 +393,6 @@ export function createProcessTool(
exitCode: exited ? exitCode : undefined,
aggregated: scopedSession.aggregated,
name: deriveSessionName(scopedSession.command),
...(runtime ? runningSessionInputDetails(runtime) : {}),
...(typeof retryInMs === "number" ? { retryInMs } : {}),
},
};
@@ -497,16 +417,9 @@ export function createProcessTool(
window.effectiveOffset,
window.effectiveLimit,
);
const runtime = describeRunningSession(scopedSession);
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
return {
content: [
{
type: "text",
text:
(slice || "(no output yet)") + logDefaultTailNote + buildInputWaitHint(runtime),
},
],
content: [{ type: "text", text: (slice || "(no output yet)") + logDefaultTailNote }],
details: {
status: scopedSession.exited ? "completed" : "running",
sessionId: params.sessionId,
@@ -515,7 +428,6 @@ export function createProcessTool(
totalChars,
truncated: scopedSession.truncated,
name: deriveSessionName(scopedSession.command),
...runningSessionInputDetails(runtime),
},
};
}

View File

@@ -50,9 +50,7 @@ export const execSchema = Type.Object({
});
export const processSchema = Type.Object({
action: Type.String({
description: "Process action (list|poll|log|write|send-keys|submit|paste|kill|clear|remove)",
}),
action: Type.String({ description: "Process action" }),
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
data: Type.Optional(Type.String({ description: "Data to write for write" })),
keys: Type.Optional(

View File

@@ -249,51 +249,6 @@ async function runMathSideQuestionAndCaptureContext() {
return context;
}
function expectRecordFields(
record: unknown,
expected: Record<string, unknown>,
): Record<string, unknown> {
expect(record).toBeDefined();
const actual = record as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
return actual;
}
function streamContext(callIndex = 0): {
messages?: Array<Record<string, unknown>>;
systemPrompt?: unknown;
} {
const call = streamSimpleMock.mock.calls[callIndex];
expect(call).toBeDefined();
return (call?.[1] ?? {}) as {
messages?: Array<Record<string, unknown>>;
systemPrompt?: unknown;
};
}
function contextMessages(context: unknown): Array<Record<string, unknown>> {
const messages = (context as { messages?: Array<Record<string, unknown>> }).messages;
expect(messages).toBeDefined();
return messages ?? [];
}
function expectTextBlockContains(block: unknown, text: string): void {
const record = expectRecordFields(block, { type: "text" });
expect(typeof record.text).toBe("string");
expect(record.text).toContain(text);
}
function firstTextBlockIncludes(message: Record<string, unknown>, text: string): boolean {
if (!Array.isArray(message.content)) {
return false;
}
const [block] = message.content;
const blockText = (block as { text?: unknown } | undefined)?.text;
return typeof blockText === "string" && blockText.includes(text);
}
function expectNoAssistantMessages(context: unknown) {
expect(
(context as { messages?: Array<{ role?: string }> }).messages?.filter(
@@ -303,24 +258,28 @@ function expectNoAssistantMessages(context: unknown) {
}
function expectSanitizedAssistantContext(context: unknown, text: string) {
const messages = contextMessages(context);
expect(messages).toHaveLength(3);
expectRecordFields(messages[0], { role: "user" });
expectRecordFields(messages[1], {
role: "assistant",
content: [{ type: "text", text }],
expect(context).toMatchObject({
messages: [
expect.objectContaining({ role: "user" }),
expect.objectContaining({
role: "assistant",
content: [{ type: "text", text }],
}),
expect.objectContaining({ role: "user" }),
],
});
expectRecordFields(messages[2], { role: "user" });
}
function expectSeedOnlyUserContext(context: unknown) {
const messages = contextMessages(context);
expect(messages).toHaveLength(2);
expectRecordFields(messages[0], {
role: "user",
content: [{ type: "text", text: "seed" }],
expect(context).toMatchObject({
messages: [
expect.objectContaining({
role: "user",
content: [{ type: "text", text: "seed" }],
}),
expect.objectContaining({ role: "user" }),
],
});
expectRecordFields(messages[1], { role: "user" });
}
describe("runBtwSideQuestion", () => {
@@ -466,9 +425,13 @@ describe("runBtwSideQuestion", () => {
const result = await runSideQuestion();
expect(result).toEqual({ text: "Final answer." });
const ensureArgs = ensureOpenClawModelsJsonMock.mock.calls[0];
expect(ensureArgs?.[1]).toBe(DEFAULT_AGENT_DIR);
expect(ensureArgs?.[2]).toEqual({ workspaceDir: "/tmp/workspace" });
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(
expect.any(Object),
DEFAULT_AGENT_DIR,
{
workspaceDir: "/tmp/workspace",
},
);
});
it("applies provider runtime auth before streaming github-copilot BTW questions", async () => {
@@ -497,28 +460,29 @@ describe("runBtwSideQuestion", () => {
});
expect(result).toEqual({ text: "Copilot answer." });
const runtimeAuthParams = expectRecordFields(
prepareProviderRuntimeAuthMock.mock.calls[0]?.[0],
{
expect(prepareProviderRuntimeAuthMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "github-copilot",
workspaceDir: "/tmp/workspace",
},
context: expect.objectContaining({
provider: "github-copilot",
modelId: "gpt-5.4",
workspaceDir: "/tmp/workspace",
apiKey: "github-token",
authMode: "token",
profileId: "profile-1",
}),
}),
);
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "github-copilot",
id: "gpt-5.4",
baseUrl: "https://api.enterprise.githubcopilot.com",
}),
expect.anything(),
expect.objectContaining({ apiKey: "copilot-runtime-token" }),
);
expectRecordFields(runtimeAuthParams.context, {
provider: "github-copilot",
modelId: "gpt-5.4",
workspaceDir: "/tmp/workspace",
apiKey: "github-token",
authMode: "token",
profileId: "profile-1",
});
const [streamModel, , streamOptions] = streamSimpleMock.mock.calls[0] ?? [];
expectRecordFields(streamModel, {
provider: "github-copilot",
id: "gpt-5.4",
baseUrl: "https://api.enterprise.githubcopilot.com",
});
expectRecordFields(streamOptions, { apiKey: "copilot-runtime-token" });
});
it("uses the provider's stream fn when registered so provider URL construction runs (#68336)", async () => {
@@ -540,17 +504,16 @@ describe("runBtwSideQuestion", () => {
const result = await runSideQuestion({ provider: "ollama", model: "glm-5.1" });
expect(result).toEqual({ text: "Ollama Cloud answer." });
const registerParams = expectRecordFields(
registerProviderStreamForModelMock.mock.calls[0]?.[0],
{
expect(registerProviderStreamForModelMock).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.objectContaining({
provider: "ollama",
api: "openai-completions",
baseUrl: "https://ollama.com/",
}),
workspaceDir: "/tmp/workspace",
},
}),
);
expectRecordFields(registerParams.model, {
provider: "ollama",
api: "openai-completions",
baseUrl: "https://ollama.com/",
});
expect(providerStreamFn).toHaveBeenCalledTimes(1);
expect(streamSimpleMock).not.toHaveBeenCalled();
});
@@ -621,8 +584,16 @@ describe("runBtwSideQuestion", () => {
const result = await runSideQuestion({ resolvedThinkLevel: "adaptive" });
expect(result).toEqual({ text: "Final answer." });
const [, , options] = streamSimpleMock.mock.calls[0] ?? [];
expect((options as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined();
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ reasoning: undefined }),
);
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.not.objectContaining({ reasoning: expect.anything() }),
);
});
it("fails when the current branch has no messages", async () => {
@@ -651,19 +622,27 @@ describe("runBtwSideQuestion", () => {
const result = await runMathSideQuestion();
expect(result).toEqual({ text: MATH_ANSWER });
const context = streamContext();
expect(String(context.systemPrompt)).toContain("ephemeral /btw side question");
const messages = contextMessages(context);
expect(messages.some((message) => message.role === "user")).toBe(true);
const sideQuestionMessage = messages.find(
(message) =>
message.role === "user" &&
firstTextBlockIncludes(
message,
`<btw_side_question>\n${MATH_QUESTION}\n</btw_side_question>`,
),
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
systemPrompt: expect.stringContaining("ephemeral /btw side question"),
messages: expect.arrayContaining([
expect.objectContaining({ role: "user" }),
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: expect.stringContaining(
`<btw_side_question>\n${MATH_QUESTION}\n</btw_side_question>`,
),
},
],
}),
]),
}),
expect.anything(),
);
expect(sideQuestionMessage).toBeDefined();
});
it("uses the in-flight prompt as background only when there is no prior transcript context", async () => {
@@ -678,11 +657,24 @@ describe("runBtwSideQuestion", () => {
const result = await runSideQuestion({ question: "what are we doing?" });
expect(result).toEqual({ text: "You're building a tic-tac-toe game in Brainfuck." });
const [message] = contextMessages(streamContext());
expectRecordFields(message, { role: "user" });
expectTextBlockContains(
(message.content as Array<unknown>)[0],
"<in_flight_main_task>\nbuild me a tic-tac-toe game in brainfuck\n</in_flight_main_task>",
expect(streamSimpleMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
messages: [
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: expect.stringContaining(
"<in_flight_main_task>\nbuild me a tic-tac-toe game in brainfuck\n</in_flight_main_task>",
),
},
],
}),
],
}),
expect.anything(),
);
});
@@ -691,19 +683,27 @@ describe("runBtwSideQuestion", () => {
await runSideQuestion({ question: "what is the distance to the sun?" });
const context = streamContext();
expect(String(context.systemPrompt)).toContain(
"Do not continue, resume, or complete any unfinished task",
);
const sideQuestionMessage = contextMessages(context).find(
(message) =>
message.role === "user" &&
firstTextBlockIncludes(
message,
"Ignore any unfinished task in the conversation while answering it.",
),
);
expect(sideQuestionMessage).toBeDefined();
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
expect(context).toMatchObject({
systemPrompt: expect.stringContaining(
"Do not continue, resume, or complete any unfinished task",
),
});
expect(context).toMatchObject({
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: expect.stringContaining(
"Ignore any unfinished task in the conversation while answering it.",
),
},
],
}),
]),
});
});
it("branches away from an unresolved trailing user turn before building BTW context", async () => {
@@ -816,12 +816,17 @@ describe("runBtwSideQuestion", () => {
await runMathSideQuestion();
const messages = contextMessages(streamContext());
expect(messages).toHaveLength(3);
expectRecordFields(messages[0], { role: "user" });
expectRecordFields(messages[1], { role: "assistant" });
expectRecordFields(messages[2], { role: "user" });
expect(messages.some((message) => message.role === "toolResult")).toBe(false);
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
expect(context).toMatchObject({
messages: [
expect.objectContaining({ role: "user" }),
expect.objectContaining({ role: "assistant" }),
expect.objectContaining({ role: "user" }),
],
});
expect((context as { messages?: Array<{ role?: string }> }).messages).not.toEqual(
expect.arrayContaining([expect.objectContaining({ role: "toolResult" })]),
);
});
it("strips assistant tool calls from BTW context so no-tool side questions stay tool-free", async () => {
@@ -841,19 +846,23 @@ describe("runBtwSideQuestion", () => {
await runMathSideQuestion();
const context = streamContext();
const [, context] = streamSimpleMock.mock.calls[0] ?? [];
expectSanitizedAssistantContext(context, "Let me check.");
const assistantMessages = contextMessages(context).filter(
(message) => message.role === "assistant",
expect(
(context as { messages?: Array<{ role?: string; content?: Array<{ type?: string }> }> })
.messages,
).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([
expect.objectContaining({ type: "toolCall" }),
expect.objectContaining({ type: "toolUse" }),
expect.objectContaining({ type: "tool_call" }),
]),
}),
]),
);
const assistantContentTypes = assistantMessages.flatMap((message) =>
Array.isArray(message.content)
? message.content.map((block) => (block as { type?: unknown }).type)
: [],
);
expect(assistantContentTypes).not.toContain("toolCall");
expect(assistantContentTypes).not.toContain("toolUse");
expect(assistantContentTypes).not.toContain("tool_call");
});
it("drops assistant messages that contain only tool calls", async () => {
@@ -906,14 +915,17 @@ describe("runBtwSideQuestion", () => {
const context = await runMathSideQuestionAndCaptureContext();
expectSanitizedAssistantContext(context, "Visible answer");
const assistantContentTypes = contextMessages(context)
.filter((message) => message.role === "assistant")
.flatMap((message) =>
Array.isArray(message.content)
? message.content.map((block) => (block as { type?: unknown }).type)
: [],
);
expect(assistantContentTypes).not.toContain("thinking");
expect(
(context as { messages?: Array<{ role?: string; content?: Array<{ type?: string }> }> })
.messages,
).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([expect.objectContaining({ type: "thinking" })]),
}),
]),
);
});
it("drops thinking-only assistant messages from BTW context", async () => {

View File

@@ -157,50 +157,6 @@ function buildPreparedContext(params?: {
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
expect(value, label).toBeTypeOf("object");
expect(value, label).not.toBeNull();
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): Array<unknown> {
expect(Array.isArray(value), label).toBe(true);
return value as Array<unknown>;
}
function callArg(
mock: { mock: { calls: Array<Array<unknown>> } },
callIndex: number,
argIndex: number,
label: string,
) {
const call = mock.mock.calls.at(callIndex);
expect(call, label).toBeDefined();
return call?.[argIndex];
}
async function expectFailoverAttribution(
run: Promise<unknown>,
expected: { sessionId: string; lane: string },
) {
try {
await run;
throw new Error("expected run to fail");
} catch (error) {
const failure = requireRecord(error, "failover error");
expect(failure.name).toBe("FailoverError");
expect(failure.sessionId).toBe(expected.sessionId);
expect(failure.lane).toBe(expected.lane);
}
}
function expectTextMessage(value: unknown, fields: { role: string; content: string }) {
const message = requireRecord(value, "message");
expect(message.role).toBe(fields.role);
expect(message.content).toBe(fields.content);
expect(message.timestamp).toBeTypeOf("number");
}
describe("runCliAgent reliability", () => {
afterEach(() => {
replyRunTesting.resetReplyRunRegistry();
@@ -245,7 +201,7 @@ describe("runCliAgent reliability", () => {
}),
);
await expectFailoverAttribution(
await expect(
executePreparedCliRun(
buildPreparedContext({
cliSessionId: "thread-123",
@@ -254,8 +210,11 @@ describe("runCliAgent reliability", () => {
}),
"thread-123",
),
{ sessionId: "s1", lane: "custom-lane" },
);
).rejects.toMatchObject({
name: "FailoverError",
sessionId: "s1",
lane: "custom-lane",
});
});
it("enqueues a system event and heartbeat wake on no-output watchdog timeout for session runs", async () => {
@@ -287,7 +246,7 @@ describe("runCliAgent reliability", () => {
const [notice, opts] = enqueueSystemEventMock.mock.calls[0] ?? [];
expect(String(notice)).toContain("produced no output");
expect(String(notice)).toContain("interactive input or an approval prompt");
expect(requireRecord(opts, "system event options").sessionKey).toBe("agent:main:main");
expect(opts).toMatchObject({ sessionKey: "agent:main:main" });
expect(requestHeartbeatMock).toHaveBeenCalledWith({
source: "cli-watchdog",
intent: "event",
@@ -381,17 +340,17 @@ describe("runCliAgent reliability", () => {
expect(hookRunner.runLlmInput).toHaveBeenCalledTimes(1);
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
const agentEndEvent = requireRecord(
callArg(hookRunner.runAgentEnd, 0, 0, "agent_end event"),
"agent_end event",
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: "rate limit exceeded",
messages: [
{ role: "user", content: "earlier context", timestamp: expect.any(Number) },
{ role: "user", content: "hi", timestamp: expect.any(Number) },
],
}),
expect.any(Object),
);
expect(agentEndEvent.success).toBe(false);
expect(agentEndEvent.error).toBe("rate limit exceeded");
const messages = requireArray(agentEndEvent.messages, "agent_end messages");
expect(messages).toHaveLength(2);
expectTextMessage(messages[0], { role: "user", content: "earlier context" });
expectTextMessage(messages[1], { role: "user", content: "hi" });
expect(callArg(hookRunner.runAgentEnd, 0, 1, "agent_end context")).toBeTypeOf("object");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -419,20 +378,21 @@ describe("runCliAgent reliability", () => {
expect(result.meta.finalPromptText).toContain("Warning: prompt budget low.");
expect(result.meta.finalPromptText).toContain("hi");
expect(result.meta.finalAssistantRawText).toBe("hello from cli");
const executionTrace = requireRecord(result.meta.executionTrace, "execution trace");
expect(executionTrace.winnerProvider).toBe("codex-cli");
expect(executionTrace.winnerModel).toBe("gpt-5.4");
expect(executionTrace.fallbackUsed).toBe(false);
expect(executionTrace.runner).toBe("cli");
expect(executionTrace.attempts).toEqual([
{ provider: "codex-cli", model: "gpt-5.4", result: "success" },
]);
const requestShaping = requireRecord(result.meta.requestShaping, "request shaping");
expect(requestShaping.thinking).toBe("low");
const completion = requireRecord(result.meta.completion, "completion");
expect(completion.finishReason).toBe("stop");
expect(completion.stopReason).toBe("completed");
expect(completion.refusal).toBe(false);
expect(result.meta.executionTrace).toMatchObject({
winnerProvider: "codex-cli",
winnerModel: "gpt-5.4",
fallbackUsed: false,
runner: "cli",
attempts: [{ provider: "codex-cli", model: "gpt-5.4", result: "success" }],
});
expect(result.meta.requestShaping).toMatchObject({
thinking: "low",
});
expect(result.meta.completion).toMatchObject({
finishReason: "stop",
stopReason: "completed",
refusal: false,
});
});
it("seeds fresh CLI sessions from the OpenClaw transcript", async () => {
@@ -536,8 +496,7 @@ describe("runCliAgent reliability", () => {
});
finishRun?.();
const result = await run;
expect(result.text).toBe("hello from cli");
await expect(run).resolves.toMatchObject({ text: "hello from cli" });
expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(false);
operation.complete();
});
@@ -617,60 +576,57 @@ describe("runCliAgent reliability", () => {
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
const llmInputEvent = requireRecord(
callArg(hookRunner.runLlmInput, 0, 0, "llm_input event"),
"llm_input event",
expect(hookRunner.runLlmInput).toHaveBeenCalledWith(
expect.objectContaining({
runId: "run-2",
sessionId: "s1",
provider: "codex-cli",
model: "gpt-5.4",
prompt: "hi",
systemPrompt: "You are a helpful assistant.",
historyMessages: expect.any(Array),
imagesCount: 0,
}),
expect.objectContaining({
runId: "run-2",
agentId: "main",
sessionKey: "agent:main:main",
sessionId: "s1",
workspaceDir: dir,
messageProvider: "acp",
trigger: "user",
channelId: "telegram",
}),
);
expect(llmInputEvent.runId).toBe("run-2");
expect(llmInputEvent.sessionId).toBe("s1");
expect(llmInputEvent.provider).toBe("codex-cli");
expect(llmInputEvent.model).toBe("gpt-5.4");
expect(llmInputEvent.prompt).toBe("hi");
expect(llmInputEvent.systemPrompt).toBe("You are a helpful assistant.");
expect(Array.isArray(llmInputEvent.historyMessages)).toBe(true);
expect(llmInputEvent.imagesCount).toBe(0);
const llmInputContext = requireRecord(
callArg(hookRunner.runLlmInput, 0, 1, "llm_input context"),
"llm_input context",
expect(hookRunner.runLlmOutput).toHaveBeenCalledWith(
expect.objectContaining({
runId: "run-2",
sessionId: "s1",
provider: "codex-cli",
model: "gpt-5.4",
assistantTexts: ["hello from cli"],
lastAssistant: expect.objectContaining({
role: "assistant",
content: [{ type: "text", text: "hello from cli" }],
provider: "codex-cli",
model: "gpt-5.4",
}),
}),
expect.any(Object),
);
expect(llmInputContext.runId).toBe("run-2");
expect(llmInputContext.agentId).toBe("main");
expect(llmInputContext.sessionKey).toBe("agent:main:main");
expect(llmInputContext.sessionId).toBe("s1");
expect(llmInputContext.workspaceDir).toBe(dir);
expect(llmInputContext.messageProvider).toBe("acp");
expect(llmInputContext.trigger).toBe("user");
expect(llmInputContext.channelId).toBe("telegram");
const llmOutputEvent = requireRecord(
callArg(hookRunner.runLlmOutput, 0, 0, "llm_output event"),
"llm_output event",
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
messages: [
{ role: "user", content: "hi", timestamp: expect.any(Number) },
expect.objectContaining({
role: "assistant",
content: [{ type: "text", text: "hello from cli" }],
}),
],
}),
expect.any(Object),
);
expect(llmOutputEvent.runId).toBe("run-2");
expect(llmOutputEvent.sessionId).toBe("s1");
expect(llmOutputEvent.provider).toBe("codex-cli");
expect(llmOutputEvent.model).toBe("gpt-5.4");
expect(llmOutputEvent.assistantTexts).toEqual(["hello from cli"]);
const lastAssistant = requireRecord(llmOutputEvent.lastAssistant, "last assistant");
expect(lastAssistant.role).toBe("assistant");
expect(lastAssistant.content).toEqual([{ type: "text", text: "hello from cli" }]);
expect(lastAssistant.provider).toBe("codex-cli");
expect(lastAssistant.model).toBe("gpt-5.4");
expect(callArg(hookRunner.runLlmOutput, 0, 1, "llm_output context")).toBeTypeOf("object");
const agentEndEvent = requireRecord(
callArg(hookRunner.runAgentEnd, 0, 0, "agent_end event"),
"agent_end event",
);
expect(agentEndEvent.success).toBe(true);
const messages = requireArray(agentEndEvent.messages, "agent_end messages");
expect(messages).toHaveLength(2);
expectTextMessage(messages[0], { role: "user", content: "hi" });
const assistantMessage = requireRecord(messages[1], "assistant message");
expect(assistantMessage.role).toBe("assistant");
expect(assistantMessage.content).toEqual([{ type: "text", text: "hello from cli" }]);
expect(callArg(hookRunner.runAgentEnd, 0, 1, "agent_end context")).toBeTypeOf("object");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -720,48 +676,37 @@ describe("runCliAgent reliability", () => {
expect(result.meta.livenessState).toBe("blocked");
expect(supervisorSpawnMock).not.toHaveBeenCalled();
expect(hookRunner.runLlmInput).not.toHaveBeenCalled();
const beforeRunEvent = requireRecord(
callArg(hookRunner.runBeforeAgentRun, 0, 0, "before_agent_run event"),
"before_agent_run event",
);
expect(beforeRunEvent.prompt).toBe("secret prompt");
const beforeRunMessages = requireArray(beforeRunEvent.messages, "before_agent_run messages");
expect(
beforeRunMessages.some((message) => {
const record = requireRecord(message, "before_agent_run message");
return record.role === "user" && record.content === "earlier context";
expect(hookRunner.runBeforeAgentRun).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "secret prompt",
messages: expect.arrayContaining([
expect.objectContaining({ role: "user", content: "earlier context" }),
]),
}),
expect.objectContaining({
runId: "run-blocked-cli",
agentId: "main",
sessionKey: "agent:main:main",
}),
).toBe(true);
const beforeRunContext = requireRecord(
callArg(hookRunner.runBeforeAgentRun, 0, 1, "before_agent_run context"),
"before_agent_run context",
);
expect(beforeRunContext.runId).toBe("run-blocked-cli");
expect(beforeRunContext.agentId).toBe("main");
expect(beforeRunContext.sessionKey).toBe("agent:main:main");
await vi.waitFor(() => {
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
const agentEndEvent = requireRecord(
callArg(hookRunner.runAgentEnd, 0, 0, "agent_end event"),
"agent_end event",
);
expect(agentEndEvent.success).toBe(false);
expect(agentEndEvent.error).toBe(
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
);
const agentEndMessages = requireArray(agentEndEvent.messages, "agent_end messages");
expect(
agentEndMessages.some((message) => {
const record = requireRecord(message, "agent_end message");
return (
record.role === "user" &&
record.content ===
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)"
);
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error:
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content:
"Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)",
}),
]),
}),
).toBe(true);
expect(callArg(hookRunner.runAgentEnd, 0, 1, "agent_end context")).toBeTypeOf("object");
expect.any(Object),
);
expect(JSON.stringify(hookRunner.runAgentEnd.mock.calls)).not.toContain("secret prompt");
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
@@ -771,7 +716,9 @@ describe("runCliAgent reliability", () => {
);
expect(JSON.stringify(blockedLine)).not.toContain("secret prompt");
expect(JSON.stringify(blockedLine)).not.toContain("matched secret prompt");
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked.blockedBy).toBe("policy-plugin");
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).toMatchObject({
blockedBy: "policy-plugin",
});
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).not.toHaveProperty("reason");
expect(Object.hasOwn(blockedLine.message.__openclaw, "beforeAgentRunBlocked")).toBe(true);
} finally {
@@ -839,16 +786,14 @@ describe("runCliAgent reliability", () => {
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
const agentEndEvent = requireRecord(
callArg(hookRunner.runAgentEnd, 0, 0, "agent_end event"),
"agent_end event",
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: "rate limit exceeded",
messages: [{ role: "user", content: "hi", timestamp: expect.any(Number) }],
}),
expect.any(Object),
);
expect(agentEndEvent.success).toBe(false);
expect(agentEndEvent.error).toBe("rate limit exceeded");
const messages = requireArray(agentEndEvent.messages, "agent_end messages");
expect(messages).toHaveLength(1);
expectTextMessage(messages[0], { role: "user", content: "hi" });
expect(callArg(hookRunner.runAgentEnd, 0, 1, "agent_end context")).toBeTypeOf("object");
});
it("does not emit duplicate llm_input when session-expired recovery succeeds", async () => {
@@ -916,7 +861,9 @@ describe("runCliAgent reliability", () => {
},
});
expect(result.payloads).toEqual([{ text: "recovered output" }]);
expect(result).toMatchObject({
payloads: [{ text: "recovered output" }],
});
expect(result.meta.finalPromptText).toContain("User: recovered history");
await vi.waitFor(() => {
@@ -924,15 +871,14 @@ describe("runCliAgent reliability", () => {
expect(hookRunner.runLlmOutput).toHaveBeenCalledTimes(1);
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
const llmInputEvent = requireRecord(
callArg(hookRunner.runLlmInput, 0, 0, "llm_input event"),
"llm_input event",
);
const historyMessages = requireArray(llmInputEvent.historyMessages, "history messages");
expect(historyMessages).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES);
const firstHistoryMessage = requireRecord(historyMessages[0], "first history message");
expect(firstHistoryMessage.role).toBe("user");
expect(firstHistoryMessage.content).toBe(`history-5`);
const llmInputCalls = hookRunner.runLlmInput.mock.calls as unknown as Array<Array<unknown>>;
const llmInputEvent = llmInputCalls[0]?.[0] as { historyMessages: unknown[] } | undefined;
expect(llmInputEvent).toMatchObject({ historyMessages: expect.any(Array) });
expect(llmInputEvent?.historyMessages).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES);
expect(llmInputEvent?.historyMessages[0]).toMatchObject({
role: "user",
content: `history-5`,
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}

View File

@@ -188,64 +188,6 @@ describe("models-config", () => {
expect(observedSnapshot).toBe(pluginMetadataSnapshot);
});
it("normalizes retired Gemini ids preserved from existing models.json rows", async () => {
const plan = await planOpenClawModelsJsonWithDeps(
{
cfg: { models: { mode: "merge", providers: {} } },
agentDir: "/tmp/openclaw-models-config-env-vars-test",
env: {},
existingRaw: "",
existingParsed: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
api: "google-generative-ai",
apiKey: "GOOGLE_API_KEY", // pragma: allowlist secret
models: [
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro",
input: ["text"],
},
],
},
},
},
},
{
resolveImplicitProviders: async () => ({
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
models: [
{
id: "gpt-5.5",
name: "GPT-5.5",
input: ["text"],
reasoning: true,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
},
],
},
}),
},
);
expect(plan.action).toBe("write");
if (plan.action !== "write") {
throw new Error("Expected models.json write plan");
}
const parsed = JSON.parse(plan.contents) as {
providers?: Record<string, { models?: Array<{ id?: string }> }>;
};
expect(parsed.providers?.google?.models?.map((model) => model.id)).toEqual([
"gemini-3.1-pro-preview",
]);
});
it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => {
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
unsetEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR]);

View File

@@ -9,7 +9,6 @@ import {
import {
applyNativeStreamingUsageCompat,
enforceSourceManagedProviderSecrets,
normalizeProviderCatalogModelsForConfig,
normalizeProviders,
resolveImplicitProviders,
type ProviderConfig,
@@ -168,15 +167,13 @@ export async function planOpenClawModelsJsonWithDeps(
providers: normalizedProviders,
secretRefManagedProviders,
});
const normalizedMergedProviders =
normalizeProviderCatalogModelsForConfig(mergedProviders) ?? mergedProviders;
const secretEnforcedProviders =
enforceSourceManagedProviderSecrets({
providers: normalizedMergedProviders,
providers: mergedProviders,
sourceProviders: params.sourceConfigForSecrets?.models?.providers,
sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
secretRefManagedProviders,
}) ?? normalizedMergedProviders;
}) ?? mergedProviders;
const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders);
const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`;

View File

@@ -84,26 +84,6 @@ function normalizeProviderModelsForConfig(
: { provider, mutated };
}
export function normalizeProviderCatalogModelsForConfig(
providers: ModelsConfig["providers"],
): ModelsConfig["providers"] {
if (!providers) {
return providers;
}
let mutated = false;
const next: Record<string, ProviderConfig> = {};
for (const [providerKey, provider] of Object.entries(providers)) {
const normalized = normalizeProviderModelsForConfig(providerKey, provider);
if (normalized.mutated) {
mutated = true;
}
next[providerKey] = normalized.provider;
}
return mutated ? next : providers;
}
export function normalizeProviders(params: {
providers: ModelsConfig["providers"];
agentDir: string;

View File

@@ -1,8 +1,5 @@
export { resolveImplicitProviders } from "./models-config.providers.implicit.js";
export {
normalizeProviderCatalogModelsForConfig,
normalizeProviders,
} from "./models-config.providers.normalize.js";
export { normalizeProviders } from "./models-config.providers.normalize.js";
export type { ProviderConfig } from "./models-config.providers.secrets.js";
export { applyNativeStreamingUsageCompat } from "./models-config.providers.policy.js";
export { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js";

View File

@@ -9,7 +9,6 @@ import type {
import { sleepWithAbort } from "../../infra/backoff.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { enqueueCommandInLane, getQueueSize } from "../../process/command-queue.js";
import { CommandPriority } from "../../process/command-queue.types.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
completeTaskRunByRunId,
@@ -316,7 +315,6 @@ export function buildContextEngineMaintenanceRuntimeContext(params: {
return await enqueueCommandInLane(
resolveSessionLane(rewriteSessionKey),
async () => await rewriteTranscriptEntriesInFile(),
{ priority: CommandPriority.Low },
);
}
return await rewriteTranscriptEntriesInFile();
@@ -571,21 +569,18 @@ function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceSchedule
const schedulerAbort = createDeferredTurnMaintenanceAbortSignal();
let runPromise: Promise<void>;
try {
runPromise = enqueueCommandInLane(
resolveDeferredTurnMaintenanceLane(sessionKey),
async () =>
runDeferredTurnMaintenanceWorker({
contextEngine: params.contextEngine,
sessionId: params.sessionId,
sessionKey,
sessionFile: params.sessionFile,
sessionManager: params.sessionManager,
runtimeContext: params.runtimeContext,
agentId: params.agentId,
config: params.config,
runId: task.runId!,
}),
{ priority: CommandPriority.Low },
runPromise = enqueueCommandInLane(resolveDeferredTurnMaintenanceLane(sessionKey), async () =>
runDeferredTurnMaintenanceWorker({
contextEngine: params.contextEngine,
sessionId: params.sessionId,
sessionKey,
sessionFile: params.sessionFile,
sessionManager: params.sessionManager,
runtimeContext: params.runtimeContext,
agentId: params.agentId,
config: params.config,
runId: task.runId!,
}),
);
} catch (err) {
schedulerAbort.dispose();

View File

@@ -16,10 +16,7 @@ import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-con
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveProviderAuthProfileId } from "../../plugins/provider-runtime.js";
import { enqueueCommandInLane } from "../../process/command-queue.js";
import {
CommandPriority,
type CommandQueueEnqueueOptions,
} from "../../process/command-queue.types.js";
import type { CommandQueueEnqueueOptions } from "../../process/command-queue.types.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { resolveUserPath } from "../../utils.js";
@@ -219,23 +216,6 @@ function withEmbeddedRunLaneTimeout(
return { ...opts, taskTimeoutMs: laneTaskTimeoutMs };
}
function resolveEmbeddedRunQueuePriority(params: RunEmbeddedPiAgentParams): CommandPriority {
if (params.queuePriority !== undefined) {
return params.queuePriority;
}
switch (params.trigger) {
case "user":
case "manual":
return CommandPriority.High;
case "cron":
case "heartbeat":
case "memory":
return CommandPriority.Low;
default:
return CommandPriority.Normal;
}
}
function normalizeEmbeddedRunAttemptResult(
attempt: EmbeddedRunAttemptForRunner,
): EmbeddedRunAttemptForRunner {
@@ -395,21 +375,14 @@ export async function runEmbeddedPiAgent(
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
const laneTaskTimeoutMs = resolveEmbeddedRunLaneTimeoutMs(params.timeoutMs);
const queuePriority = resolveEmbeddedRunQueuePriority(params);
const withQueuePriority = (opts?: CommandQueueEnqueueOptions): CommandQueueEnqueueOptions => ({
...opts,
priority: opts?.priority ?? queuePriority,
});
const withLaneTimeout = (opts?: CommandQueueEnqueueOptions) =>
withEmbeddedRunLaneTimeout(withQueuePriority(opts), laneTaskTimeoutMs);
withEmbeddedRunLaneTimeout(opts, laneTaskTimeoutMs);
const enqueueGlobal = <T>(task: () => Promise<T>, opts?: CommandQueueEnqueueOptions) =>
params.enqueue
? params.enqueue(task, withLaneTimeout(opts))
: enqueueCommandInLane(globalLane, task, withLaneTimeout(opts));
const enqueueSession = <T>(task: () => Promise<T>, opts?: CommandQueueEnqueueOptions) =>
params.enqueue
? params.enqueue(task, withQueuePriority(opts))
: enqueueCommandInLane(sessionLane, task, withQueuePriority(opts));
params.enqueue ? params.enqueue(task, opts) : enqueueCommandInLane(sessionLane, task, opts);
const channelHint = params.messageChannel ?? params.messageProvider;
const resolvedToolResultFormat =
params.toolResultFormat ??

View File

@@ -95,8 +95,6 @@ export function createSubscriptionMock(): SubscriptionMock {
return {
assistantTexts: [] as string[],
toolMetas: [] as Array<{ toolName: string; meta?: string }>,
runToolLifecycle: async <T>(toolParams: { execute: () => Promise<T> }) =>
await toolParams.execute(),
unsubscribe: () => {},
setTerminalLifecycleMeta: () => {},
waitForCompactionRetry: async () => {},

View File

@@ -12,9 +12,6 @@ import { buildAgentSystemPrompt } from "../../system-prompt.js";
import { resolveBootstrapContextTargets } from "./attempt-bootstrap-routing.js";
import {
buildContextEnginePromptCacheInfo,
buildAutoAddedToolSearchControlNamesForAllowlistCheck,
buildCallableToolNamesForEmptyAllowlistCheck,
buildToolSearchRunPlan,
buildAfterTurnRuntimeContext,
buildAfterTurnRuntimeContextFromUsage,
composeSystemPromptWithHookContext,
@@ -91,137 +88,6 @@ describe("buildEmbeddedAttemptToolRunContext", () => {
});
});
describe("buildCallableToolNamesForEmptyAllowlistCheck", () => {
it("ignores auto-added Tool Search controls so bad allowlists still fail", () => {
expect(
buildCallableToolNamesForEmptyAllowlistCheck({
effectiveToolNames: ["tool_search_code"],
autoAddedToolSearchControlNames: new Set(["tool_search_code"]),
toolSearchCatalogToolCount: 0,
}),
).toEqual([]);
});
it("counts cataloged tools hidden behind auto-added Tool Search controls", () => {
expect(
buildCallableToolNamesForEmptyAllowlistCheck({
effectiveToolNames: ["tool_search_code"],
autoAddedToolSearchControlNames: new Set(["tool_search_code"]),
toolSearchCatalogToolCount: 1,
}),
).toEqual(["tool-search:0"]);
});
it("keeps explicitly requested Tool Search controls callable", () => {
expect(
buildCallableToolNamesForEmptyAllowlistCheck({
effectiveToolNames: ["tool_search_code"],
autoAddedToolSearchControlNames: new Set(),
toolSearchCatalogToolCount: 0,
}),
).toEqual(["tool_search_code"]);
});
});
describe("buildAutoAddedToolSearchControlNamesForAllowlistCheck", () => {
it("treats controls as auto-added unless any explicit allowlist requested them", () => {
expect(
buildAutoAddedToolSearchControlNamesForAllowlistCheck({
toolSearchControlsEnabled: true,
explicitAllowlistSources: [{ entries: ["missing_tool"] }],
controlNames: ["tool_search_code", "tool_search"],
}),
).toEqual(new Set(["tool_search_code", "tool_search"]));
expect(
buildAutoAddedToolSearchControlNamesForAllowlistCheck({
toolSearchControlsEnabled: true,
explicitAllowlistSources: [{ entries: ["tool_search_code"] }],
controlNames: ["tool_search_code", "tool_search"],
}),
).toEqual(new Set(["tool_search"]));
});
});
describe("buildToolSearchRunPlan", () => {
it("keeps compact visible names separate from replay-safe names", () => {
const plan = buildToolSearchRunPlan({
visibleTools: [{ name: "tool_search_code" }] as never,
uncompactedTools: [
{ name: "tool_search_code" },
{ name: "exec" },
{ name: "fake_plugin_tool" },
] as never,
clientTools: [
{
type: "function",
function: {
name: "client_pick_file",
parameters: { type: "object", properties: {} },
},
},
],
catalogRegistered: true,
catalogToolCount: 2,
controlsEnabled: true,
explicitAllowlistSources: [{ entries: ["missing_tool"] }],
});
expect([...plan.visibleAllowedToolNames]).toEqual(["tool_search_code"]);
expect([...plan.replayAllowedToolNames]).toEqual([
"tool_search_code",
"exec",
"fake_plugin_tool",
"client_pick_file",
]);
expect(plan.emptyAllowlistCallableNames).toEqual(["tool-search:0", "tool-search:1"]);
});
it("counts explicitly allowlisted client tools before they are cataloged later", () => {
const plan = buildToolSearchRunPlan({
visibleTools: [{ name: "tool_search_code" }] as never,
uncompactedTools: [{ name: "tool_search_code" }] as never,
clientTools: [
{
type: "function",
function: {
name: "client_pick_file",
parameters: { type: "object", properties: {} },
},
},
],
catalogRegistered: true,
catalogToolCount: 0,
controlsEnabled: true,
explicitAllowlistSources: [{ entries: ["client_pick_file"] }],
});
expect(plan.emptyAllowlistCallableNames).toEqual(["tool-search-client:client_pick_file"]);
});
it("does not let unrelated client tools mask a bad explicit allowlist", () => {
const plan = buildToolSearchRunPlan({
visibleTools: [{ name: "tool_search_code" }] as never,
uncompactedTools: [{ name: "tool_search_code" }] as never,
clientTools: [
{
type: "function",
function: {
name: "client_pick_file",
parameters: { type: "object", properties: {} },
},
},
],
catalogRegistered: true,
catalogToolCount: 0,
controlsEnabled: true,
explicitAllowlistSources: [{ entries: ["missing_tool"] }],
});
expect(plan.emptyAllowlistCallableNames).toEqual([]);
});
});
describe("normalizeMessagesForLlmBoundary", () => {
it("strips tool result details before provider conversion", () => {
const input = [

View File

@@ -174,22 +174,6 @@ import {
collectExplicitToolAllowlistSources,
} from "../../tool-allowlist-guard.js";
import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js";
import { normalizeToolName } from "../../tool-policy.js";
import {
addClientToolsToToolSearchCatalog,
applyToolSearchCatalog,
clearToolSearchCatalog,
createToolSearchCatalogRef,
projectToolSearchTargetTranscriptMessages,
resolveToolSearchConfig,
TOOL_CALL_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
TOOL_SEARCH_RAW_TOOL_NAME,
type ToolSearchCatalogRef,
type ToolSearchCatalogToolExecutor,
type ToolSearchTargetTranscriptProjection,
} from "../../tool-search.js";
import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js";
import { normalizeUsage, type NormalizedUsage } from "../../usage.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
@@ -244,7 +228,6 @@ import { applySystemPromptOverrideToSession } from "../system-prompt.js";
import { dropReasoningFromHistory, dropThinkingBlocks } from "../thinking.js";
import {
collectAllowedToolNames,
collectCoreBuiltinToolNames,
collectRegisteredToolNames,
PI_RESERVED_TOOL_NAMES,
toSessionToolAllowlist,
@@ -406,114 +389,6 @@ export {
};
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
TOOL_SEARCH_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_CALL_RAW_TOOL_NAME,
];
export function buildCallableToolNamesForEmptyAllowlistCheck(params: {
effectiveToolNames: string[];
autoAddedToolSearchControlNames?: Set<string>;
toolSearchCatalogToolCount: number;
}): string[] {
return [
...params.effectiveToolNames.filter(
(toolName) => !params.autoAddedToolSearchControlNames?.has(toolName),
),
...Array.from(
{ length: params.toolSearchCatalogToolCount },
(_, index) => `tool-search:${index}`,
),
];
}
export function buildAutoAddedToolSearchControlNamesForAllowlistCheck(params: {
toolSearchControlsEnabled: boolean;
explicitAllowlistSources: Array<{ entries: string[] }>;
controlNames?: readonly string[];
}): Set<string> | undefined {
if (!params.toolSearchControlsEnabled) {
return undefined;
}
const explicitlyAllowed = new Set(
params.explicitAllowlistSources.flatMap((source) =>
source.entries.map((entry) => normalizeToolName(entry)),
),
);
return new Set(
(params.controlNames ?? TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES).filter(
(controlName) => !explicitlyAllowed.has(normalizeToolName(controlName)),
),
);
}
export type ToolSearchRunPlan = {
visibleAllowedToolNames: Set<string>;
replayAllowedToolNames: Set<string>;
autoAddedControlNames?: Set<string>;
emptyAllowlistCallableNames: string[];
};
type CollectAllowedToolNamesParams = Parameters<typeof collectAllowedToolNames>[0];
function collectExplicitlyAllowedClientToolNames(params: {
clientTools?: CollectAllowedToolNamesParams["clientTools"];
explicitAllowlistSources: Array<{ entries: string[] }>;
}): string[] {
const explicitNames = new Set(
params.explicitAllowlistSources.flatMap((source) =>
source.entries.map((entry) => normalizeToolName(entry)),
),
);
return (params.clientTools ?? [])
.map((tool) => tool.function?.name)
.filter((name): name is string => Boolean(name?.trim()))
.filter((name) => explicitNames.has(normalizeToolName(name)));
}
export function buildToolSearchRunPlan(params: {
visibleTools: CollectAllowedToolNamesParams["tools"];
uncompactedTools: CollectAllowedToolNamesParams["tools"];
clientTools?: CollectAllowedToolNamesParams["clientTools"];
catalogRegistered: boolean;
catalogToolCount: number;
controlsEnabled: boolean;
explicitAllowlistSources: Array<{ entries: string[] }>;
}): ToolSearchRunPlan {
const visibleAllowedToolNames = collectAllowedToolNames({
tools: params.visibleTools,
clientTools: params.catalogRegistered ? undefined : params.clientTools,
});
const replayAllowedToolNames = collectAllowedToolNames({
tools: params.uncompactedTools,
clientTools: params.clientTools,
});
const autoAddedControlNames = buildAutoAddedToolSearchControlNamesForAllowlistCheck({
toolSearchControlsEnabled: params.controlsEnabled,
explicitAllowlistSources: params.explicitAllowlistSources,
});
const clientCatalogCallableNames = params.catalogRegistered
? collectExplicitlyAllowedClientToolNames({
clientTools: params.clientTools,
explicitAllowlistSources: params.explicitAllowlistSources,
}).map((name) => `tool-search-client:${name}`)
: [];
return {
visibleAllowedToolNames,
replayAllowedToolNames,
autoAddedControlNames,
emptyAllowlistCallableNames: [
...buildCallableToolNamesForEmptyAllowlistCheck({
effectiveToolNames: [...visibleAllowedToolNames],
autoAddedToolSearchControlNames: autoAddedControlNames,
toolSearchCatalogToolCount: params.catalogToolCount,
}),
...clientCatalogCallableNames,
],
};
}
export function resolveUnknownToolGuardThreshold(loopDetection?: {
enabled?: boolean;
@@ -962,25 +837,7 @@ export async function runEmbeddedAttempt(
isRawModelRun,
toolsAllow: params.toolsAllow,
});
const toolsEnabled = supportsModelTools(params.model);
const toolSearchControlsEnabledForRun =
toolsEnabled &&
params.disableTools !== true &&
!isRawModelRun &&
params.toolsAllow?.length !== 0 &&
resolveToolSearchConfig(params.config).enabled;
const effectiveToolsAllow =
toolSearchControlsEnabledForRun && params.toolsAllow
? [...new Set([...params.toolsAllow, ...TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES])]
: params.toolsAllow;
const shouldConstructTools =
toolConstructionPlan.constructTools || toolSearchControlsEnabledForRun;
let toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor | undefined;
const toolSearchCatalogRef: ToolSearchCatalogRef | undefined = toolSearchControlsEnabledForRun
? createToolSearchCatalogRef()
: undefined;
const toolSearchTargetTranscriptProjections: ToolSearchTargetTranscriptProjection[] = [];
const toolsRaw = !shouldConstructTools
const toolsRaw = !toolConstructionPlan.constructTools
? []
: (() => {
const allTools = createOpenClawCodingTools({
@@ -1017,7 +874,6 @@ export async function runEmbeddedAttempt(
: undefined,
sessionId: params.sessionId,
runId: params.runId,
toolSearchCatalogRef,
agentDir,
workspaceDir: effectiveWorkspace,
// When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points
@@ -1040,13 +896,6 @@ export async function runEmbeddedAttempt(
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
includeCoreTools: toolConstructionPlan.includeCoreTools,
includeToolSearchControls: true,
toolSearchCatalogExecutor: (toolParams) => {
if (!toolSearchCatalogExecutor) {
throw new Error("Tool Search catalog executor is unavailable for this run.");
}
return toolSearchCatalogExecutor(toolParams);
},
toolConstructionPlan: toolConstructionPlan.codingToolConstructionPlan,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
@@ -1070,7 +919,7 @@ export async function runEmbeddedAttempt(
},
});
corePluginToolStages.mark("attempt:create-openclaw-coding-tools");
const filteredTools = applyEmbeddedAttemptToolsAllow(allTools, effectiveToolsAllow, {
const filteredTools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, {
toolMeta: (tool) => getPluginToolMeta(tool),
});
corePluginToolStages.mark("attempt:tools-allow");
@@ -1078,6 +927,7 @@ export async function runEmbeddedAttempt(
})();
prepStages.mark("core-plugin-tools");
emitCorePluginToolStageSummary("core-plugin-tools", corePluginToolStages.snapshot());
const toolsEnabled = supportsModelTools(params.model);
const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read");
const bootstrapWarn = makeBootstrapWarn({
sessionLabel,
@@ -1275,40 +1125,12 @@ export async function runEmbeddedAttempt(
ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist,
warn: (message) => log.warn(message),
});
const uncompactedEffectiveTools = [...tools, ...filteredBundledTools];
let effectiveTools = uncompactedEffectiveTools;
const toolSearch = applyToolSearchCatalog({
tools: effectiveTools,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
agentId: sessionAgentId,
runId: params.runId,
catalogRef: toolSearchCatalogRef,
toolHookContext: {
agentId: sessionAgentId,
config: params.config,
cwd: effectiveWorkspace,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
channelId: params.currentChannelId,
trace: runTrace,
loopDetection: resolveToolLoopDetectionConfig({
cfg: params.config,
agentId: sessionAgentId,
}),
onToolOutcome: params.onToolOutcome,
},
});
effectiveTools = toolSearch.tools;
if (toolSearch.compacted) {
prepStages.mark("tool-search");
log.info(
`tool-search: cataloged ${toolSearch.catalogToolCount} tools behind compact prompt surface`,
);
}
const effectiveTools = [...tools, ...filteredBundledTools];
prepStages.mark("bundle-tools");
const allowedToolNames = collectAllowedToolNames({
tools: effectiveTools,
clientTools,
});
const explicitToolAllowlistSources = collectAttemptExplicitToolAllowlistSources({
config: params.config,
sessionKey: params.sessionKey,
@@ -1329,20 +1151,9 @@ export async function runEmbeddedAttempt(
sandboxToolPolicy: sandbox?.tools,
toolsAllow: params.toolsAllow,
});
const toolSearchRunPlan = buildToolSearchRunPlan({
visibleTools: effectiveTools,
uncompactedTools: uncompactedEffectiveTools,
clientTools,
catalogRegistered: toolSearch.catalogRegistered,
catalogToolCount: toolSearch.catalogToolCount,
controlsEnabled: toolSearchControlsEnabledForRun,
explicitAllowlistSources: explicitToolAllowlistSources,
});
const allowedToolNames = toolSearchRunPlan.visibleAllowedToolNames;
const replayAllowedToolNames = toolSearchRunPlan.replayAllowedToolNames;
const emptyExplicitToolAllowlistError = buildEmptyExplicitToolAllowlistError({
sources: explicitToolAllowlistSources,
callableToolNames: toolSearchRunPlan.emptyAllowlistCallableNames,
callableToolNames: effectiveTools.map((tool) => tool.name),
toolsEnabled,
disableTools: params.disableTools,
});
@@ -1636,7 +1447,7 @@ export async function runEmbeddedAttempt(
params.model.api === "openai-codex-responses"
? "aborted"
: undefined,
allowedToolNames: replayAllowedToolNames,
allowedToolNames,
suppressNextUserMessagePersistence: params.suppressNextUserMessagePersistence,
onUserMessagePersisted: (message) => {
params.onUserMessagePersisted?.(message);
@@ -1772,7 +1583,7 @@ export async function runEmbeddedAttempt(
// MEDIA: passthrough gate: a normalized alias is not sufficient — the
// emitted tool name must match an exact registration of this run.
const builtinToolNames = new Set(
uncompactedEffectiveTools.flatMap((tool) => {
effectiveTools.flatMap((tool) => {
const name = (tool.name ?? "").trim();
return name ? [name] : [];
}),
@@ -1782,10 +1593,15 @@ export async function runEmbeddedAttempt(
// plugin tool names. MEDIA passthrough is still gated by the raw-name
// set above, so a client tool that normalize-collides with a plugin
// tool cannot inherit the plugin's local-media trust.
const coreBuiltinToolNames = collectCoreBuiltinToolNames(uncompactedEffectiveTools, {
isPluginTool: (tool) =>
Boolean(getPluginToolMeta(tool as Parameters<typeof getPluginToolMeta>[0])),
});
const coreBuiltinToolNames = new Set(
effectiveTools.flatMap((tool) => {
const name = (tool.name ?? "").trim();
if (!name || getPluginToolMeta(tool)) {
return [];
}
return [name];
}),
);
const clientToolNameConflicts = findClientToolNameConflicts({
tools: clientTools ?? [],
existingToolNames: [...coreBuiltinToolNames, ...PI_RESERVED_TOOL_NAMES],
@@ -1793,7 +1609,7 @@ export async function runEmbeddedAttempt(
if (clientToolNameConflicts.length > 0) {
throw createClientToolNameConflictError(clientToolNameConflicts);
}
let clientToolDefs = clientTools
const clientToolDefs = clientTools
? toClientToolDefinitions(
clientTools,
{
@@ -1835,21 +1651,6 @@ export async function runEmbeddedAttempt(
},
)
: [];
const clientToolSearch = addClientToolsToToolSearchCatalog({
tools: clientToolDefs,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
agentId: sessionAgentId,
runId: params.runId,
catalogRef: toolSearchCatalogRef,
});
clientToolDefs = clientToolSearch.tools;
if (clientToolSearch.compacted) {
log.info(
`tool-search: cataloged ${clientToolSearch.catalogToolCount} client tools behind compact prompt surface`,
);
}
const allCustomTools = [...customTools, ...clientToolDefs];
// Pi treats `tools` as a name allowlist during session creation. Pass the
@@ -2242,7 +2043,7 @@ export async function runEmbeddedAttempt(
const nextMessages = sanitizeReplayToolCallIdsForStream({
messages: messages as AgentMessage[],
mode,
allowedToolNames: replayAllowedToolNames,
allowedToolNames,
preserveNativeAnthropicToolUseIds: transcriptPolicy.preserveNativeAnthropicToolUseIds,
preserveReplaySafeThinkingToolCallIds: shouldAllowProviderOwnedThinkingReplay({
modelApi: (model as { api?: unknown })?.api as string | null | undefined,
@@ -2399,7 +2200,7 @@ export async function runEmbeddedAttempt(
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
allowedToolNames: replayAllowedToolNames,
allowedToolNames,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
@@ -2646,7 +2447,6 @@ export async function runEmbeddedAttempt(
const {
assistantTexts,
toolMetas,
runToolLifecycle,
unsubscribe,
waitForCompactionRetry,
isCompactionInFlight,
@@ -2665,47 +2465,6 @@ export async function runEmbeddedAttempt(
getCompactionCount,
getLastCompactionTokensAfter,
} = subscription;
toolSearchCatalogExecutor = async (toolParams) => {
try {
const result = await runToolLifecycle({
toolName: toolParams.toolName,
toolCallId: toolParams.toolCallId,
args: toolParams.input,
execute: async () =>
await toolParams.tool.execute(
toolParams.toolCallId,
toolParams.input,
toolParams.signal ?? runAbortController.signal,
toolParams.onUpdate,
undefined as never,
),
});
toolSearchTargetTranscriptProjections.push({
parentToolCallId: toolParams.parentToolCallId,
toolCallId: toolParams.toolCallId,
toolName: toolParams.toolName,
input: toolParams.input,
result,
timestamp: Date.now(),
});
return result;
} catch (error) {
const message = formatErrorMessage(error);
toolSearchTargetTranscriptProjections.push({
parentToolCallId: toolParams.parentToolCallId,
toolCallId: toolParams.toolCallId,
toolName: toolParams.toolName,
input: toolParams.input,
result: {
content: [{ type: "text", text: message }],
details: { status: "error", error: message },
},
isError: true,
timestamp: Date.now(),
});
throw error;
}
};
const queueHandle: EmbeddedPiQueueHandle & {
kind: "embedded";
@@ -3260,17 +3019,11 @@ export async function runEmbeddedAttempt(
messages: activeSession.messages,
note: `images: prompt=${imageResult.images.length}`,
});
const trajectoryProviderVisibleTools = toTrajectoryToolDefinitions(effectiveTools);
trajectoryRecorder?.recordEvent("context.compiled", {
systemPrompt: systemPromptForHook,
prompt: promptForModel,
messages: activeSession.messages,
tools: toTrajectoryToolDefinitions(
toolSearch.compacted ? uncompactedEffectiveTools : effectiveTools,
),
...(toolSearch.compacted
? { providerVisibleTools: trajectoryProviderVisibleTools }
: {}),
tools: toTrajectoryToolDefinitions(effectiveTools),
imagesCount: imageResult.images.length,
streamStrategy,
transport: effectiveAgentTransport,
@@ -3644,10 +3397,7 @@ export async function runEmbeddedAttempt(
);
}
}
messagesSnapshot = projectToolSearchTargetTranscriptMessages(
snapshotSelection.messagesSnapshot,
toolSearchTargetTranscriptProjections,
);
messagesSnapshot = snapshotSelection.messagesSnapshot;
sessionIdUsed = snapshotSelection.sessionIdUsed;
lastAssistant = messagesSnapshot
@@ -4109,13 +3859,6 @@ export async function runEmbeddedAttempt(
// See: https://github.com/openclaw/openclaw/issues/8643
let cleanupError: unknown;
try {
clearToolSearchCatalog({
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
agentId: sessionAgentId,
runId: params.runId,
catalogRef: toolSearchCatalogRef,
});
await cleanupEmbeddedAttemptResources({
removeToolResultContextGuard,
flushPendingToolResultsAfterIdle,

View File

@@ -119,27 +119,6 @@ describe("resolveLlmIdleTimeoutMs", () => {
expect(resolveLlmIdleTimeoutMs({ model: { baseUrl } })).toBe(0);
});
it("keeps the default idle watchdog for Ollama cloud models routed through local Ollama", () => {
expect(
resolveLlmIdleTimeoutMs({
model: {
provider: "ollama",
id: "glm-5.1:cloud",
baseUrl: "http://127.0.0.1:11434",
},
}),
).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
expect(
resolveLlmIdleTimeoutMs({
model: {
provider: "ollama2",
id: "ollama2/kimi-k2.5:cloud",
baseUrl: "http://localhost:11434",
},
}),
).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
});
it.each([
"http://172.32.0.1:11434",
"http://192.169.1.1:11434",

View File

@@ -91,23 +91,6 @@ function isLocalProviderBaseUrl(baseUrl: string): boolean {
);
}
function isOllamaCloudModel(model: { id?: string; provider?: string } | undefined): boolean {
const rawModelId = model?.id;
if (typeof rawModelId !== "string") {
return false;
}
const provider = model?.provider?.trim().toLowerCase();
if (provider && !provider.startsWith("ollama")) {
return false;
}
const modelId = rawModelId.trim().toLowerCase();
const slashIndex = modelId.indexOf("/");
const bareModelId = slashIndex >= 0 ? modelId.slice(slashIndex + 1) : modelId;
return bareModelId.endsWith(":cloud");
}
/**
* Resolves the LLM idle timeout from configuration.
* @returns Idle timeout in milliseconds, or 0 to disable
@@ -117,7 +100,7 @@ export function resolveLlmIdleTimeoutMs(params?: {
trigger?: EmbeddedRunTrigger;
runTimeoutMs?: number;
modelRequestTimeoutMs?: number;
model?: { baseUrl?: string; id?: string; provider?: string };
model?: { baseUrl?: string };
}): number {
const clampTimeoutMs = (valueMs: number) => Math.min(Math.floor(valueMs), MAX_SAFE_TIMEOUT_MS);
const clampImplicitTimeoutMs = (valueMs: number) =>
@@ -173,16 +156,9 @@ export function resolveLlmIdleTimeoutMs(params?: {
// Local providers can legitimately stream nothing for many minutes during
// prompt evaluation or thinking, so falling back to the default would abort
// valid local runs. Honor it only when the user has not opted out via the
// baseUrl pointing at loopback / private-network / `.local`. Ollama cloud
// models are still hosted remotely even when proxied through local Ollama, so
// keep the cloud watchdog for `*:cloud` model ids.
// baseUrl pointing at loopback / private-network / `.local`.
const baseUrl = params?.model?.baseUrl;
if (
typeof baseUrl === "string" &&
baseUrl.length > 0 &&
isLocalProviderBaseUrl(baseUrl) &&
!isOllamaCloudModel(params?.model)
) {
if (typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl)) {
return 0;
}

View File

@@ -9,10 +9,7 @@ import type { ReplyOperation } from "../../../auto-reply/reply/reply-run-registr
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
import type {
CommandPriority,
CommandQueueEnqueueFn,
} from "../../../process/command-queue.types.js";
import type { CommandQueueEnqueueFn } from "../../../process/command-queue.types.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.exec-types.js";
import type { AgentStreamParams, ClientToolDefinition } from "../../command/shared-types.js";
@@ -187,7 +184,6 @@ export type RunEmbeddedPiAgentParams = {
sessionKey?: string;
}) => void | Promise<void>;
lane?: string;
queuePriority?: CommandPriority;
enqueue?: CommandQueueEnqueueFn;
extraSystemPrompt?: string;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;

View File

@@ -185,8 +185,7 @@ describe("buildEmbeddedSystemPrompt", () => {
expect(prompt).toContain("Active background exec sessions in this scope:");
expect(prompt).toContain("sess-active running pid=1234 cwd=/tmp/work :: sleep 600");
expect(prompt).toContain("Use process log before interactive input");
expect(prompt).toContain("waitingForInput/stdinWritable");
expect(prompt).toContain("process tool with a sessionId");
expect(prompt).toContain("process list");
});
});

View File

@@ -1,15 +1,7 @@
import { describe, expect, it } from "vitest";
import { findClientToolNameConflicts } from "../pi-tool-definition-adapter.js";
import { createStubTool } from "../test-helpers/pi-tool-stubs.js";
import {
addClientToolsToToolSearchCatalog,
applyToolSearchCatalog,
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
} from "../tool-search.js";
import type { ClientToolDefinition } from "./run/params.js";
import {
collectAllowedToolNames,
collectCoreBuiltinToolNames,
collectRegisteredToolNames,
PI_RESERVED_TOOL_NAMES,
toSessionToolAllowlist,
@@ -53,37 +45,6 @@ describe("tool name allowlists", () => {
expect(allowlist).toEqual(["exec", "image_generate", "read"]);
});
it("keeps hidden core names available for client conflict admission", () => {
const uncompactedTools = [
createStubTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME),
createStubTool("exec"),
createStubTool("message"),
];
const compacted = applyToolSearchCatalog({
tools: uncompactedTools,
config: { tools: { toolSearch: true } } as never,
sessionId: "session-conflict-admission",
});
const names = collectCoreBuiltinToolNames(uncompactedTools);
expect([...names]).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME, "exec", "message"]);
expect(compacted.tools.map((tool) => tool.name)).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]);
expect(
findClientToolNameConflicts({
tools: [
{
type: "function",
function: {
name: "exec",
parameters: { type: "object", properties: {} },
},
},
],
existingToolNames: [...names, ...PI_RESERVED_TOOL_NAMES],
}),
).toEqual(["exec"]);
});
it("pins the reserved Pi built-in tool namespace used by client conflict checks", () => {
expect(PI_RESERVED_TOOL_NAMES).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]);
});
@@ -106,82 +67,4 @@ describe("tool name allowlists", () => {
expect(allowlist).toEqual(["exec", "image_generate", "read"]);
});
it("excludes client tool names when Tool Search compacts them into the catalog", () => {
const config = { tools: { toolSearch: true } } as never;
const compacted = applyToolSearchCatalog({
tools: [createStubTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME)],
config,
sessionId: "session-client-allowed-names",
});
const clientTools: ClientToolDefinition[] = [
{
type: "function",
function: {
name: "client_pick_file",
parameters: { type: "object", properties: {} },
},
},
];
const clientToolSearch = addClientToolsToToolSearchCatalog({
tools: [createStubTool("client_pick_file")],
config,
sessionId: "session-client-allowed-names",
});
const allowlist = toSessionToolAllowlist(
collectAllowedToolNames({
tools: compacted.tools,
clientTools: compacted.catalogRegistered ? undefined : clientTools,
}),
);
expect(compacted.catalogRegistered).toBe(true);
expect(clientToolSearch.tools).toEqual([]);
expect(allowlist).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]);
});
it("keeps hidden catalog tools valid for replay guards after Tool Search compaction", () => {
const config = { tools: { toolSearch: true } } as never;
const uncompactedTools = [
createStubTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME),
createStubTool("exec"),
createStubTool("fake_plugin_tool"),
];
const compacted = applyToolSearchCatalog({
tools: uncompactedTools,
config,
sessionId: "session-replay-allowed-names",
});
const clientTools: ClientToolDefinition[] = [
{
type: "function",
function: {
name: "client_pick_file",
parameters: { type: "object", properties: {} },
},
},
];
const visibleAllowlist = toSessionToolAllowlist(
collectAllowedToolNames({
tools: compacted.tools,
clientTools: compacted.catalogRegistered ? undefined : clientTools,
}),
);
const replayAllowlist = toSessionToolAllowlist(
collectAllowedToolNames({
tools: uncompactedTools,
clientTools,
}),
);
expect(visibleAllowlist).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]);
expect(replayAllowlist).toEqual([
"client_pick_file",
"exec",
"fake_plugin_tool",
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
]);
});
});

View File

@@ -42,20 +42,6 @@ export function collectRegisteredToolNames(tools: Array<{ name?: string }>): Set
return names;
}
export function collectCoreBuiltinToolNames(
tools: Array<{ name?: string }>,
options?: { isPluginTool?: (tool: { name?: string }) => boolean },
): Set<string> {
const names = new Set<string>();
for (const tool of tools) {
if (options?.isPluginTool?.(tool)) {
continue;
}
addName(names, tool.name);
}
return names;
}
export function toSessionToolAllowlist(allowedToolNames: Iterable<string>): string[] {
return [...new Set(allowedToolNames)].toSorted((a, b) => a.localeCompare(b));
}

View File

@@ -28,10 +28,6 @@ import {
consumePendingToolMediaIntoReply,
readPendingToolMediaReply,
} from "./pi-embedded-subscribe.handlers.messages.js";
import {
handleToolExecutionEnd,
handleToolExecutionStart,
} from "./pi-embedded-subscribe.handlers.tools.js";
import type {
EmbeddedPiSubscribeContext,
EmbeddedPiSubscribeState,
@@ -1028,44 +1024,6 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
return {
assistantTexts,
toolMetas,
runToolLifecycle: async <T>(toolParams: {
toolName: string;
toolCallId: string;
args: unknown;
execute: () => Promise<T>;
}): Promise<T> => {
await handleToolExecutionStart(ctx, {
type: "tool_execution_start",
toolName: toolParams.toolName,
toolCallId: toolParams.toolCallId,
args: toolParams.args,
} as never);
try {
const result = await toolParams.execute();
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: toolParams.toolName,
toolCallId: toolParams.toolCallId,
isError: false,
result,
} as never);
return result;
} catch (error) {
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: toolParams.toolName,
toolCallId: toolParams.toolCallId,
isError: true,
result: {
details: {
status: "error",
error: error instanceof Error ? error.message : String(error),
},
},
} as never);
throw error;
}
},
unsubscribe,
setTerminalLifecycleMeta: (meta: {
replayInvalid?: boolean;

View File

@@ -1,7 +1,6 @@
import { copyPluginToolMeta } from "../plugins/tools.js";
import { bindAbortRelay } from "../utils/fetch-timeout.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import { copyBeforeToolCallHookMarker } from "./pi-tools.before-tool-call.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
function throwAbortError(): never {
@@ -69,6 +68,5 @@ export function wrapToolWithAbortSignal(
};
copyPluginToolMeta(tool, wrappedTool);
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
copyBeforeToolCallHookMarker(tool, wrappedTool);
return wrappedTool;
}

View File

@@ -18,7 +18,6 @@ import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import {
__testing as beforeToolCallTesting,
consumeAdjustedParamsForToolCall,
isToolWrappedWithBeforeToolCallHook,
wrapToolWithBeforeToolCallHook,
} from "./pi-tools.before-tool-call.js";
@@ -312,18 +311,6 @@ describe("before_tool_call hook deduplication (#15502)", () => {
expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
});
it("preserves the hook marker when abort wrapping a hooked tool", () => {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const baseTool = { name: "Bash", execute, description: "bash", parameters: {} } as any;
const wrapped = wrapToolWithBeforeToolCallHook(baseTool, {
agentId: "main",
sessionKey: "main",
});
const withAbort = wrapToolWithAbortSignal(wrapped, new AbortController().signal);
expect(isToolWrappedWithBeforeToolCallHook(withAbort)).toBe(true);
});
});
describe("before_tool_call hook integration for client tools", () => {

View File

@@ -791,16 +791,6 @@ export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void {
if (!isToolWrappedWithBeforeToolCallHook(source)) {
return;
}
Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: true,
});
}
export function consumeAdjustedParamsForToolCall(toolCallId: string, runId?: string): unknown {
const adjustedParamsKey = buildAdjustedParamsKey({ runId, toolCallId });
const params = adjustedParamsByToolCallId.get(adjustedParamsKey);

View File

@@ -155,121 +155,6 @@ describe("createOpenClawCodingTools", () => {
expectListIncludes([...values], ["restart", "config.get", "config.patch", "config.apply"]);
});
it("does not add Tool Search control tools from the shared factory by default", () => {
const tools = createOpenClawCodingTools({
config: {
tools: {
toolSearch: true,
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("tool_search_code")).toBe(false);
expect(names.has("tool_search")).toBe(false);
expect(names.has("tool_describe")).toBe(false);
expect(names.has("tool_call")).toBe(false);
});
it("adds PI Tool Search control tools when explicitly requested", () => {
const tools = createOpenClawCodingTools({
includeToolSearchControls: true,
config: {
tools: {
toolSearch: true,
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("tool_search_code")).toBe(true);
expect(names.has("tool_search")).toBe(true);
expect(names.has("tool_describe")).toBe(true);
expect(names.has("tool_call")).toBe(true);
});
it("keeps PI Tool Search controls available under restrictive tool profiles", () => {
const tools = createOpenClawCodingTools({
includeToolSearchControls: true,
config: {
tools: {
profile: "coding",
toolSearch: true,
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("tool_search_code")).toBe(true);
expect(names.has("tool_search")).toBe(true);
expect(names.has("tool_describe")).toBe(true);
expect(names.has("tool_call")).toBe(true);
expect(names.has("message")).toBe(false);
});
it("keeps PI Tool Search controls available under restrictive tool allowlists", () => {
const tools = createOpenClawCodingTools({
includeToolSearchControls: true,
config: {
tools: {
allow: ["read"],
toolSearch: true,
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("tool_search_code")).toBe(true);
expect(names.has("tool_search")).toBe(true);
expect(names.has("tool_describe")).toBe(true);
expect(names.has("tool_call")).toBe(true);
});
it("lets explicit deny policies remove PI Tool Search controls", () => {
const tools = createOpenClawCodingTools({
includeToolSearchControls: true,
config: {
tools: {
profile: "coding",
deny: ["tool_search_code"],
toolSearch: true,
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("tool_search_code")).toBe(false);
expect(names.has("read")).toBe(true);
});
it("keeps PI Tool Search controls when core OpenClaw tools are not materialized", () => {
const tools = createOpenClawCodingTools({
includeCoreTools: false,
includeToolSearchControls: true,
toolConstructionPlan: {
includeBaseCodingTools: false,
includeShellTools: false,
includeChannelTools: false,
includeOpenClawTools: false,
includePluginTools: true,
},
config: {
tools: {
toolSearch: true,
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("tool_search_code")).toBe(true);
expect(names.has("tool_search")).toBe(true);
expect(names.has("tool_describe")).toBe(true);
expect(names.has("tool_call")).toBe(true);
expect(names.has("message")).toBe(false);
});
it("exposes only an explicitly authorized owner-only tool to non-owner sessions", () => {
const tools = createOpenClawCodingTools({
config: testConfig,

View File

@@ -78,16 +78,6 @@ import {
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
import {
createToolSearchTools,
resolveToolSearchConfig,
TOOL_CALL_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
TOOL_SEARCH_RAW_TOOL_NAME,
type ToolSearchCatalogRef,
type ToolSearchCatalogToolExecutor,
} from "./tool-search.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
function isOpenAIProvider(provider?: string) {
@@ -386,12 +376,6 @@ export function createOpenClawCodingTools(options?: {
forceHeartbeatTool?: boolean;
/** If false, build plugin tools only while preserving the shared policy pipeline. */
includeCoreTools?: boolean;
/** PI-only: expose OpenClaw Tool Search controls for catalog compaction. */
includeToolSearchControls?: boolean;
/** Executes cataloged tools through the active PI run lifecycle. */
toolSearchCatalogExecutor?: ToolSearchCatalogToolExecutor;
/** Runtime-local Tool Search catalog ref shared with PI attempt compaction. */
toolSearchCatalogRef?: ToolSearchCatalogRef;
/** Limits which tool families are materialized before the shared policy pipeline runs. */
toolConstructionPlan?: OpenClawCodingToolConstructionPlan;
/** Whether the sender is an owner (required for owner-only tools). */
@@ -466,24 +450,9 @@ export function createOpenClawCodingTools(options?: {
(options?.trigger === "heartbeat" &&
options?.config?.messages?.visibleReplies === "message_tool");
const forceHeartbeatTool = options?.forceHeartbeatTool === true || enableHeartbeatTool;
const toolSearchConfig = resolveToolSearchConfig(options?.config);
const toolSearchControlsEnabled =
options?.includeToolSearchControls === true && toolSearchConfig.enabled;
const toolSearchControlAllowlist = toolSearchControlsEnabled
? [
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
TOOL_SEARCH_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_CALL_RAW_TOOL_NAME,
]
: [];
const mergeToolSearchControlAllowlist = <TPolicy extends { allow?: string[] }>(
policy: TPolicy | undefined,
) => mergeAlsoAllowPolicy(policy, toolSearchControlAllowlist);
const runtimeProfileAlsoAllow = [
...(options?.forceMessageTool ? ["message"] : []),
...(forceHeartbeatTool ? [HEARTBEAT_RESPONSE_TOOL_NAME] : []),
...toolSearchControlAllowlist,
];
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, [
...(profileAlsoAllow ?? []),
@@ -514,26 +483,16 @@ export function createOpenClawCodingTools(options?: {
store: subagentStore,
})
: undefined;
const globalPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(globalPolicy);
const globalProviderPolicyWithToolSearchControls =
mergeToolSearchControlAllowlist(globalProviderPolicy);
const agentPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(agentPolicy);
const agentProviderPolicyWithToolSearchControls =
mergeToolSearchControlAllowlist(agentProviderPolicy);
const groupPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(groupPolicy);
const sandboxToolPolicyWithToolSearchControls =
mergeToolSearchControlAllowlist(sandboxToolPolicy);
const subagentPolicyWithToolSearchControls = mergeToolSearchControlAllowlist(subagentPolicy);
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow,
globalPolicyWithToolSearchControls,
globalProviderPolicyWithToolSearchControls,
agentPolicyWithToolSearchControls,
agentProviderPolicyWithToolSearchControls,
groupPolicyWithToolSearchControls,
sandboxToolPolicyWithToolSearchControls,
subagentPolicyWithToolSearchControls,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandboxToolPolicy,
subagentPolicy,
]);
options?.recordToolPrepStage?.("tool-policy");
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
@@ -746,19 +705,6 @@ export function createOpenClawCodingTools(options?: {
},
resolvedConfig: options?.config,
});
const toolSearchTools = toolSearchControlsEnabled
? createToolSearchTools({
config: options?.config,
runtimeConfig: options?.config,
agentId,
sessionKey: options?.sessionKey,
sessionId: options?.sessionId,
runId: options?.runId,
catalogRef: options?.toolSearchCatalogRef,
abortSignal: options?.abortSignal,
executeTool: options?.toolSearchCatalogExecutor,
})
: [];
const tools: AnyAgentTool[] = [
...base,
...(includeBaseCodingTools && sandboxRoot
@@ -842,7 +788,6 @@ export function createOpenClawCodingTools(options?: {
recordToolPrepStage: options?.recordToolPrepStage,
})
: pluginToolsOnly),
...toolSearchTools,
];
options?.recordToolPrepStage?.("openclaw-tools");
const toolsForMemoryFlush: AnyAgentTool[] = isMemoryFlushRun && memoryFlushWritePath ? [] : tools;
@@ -902,15 +847,15 @@ export function createOpenClawCodingTools(options?: {
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
providerProfile,
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
globalPolicy: globalPolicyWithToolSearchControls,
globalProviderPolicy: globalProviderPolicyWithToolSearchControls,
agentPolicy: agentPolicyWithToolSearchControls,
agentProviderPolicy: agentProviderPolicyWithToolSearchControls,
groupPolicy: groupPolicyWithToolSearchControls,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
agentId,
}),
{ policy: sandboxToolPolicyWithToolSearchControls, label: "sandbox tools.allow" },
{ policy: subagentPolicyWithToolSearchControls, label: "subagent tools.allow" },
{ policy: sandboxToolPolicy, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
options?.recordToolPrepStage?.("authorization-policy");

View File

@@ -1299,7 +1299,7 @@ function buildActiveProcessSessionReferenceLines(
const cwd = session.cwd ? ` cwd=${sanitizeForPromptLiteral(session.cwd)}` : "";
return `- ${session.sessionId} ${session.status}${pid}${cwd} :: ${sanitizeForPromptLiteral(session.name)}`;
}),
"Use process log before interactive input; log/poll may report waitingForInput/stdinWritable. If prior context lost a sessionId, run process list.",
"Use the process tool with a sessionId to poll, log, write to, or terminate these sessions. If prior context lost a sessionId, run process list.",
];
}

View File

@@ -1,858 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { setPluginToolMeta } from "../plugins/tools.js";
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import {
isToolWrappedWithBeforeToolCallHook,
wrapToolWithBeforeToolCallHook,
} from "./pi-tools.before-tool-call.js";
import {
__testing,
addClientToolsToToolSearchCatalog,
applyToolSearchCatalog,
clearToolSearchCatalog,
createToolSearchCatalogRef,
createToolSearchTools,
projectToolSearchTargetTranscriptMessages,
TOOL_CALL_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
TOOL_SEARCH_RAW_TOOL_NAME,
} from "./tool-search.js";
import { jsonResult, type AnyAgentTool } from "./tools/common.js";
function fakeTool(name: string, description: string): AnyAgentTool {
return {
name,
label: name,
description,
parameters: {
type: "object",
properties: {
value: { type: "string" },
},
},
execute: vi.fn(async (_toolCallId, input) => jsonResult({ name, input })),
};
}
function pluginTool(name: string, description: string, pluginId = "fake-catalog"): AnyAgentTool {
const tool = fakeTool(name, description);
setPluginToolMeta(tool, {
pluginId,
optional: true,
});
return tool;
}
describe("Tool Search", () => {
it("enables object config when a mode is set", () => {
expect(
__testing.resolveToolSearchConfig({
tools: {
toolSearch: {
mode: "tools",
},
},
} as never),
).toMatchObject({
enabled: true,
mode: "tools",
});
});
it("falls back to structured controls when code mode is unsupported", () => {
__testing.setToolSearchCodeModeSupportedForTest(false);
try {
const config = { tools: { toolSearch: true } } as never;
const resolved = __testing.resolveToolSearchConfig(config);
const compacted = applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"),
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
pluginTool("fake_bun_fallback", "Fallback target"),
],
config,
sessionId: "session-code-unsupported",
});
expect(resolved.mode).toBe("tools");
expect(compacted.tools.map((tool) => tool.name)).toEqual([
TOOL_SEARCH_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_CALL_RAW_TOOL_NAME,
]);
expect(compacted.catalogToolCount).toBe(1);
} finally {
__testing.setToolSearchCodeModeSupportedForTest(undefined);
}
});
it("compacts plugin tools behind the code surface and can search, describe, and call them", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const alpha = pluginTool("fake_create_ticket", "Create a ticket in the fake tracker");
const beta = pluginTool("fake_weather", "Read fake weather");
const compacted = applyToolSearchCatalog({
tools: [codeTool, alpha, beta],
config: {
tools: {
toolSearch: true,
},
} as never,
sessionId: "session-1",
sessionKey: "agent:main:main",
});
expect(compacted.tools.map((tool) => tool.name)).toEqual([TOOL_SEARCH_CODE_MODE_TOOL_NAME]);
expect(compacted.catalogToolCount).toBe(2);
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-1",
sessionKey: "agent:main:main",
config: compacted.tools[0] ? {} : undefined,
});
const result = await runtimeCodeTool.execute("call-1", {
code: `
const hits = await openclaw.tools.search("ticket", { limit: 1 });
const described = await openclaw.tools.describe(hits[0].id);
return await openclaw.tools.call(described.id, { value: "ship" });
`,
});
expect(alpha.execute).toHaveBeenCalledWith(
"tool_search_code:call-1:fake_create_ticket:1",
{
value: "ship",
},
expect.any(AbortSignal),
undefined,
undefined,
);
expect(result.details).toMatchObject({
ok: true,
telemetry: {
catalogSize: 2,
searchCount: 1,
describeCount: 1,
callCount: 1,
},
});
});
it("scopes catalogs by run id when attempts share a session", async () => {
const runATool = pluginTool("fake_run_a", "Tool visible only to run A");
const runBTool = pluginTool("fake_run_b", "Tool visible only to run B");
const config = {
tools: {
toolSearch: true,
},
} as never;
applyToolSearchCatalog({
tools: [fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"), runATool],
config,
sessionId: "session-overlap",
sessionKey: "agent:main:main",
runId: "run-a",
});
applyToolSearchCatalog({
tools: [fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"), runBTool],
config,
sessionId: "session-overlap",
sessionKey: "agent:main:main",
runId: "run-b",
});
const [, , , runACallTool] = createToolSearchTools({
sessionId: "session-overlap",
sessionKey: "agent:main:main",
runId: "run-a",
config,
});
await runACallTool.execute("call-run-a", {
id: "fake_run_a",
args: { value: "A" },
});
await expect(
runACallTool.execute("call-run-a-miss", {
id: "fake_run_b",
args: { value: "B" },
}),
).rejects.toThrow("Unknown tool id: fake_run_b");
clearToolSearchCatalog({
sessionId: "session-overlap",
sessionKey: "agent:main:main",
runId: "run-a",
});
expect(__testing.sessionCatalogs.has("run:run-a")).toBe(false);
expect(__testing.sessionCatalogs.has("run:run-b")).toBe(true);
expect(runATool.execute).toHaveBeenCalledTimes(1);
expect(runBTool.execute).not.toHaveBeenCalled();
clearToolSearchCatalog({ runId: "run-b" });
});
it("uses the runtime-local catalog ref before the shared catalog registry", async () => {
const localRef = createToolSearchCatalogRef();
const localTool = pluginTool("fake_local_ref", "Tool visible through the local ref");
const globalTool = pluginTool("fake_global_ref", "Tool visible through the registry fallback");
const config = { tools: { toolSearch: true } } as never;
applyToolSearchCatalog({
tools: [fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"), localTool],
config,
sessionId: "session-catalog-ref",
runId: "run-local-ref",
catalogRef: localRef,
});
applyToolSearchCatalog({
tools: [fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"), globalTool],
config,
sessionId: "session-catalog-ref",
});
const [, , , callTool] = createToolSearchTools({
sessionId: "session-catalog-ref",
runId: "run-local-ref",
catalogRef: localRef,
config,
});
await callTool.execute("call-local-ref", {
id: "fake_local_ref",
args: { value: "local" },
});
await expect(
callTool.execute("call-global-ref", {
id: "fake_global_ref",
args: { value: "global" },
}),
).rejects.toThrow("Unknown tool id: fake_global_ref");
expect(localTool.execute).toHaveBeenCalledTimes(1);
expect(globalTool.execute).not.toHaveBeenCalled();
clearToolSearchCatalog({ runId: "run-local-ref", catalogRef: localRef });
clearToolSearchCatalog({ sessionId: "session-catalog-ref" });
});
it("keeps raw fallback tools and hides the code tool in tools mode", () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const searchTool = fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search");
const describeTool = fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe");
const callTool = fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call");
const target = pluginTool("fake_lookup", "Lookup fake records");
const compacted = applyToolSearchCatalog({
tools: [codeTool, searchTool, describeTool, callTool, target],
config: {
tools: {
toolSearch: { enabled: true, mode: "tools" },
},
} as never,
sessionId: "session-raw",
});
expect(compacted.tools.map((tool) => tool.name)).toEqual([
TOOL_SEARCH_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_CALL_RAW_TOOL_NAME,
]);
expect(compacted.catalogToolCount).toBe(1);
});
it("drops inactive controls when the selected Tool Search control is unavailable", () => {
const searchTool = fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search");
const describeTool = fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe");
const callTool = fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call");
const target = pluginTool("fake_lookup_direct", "Lookup fake records directly");
const compacted = applyToolSearchCatalog({
tools: [searchTool, describeTool, callTool, target],
config: {
tools: {
toolSearch: true,
},
} as never,
sessionId: "session-code-control-denied",
});
expect(compacted.tools.map((tool) => tool.name)).toEqual(["fake_lookup_direct"]);
expect(compacted.catalogRegistered).toBe(false);
expect(compacted.catalogToolCount).toBe(0);
});
it("moves client tools into the same catalog when a session catalog exists", () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const config = {
tools: {
toolSearch: true,
},
} as never;
applyToolSearchCatalog({
tools: [codeTool],
config,
sessionId: "session-client",
});
const clientTool = fakeTool("client_pick_file", "Ask the client to pick a file");
const compacted = addClientToolsToToolSearchCatalog({
tools: [clientTool],
config,
sessionId: "session-client",
});
expect(compacted.tools).toEqual([]);
expect(compacted.catalogToolCount).toBe(1);
expect(__testing.sessionCatalogs.get("session:session-client")?.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "client:client:client_pick_file",
source: "client",
}),
]),
);
});
it("wraps cataloged OpenClaw tools with before_tool_call hooks", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_hooked", "Run a hook-aware fake tool");
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-hooks",
toolHookContext: {
agentId: "agent-main",
sessionId: "session-hooks",
sessionKey: "agent:main:main",
},
});
const entry = __testing.sessionCatalogs
.get("session:session-hooks")
?.entries.find((candidate) => candidate.name === "fake_hooked");
expect(entry).toBeTruthy();
expect(isToolWrappedWithBeforeToolCallHook(entry!.tool as AnyAgentTool)).toBe(true);
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-hooks",
sessionKey: "agent:main:main",
config: {},
});
await runtimeCodeTool.execute("call-hooks", {
code: `return await openclaw.tools.call("fake_hooked", { value: "ok" });`,
});
expect(target.execute).toHaveBeenCalledWith(
"tool_search_code:call-hooks:fake_hooked:1",
{ value: "ok" },
expect.any(AbortSignal),
undefined,
);
});
it("does not re-wrap abort-wrapped tools that already have before_tool_call hooks", () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_already_hooked", "Already hook-aware fake tool");
const hooked = wrapToolWithBeforeToolCallHook(target, {
agentId: "agent-main",
sessionId: "session-hooks-abort",
sessionKey: "agent:main:main",
});
const abortWrapped = wrapToolWithAbortSignal(hooked, new AbortController().signal);
applyToolSearchCatalog({
tools: [codeTool, abortWrapped],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-hooks-abort",
toolHookContext: {
agentId: "agent-main",
sessionId: "session-hooks-abort",
sessionKey: "agent:main:main",
},
});
const entry = __testing.sessionCatalogs
.get("session:session-hooks-abort")
?.entries.find((candidate) => candidate.name === "fake_already_hooked");
expect(entry?.tool).toBe(abortWrapped);
expect(isToolWrappedWithBeforeToolCallHook(entry!.tool as AnyAgentTool)).toBe(true);
});
it("uses a unique bridged tool call id for repeated calls", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_repeated", "Run a repeated fake tool");
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-repeated",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-repeated",
sessionKey: "agent:main:main",
config: {},
});
await runtimeCodeTool.execute("call-repeated", {
code: `
await openclaw.tools.call("fake_repeated", { value: "one" });
return await openclaw.tools.call("fake_repeated", { value: "two" });
`,
});
expect(target.execute).toHaveBeenNthCalledWith(
1,
"tool_search_code:call-repeated:fake_repeated:1",
{
value: "one",
},
expect.any(AbortSignal),
undefined,
undefined,
);
expect(target.execute).toHaveBeenNthCalledWith(
2,
"tool_search_code:call-repeated:fake_repeated:2",
{
value: "two",
},
expect.any(AbortSignal),
undefined,
undefined,
);
await runtimeCodeTool.execute("call-repeated-again", {
code: `return await openclaw.tools.call("fake_repeated", { value: "three" });`,
});
expect(target.execute).toHaveBeenNthCalledWith(
3,
"tool_search_code:call-repeated-again:fake_repeated:1",
{
value: "three",
},
expect.any(AbortSignal),
undefined,
undefined,
);
});
it("routes bridged calls through the configured catalog executor", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_lifecycle", "Run through lifecycle executor");
const abortController = new AbortController();
const onUpdate = vi.fn();
const executeTool = vi.fn(async () => jsonResult({ status: "ok" }));
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-lifecycle",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool, , , runtimeCallTool] = createToolSearchTools({
sessionId: "session-lifecycle",
sessionKey: "agent:main:main",
config: {},
abortSignal: abortController.signal,
executeTool,
});
await runtimeCodeTool.execute(
"call-lifecycle",
{
code: `return await openclaw.tools.call("fake_lifecycle", { value: "ok" });`,
},
undefined,
onUpdate,
);
expect(target.execute).not.toHaveBeenCalled();
expect(executeTool).toHaveBeenCalledWith(
expect.objectContaining({
tool: expect.objectContaining({ name: "fake_lifecycle" }),
toolName: "fake_lifecycle",
toolCallId: "tool_search_code:call-lifecycle:fake_lifecycle:1",
parentToolCallId: "call-lifecycle",
input: { value: "ok" },
signal: expect.any(AbortSignal),
onUpdate,
}),
);
await runtimeCallTool.execute(
"call-lifecycle-structured",
{
id: "fake_lifecycle",
args: { value: "structured" },
},
abortController.signal,
onUpdate,
);
expect(target.execute).not.toHaveBeenCalled();
expect(executeTool).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
tool: expect.objectContaining({ name: "fake_lifecycle" }),
toolName: "fake_lifecycle",
toolCallId: "tool_search_code:call-lifecycle-structured:fake_lifecycle:1",
parentToolCallId: "call-lifecycle-structured",
input: { value: "structured" },
signal: abortController.signal,
onUpdate,
}),
);
});
it("projects target tool calls after their Tool Search wrapper result", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "wrapper-call",
name: TOOL_CALL_RAW_TOOL_NAME,
arguments: { id: "fake_target", args: { value: "ok" } },
},
],
},
{
role: "toolResult",
toolCallId: "wrapper-call",
toolName: TOOL_CALL_RAW_TOOL_NAME,
content: [{ type: "text", text: "wrapped" }],
},
{
role: "assistant",
content: [{ type: "text", text: "done" }],
},
];
const projected = projectToolSearchTargetTranscriptMessages(messages as never, [
{
parentToolCallId: "wrapper-call",
toolCallId: "tool_search_code:wrapper-call:fake_target:1",
toolName: "fake_target",
input: { value: "ok" },
result: jsonResult({ ok: true }),
timestamp: 123,
},
]);
expect(projected).toHaveLength(5);
expect(projected[2]).toMatchObject({
role: "assistant",
content: [
{
type: "toolCall",
id: "tool_search_code:wrapper-call:fake_target:1",
name: "fake_target",
arguments: { value: "ok" },
input: { value: "ok" },
},
],
});
expect(projected[3]).toMatchObject({
role: "toolResult",
toolCallId: "tool_search_code:wrapper-call:fake_target:1",
toolName: "fake_target",
isError: false,
content: [{ type: "text", text: JSON.stringify({ ok: true }, null, 2) }],
});
expect(projected[4]).toBe(messages[2]);
});
it("does not execute fire-and-forget bridged calls after code returns", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_fire_and_forget", "Should not run unless awaited");
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-fire-and-forget",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-fire-and-forget",
sessionKey: "agent:main:main",
config: {},
});
const result = await runtimeCodeTool.execute("call-fire-and-forget", {
code: `
openclaw.tools.call("fake_fire_and_forget", { value: "late" });
return "done";
`,
});
expect(target.execute).not.toHaveBeenCalled();
expect(result.details).toMatchObject({
ok: true,
value: "done",
telemetry: {
callCount: 0,
},
});
});
it("waits for started bridged calls before returning code-mode success", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_then_started", "Started by .then without await");
let resolveTool: (() => void) | undefined;
target.execute = vi.fn(
async (_toolCallId: string, input: unknown): Promise<ReturnType<typeof jsonResult>> => {
await new Promise<void>((resolve) => {
resolveTool = resolve;
});
return jsonResult({ name: target.name, input });
},
);
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-started-bridge",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-started-bridge",
sessionKey: "agent:main:main",
config: {},
});
let settled = false;
const resultPromise = runtimeCodeTool
.execute("call-started-bridge", {
code: `
openclaw.tools.call("fake_then_started", { value: "started" }).then(() => {});
return "done";
`,
})
.then((result) => {
settled = true;
return result;
});
await vi.waitFor(() => expect(target.execute).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
resolveTool?.();
const result = await resultPromise;
expect(result.details).toMatchObject({
ok: true,
value: "done",
telemetry: {
callCount: 1,
},
});
});
it("does not expose the host process to model-authored code", async () => {
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-escape",
sessionKey: "agent:main:main",
config: {},
});
await expect(
runtimeCodeTool.execute("call-escape", {
code: `return Function("return process")();`,
}),
).rejects.toThrow();
await expect(
runtimeCodeTool.execute("call-constructor-escape", {
code: `return globalThis.constructor.constructor("return process")();`,
}),
).rejects.toThrow();
await expect(
runtimeCodeTool.execute("call-console-escape", {
code: `return console.log.constructor.constructor("return process")();`,
}),
).rejects.toThrow();
await expect(
runtimeCodeTool.execute("call-bridge-escape", {
code: `return openclaw.tools.call.constructor.constructor("return process")();`,
}),
).rejects.toThrow();
});
it("preserves code-mode bridge errors from the child process", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
applyToolSearchCatalog({
tools: [codeTool],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-missing-tool-error",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-missing-tool-error",
sessionKey: "agent:main:main",
config: {},
});
await expect(
runtimeCodeTool.execute("call-missing-tool", {
code: `return await openclaw.tools.call("missing_tool", {});`,
}),
).rejects.toThrow("Unknown tool id: missing_tool");
});
it("does not expose host-realm bridge result objects to model-authored code", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_bridge_result_escape", "Target for bridge result escape");
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-bridge-result-escape",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-bridge-result-escape",
sessionKey: "agent:main:main",
config: {},
});
await expect(
runtimeCodeTool.execute("call-bridge-result-escape", {
code: `
const hits = await openclaw.tools.search("bridge result", { limit: 1 });
return hits.constructor.constructor("return process")();
`,
}),
).rejects.toThrow();
expect(target.execute).not.toHaveBeenCalled();
});
it("does not let model-authored code access bridge controller locals", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_controller_escape", "Target for forged bridge request");
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-controller-escape",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-controller-escape",
sessionKey: "agent:main:main",
config: {},
});
await expect(
runtimeCodeTool.execute("call-controller-escape", {
code: `
})(openclaw, console),
bridgeMessages.push({
id: "forged",
method: "call",
args: ["fake_controller_escape", { value: "forged" }],
}),
(async (openclaw, console) => {
return "done";
`,
}),
).rejects.toThrow();
expect(target.execute).not.toHaveBeenCalled();
});
it("terminates async continuations that block the event loop after a bridge call", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const alpha = pluginTool("fake_timeout_target", "Target tool for timeout search");
const config = {
tools: {
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 1000 },
},
} as never;
applyToolSearchCatalog({
tools: [codeTool, alpha],
config,
sessionId: "session-timeout",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-timeout",
sessionKey: "agent:main:main",
config,
});
await expect(
runtimeCodeTool.execute("call-timeout", {
code: `
await openclaw.tools.search("timeout", { limit: 1 });
while (true) {}
`,
}),
).rejects.toThrow("tool_search_code timed out");
}, 5_000);
it("aborts already-started bridged calls when code mode times out", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_abort_on_timeout", "Long-running target tool");
let observedSignal: AbortSignal | undefined;
let abortCount = 0;
target.execute = vi.fn(
async (
_toolCallId: string,
_input: unknown,
signal?: AbortSignal,
): Promise<ReturnType<typeof jsonResult>> => {
observedSignal = signal;
await new Promise<void>((resolve) => {
if (signal?.aborted) {
abortCount += 1;
resolve();
return;
}
signal?.addEventListener(
"abort",
() => {
abortCount += 1;
resolve();
},
{ once: true },
);
});
return jsonResult({ aborted: true });
},
);
const config = {
tools: {
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 100 },
},
} as never;
applyToolSearchCatalog({
tools: [codeTool, target],
config,
sessionId: "session-abort-timeout",
sessionKey: "agent:main:main",
});
const [runtimeCodeTool] = createToolSearchTools({
sessionId: "session-abort-timeout",
sessionKey: "agent:main:main",
config,
});
await expect(
runtimeCodeTool.execute("call-abort-timeout", {
code: `return await openclaw.tools.call("fake_abort_on_timeout", { value: "wait" });`,
}),
).rejects.toThrow("tool_search_code timed out");
expect(observedSignal).toBeDefined();
expect(observedSignal?.aborted).toBe(true);
expect(abortCount).toBe(1);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -120,43 +120,6 @@ function requireImageGenerateTool(tool: ReturnType<typeof createImageGenerateToo
return tool;
}
type UnknownMock = { mock: { calls: unknown[][] } };
function mockCallArg(
mock: unknown,
index: number,
label: string,
argIndex = 0,
): Record<string, unknown> {
const calls = (mock as UnknownMock).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`Expected ${label} to be a mock`);
}
const call = calls[index];
if (!call) {
throw new Error(`Expected ${label} call ${index + 1}`);
}
return call[argIndex] as Record<string, unknown>;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
type ImageGenerateTool = NonNullable<ReturnType<typeof createImageGenerateTool>>;
type ToolResult = Awaited<ReturnType<ImageGenerateTool["execute"]>>;
function resultDetails(result: ToolResult): Record<string, unknown> {
return requireRecord(result.details, "tool result details");
}
function resultText(result: ToolResult): string {
return (result.content?.[0] as { text: string } | undefined)?.text ?? "";
}
function ensureDefaultImageGenerationProvidersStubbed() {
if (vi.isMockFunction(imageGenerationRuntime.listRuntimeImageGenerationProviders)) {
return;
@@ -609,23 +572,26 @@ describe("createImageGenerateTool", () => {
size: "1024x1024",
});
const generateArgs = mockCallArg(generateImage, 0, "generateImage");
expect(generateArgs.cfg).toEqual({
agents: {
defaults: {
mediaMaxMb: 8,
imageGenerationModel: {
primary: "openai/gpt-image-1",
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {
agents: {
defaults: {
mediaMaxMb: 8,
imageGenerationModel: {
primary: "openai/gpt-image-1",
},
},
},
},
},
});
expect(generateArgs.prompt).toBe("A cat wearing sunglasses");
expect(generateArgs.agentDir).toBe("/tmp/agent");
expect(generateArgs.modelOverride).toBe("openai/gpt-image-1");
expect(generateArgs.size).toBe("1024x1024");
expect(generateArgs.count).toBe(2);
expect(generateArgs.inputImages).toEqual([]);
prompt: "A cat wearing sunglasses",
agentDir: "/tmp/agent",
modelOverride: "openai/gpt-image-1",
size: "1024x1024",
count: 2,
inputImages: [],
}),
);
expect(saveMediaBuffer).toHaveBeenNthCalledWith(
1,
Buffer.from("png-1"),
@@ -642,17 +608,26 @@ describe("createImageGenerateTool", () => {
8 * 1024 * 1024,
"cats/output.png",
);
const text = resultText(result);
expect(text).toContain("Generated 2 images with openai/gpt-image-1.");
const details = resultDetails(result);
const media = requireRecord(details.media, "media details");
expect(details.provider).toBe("openai");
expect(details.model).toBe("gpt-image-1");
expect(details.count).toBe(2);
expect(media.mediaUrls).toEqual(["/tmp/generated-1.png", "/tmp/generated-2.png"]);
expect(details.paths).toEqual(["/tmp/generated-1.png", "/tmp/generated-2.png"]);
expect(details.filename).toBe("cats/output.png");
expect(details.revisedPrompts).toEqual(["A more cinematic cat"]);
expect(result).toMatchObject({
content: [
{
type: "text",
text: expect.stringContaining("Generated 2 images with openai/gpt-image-1."),
},
],
details: {
provider: "openai",
model: "gpt-image-1",
count: 2,
media: {
mediaUrls: ["/tmp/generated-1.png", "/tmp/generated-2.png"],
},
paths: ["/tmp/generated-1.png", "/tmp/generated-2.png"],
filename: "cats/output.png",
revisedPrompts: ["A more cinematic cat"],
},
});
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain("MEDIA:/tmp/generated-1.png");
expect(text).toContain("MEDIA:/tmp/generated-2.png");
});
@@ -702,10 +677,20 @@ describe("createImageGenerateTool", () => {
timeoutMs: 12_345,
});
expect(mockCallArg(generateImage, 0, "generateImage").timeoutMs).toBe(180_000);
expect(mockCallArg(generateImage, 1, "generateImage").timeoutMs).toBe(12_345);
expect(resultDetails(defaultResult).timeoutMs).toBe(180_000);
expect(resultDetails(overrideResult).timeoutMs).toBe(12_345);
expect(generateImage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
timeoutMs: 180_000,
}),
);
expect(generateImage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
timeoutMs: 12_345,
}),
);
expect(defaultResult.details).toMatchObject({ timeoutMs: 180_000 });
expect(overrideResult.details).toMatchObject({ timeoutMs: 12_345 });
});
it("forwards output hints and OpenAI provider options", async () => {
@@ -742,20 +727,26 @@ describe("createImageGenerateTool", () => {
},
});
const generateArgs = mockCallArg(generateImage, 0, "generateImage");
expect(generateArgs.quality).toBe("low");
expect(generateArgs.outputFormat).toBe("jpeg");
expect(generateArgs.providerOptions).toEqual({
openai: {
background: "opaque",
moderation: "low",
outputCompression: 60,
user: "end-user-42",
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
quality: "low",
outputFormat: "jpeg",
providerOptions: {
openai: {
background: "opaque",
moderation: "low",
outputCompression: 60,
user: "end-user-42",
},
},
}),
);
expect(result).toMatchObject({
details: {
quality: "low",
outputFormat: "jpeg",
},
});
const details = resultDetails(result);
expect(details.quality).toBe("low");
expect(details.outputFormat).toBe("jpeg");
});
it("forwards transparent OpenAI background requests with a PNG output format", async () => {
@@ -788,21 +779,30 @@ describe("createImageGenerateTool", () => {
},
});
const generateArgs = mockCallArg(generateImage, 0, "generateImage");
const cfg = requireRecord(generateArgs.cfg, "generateImage config");
const agents = requireRecord(cfg.agents, "generateImage agents config");
const defaults = requireRecord(agents.defaults, "generateImage defaults config");
expect(defaults.imageGenerationModel).toEqual({ primary: "openai/gpt-image-1.5" });
expect(generateArgs.outputFormat).toBe("png");
expect(generateArgs.providerOptions).toEqual({
openai: {
background: "transparent",
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
imageGenerationModel: { primary: "openai/gpt-image-1.5" },
}),
}),
}),
outputFormat: "png",
providerOptions: {
openai: {
background: "transparent",
},
},
}),
);
expect(result).toMatchObject({
details: {
provider: "openai",
model: "gpt-image-1.5",
outputFormat: "png",
},
});
const details = resultDetails(result);
expect(details.provider).toBe("openai");
expect(details.model).toBe("gpt-image-1.5");
expect(details.outputFormat).toBe("png");
});
it("includes MEDIA paths in content text so follow-up replies use the real saved file", async () => {
@@ -866,16 +866,18 @@ describe("createImageGenerateTool", () => {
);
const result = await tool.execute("call-regression", { prompt: "kodo sawaki zazen" });
const text = resultText(result);
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain(
"MEDIA:/home/openclaw/.openclaw/media/tool-image-generation/kodo_sawaki_zazen---3337a0ed-898a-4572-8950-0d288719f4f8.jpg",
);
const details = resultDetails(result);
const media = requireRecord(details.media, "media details");
expect(media.mediaUrls).toEqual([
"/home/openclaw/.openclaw/media/tool-image-generation/kodo_sawaki_zazen---3337a0ed-898a-4572-8950-0d288719f4f8.jpg",
]);
expect(result.details).toMatchObject({
media: {
mediaUrls: [
"/home/openclaw/.openclaw/media/tool-image-generation/kodo_sawaki_zazen---3337a0ed-898a-4572-8950-0d288719f4f8.jpg",
],
},
});
});
it("rejects counts outside the supported range", async () => {
@@ -936,15 +938,18 @@ describe("createImageGenerateTool", () => {
image: "./fixtures/reference.png",
});
const generateArgs = mockCallArg(generateImage, 0, "generateImage");
expect(generateArgs.aspectRatio).toBeUndefined();
expect(generateArgs.resolution).toBe("4K");
expect(generateArgs.inputImages).toEqual([
{
buffer: Buffer.from("input-image"),
mimeType: "image/png",
},
]);
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: undefined,
resolution: "4K",
inputImages: [
expect.objectContaining({
buffer: Buffer.from("input-image"),
mimeType: "image/png",
}),
],
}),
);
});
it("accepts managed inbound reference images for edit mode", async () => {
@@ -958,12 +963,10 @@ describe("createImageGenerateTool", () => {
image: "media://inbound/reference.png",
});
const loadArgs = mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 1);
expect(mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 0)).toBe(
expect(webMedia.loadWebMedia).toHaveBeenCalledWith(
"media://inbound/reference.png",
expect.any(Object),
);
expect(loadArgs).not.toBeNull();
expect(typeof loadArgs).toBe("object");
});
it("passes web_fetch SSRF policy to remote reference images", async () => {
@@ -984,10 +987,10 @@ describe("createImageGenerateTool", () => {
prompt: "Use this reference.",
image: "http://198.18.0.153/reference.png",
});
const defaultLoadUrl = mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 0);
const defaultLoadOptions = mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 1);
expect(defaultLoadUrl).toBe("http://198.18.0.153/reference.png");
expect(requireRecord(defaultLoadOptions, "loadWebMedia options").ssrfPolicy).toBeUndefined();
expect(webMedia.loadWebMedia).toHaveBeenLastCalledWith(
"http://198.18.0.153/reference.png",
expect.not.objectContaining({ ssrfPolicy: expect.anything() }),
);
const tool = requireImageGenerateTool(
createImageGenerateTool({
@@ -1006,15 +1009,17 @@ describe("createImageGenerateTool", () => {
image: "http://198.18.0.153/reference.png",
});
const configuredLoadUrl = mockCallArg(webMedia.loadWebMedia, 1, "loadWebMedia", 0);
const configuredLoadOptions = mockCallArg(webMedia.loadWebMedia, 1, "loadWebMedia", 1);
expect(configuredLoadUrl).toBe("http://198.18.0.153/reference.png");
expect(requireRecord(configuredLoadOptions, "loadWebMedia options").ssrfPolicy).toEqual({
allowRfc2544BenchmarkRange: true,
});
expect(mockCallArg(generateImage, 1, "generateImage").ssrfPolicy).toEqual({
allowRfc2544BenchmarkRange: true,
});
expect(webMedia.loadWebMedia).toHaveBeenCalledWith(
"http://198.18.0.153/reference.png",
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
expect(generateImage).toHaveBeenLastCalledWith(
expect.objectContaining({
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
}),
);
});
it("ignores non-finite mediaMaxMb when loading reference images", async () => {
@@ -1041,10 +1046,9 @@ describe("createImageGenerateTool", () => {
image: "./fixtures/reference.png",
});
expect(typeof mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 0)).toBe("string");
expect(mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 1)).toHaveProperty(
"maxBytes",
undefined,
expect(webMedia.loadWebMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ maxBytes: undefined }),
);
});
@@ -1115,19 +1119,25 @@ describe("createImageGenerateTool", () => {
prompt: "Remove the subject but keep the rest unchanged.",
image: "./fixtures/reference.png",
});
const details = resultDetails(result);
expect(details.provider).toBe("openai");
expect(details.model).toBe("gpt-image-1");
const generateArgs = mockCallArg(generateImage, 0, "generateImage");
expect(generateArgs.modelOverride).toBeUndefined();
expect(generateArgs.resolution).toBeUndefined();
expect(generateArgs.inputImages).toEqual([
{
buffer: Buffer.from("input-image"),
mimeType: "image/jpeg",
expect(result).toMatchObject({
details: {
provider: "openai",
model: "gpt-image-1",
},
]);
});
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
modelOverride: undefined,
resolution: undefined,
inputImages: [
expect.objectContaining({
buffer: Buffer.from("input-image"),
mimeType: "image/jpeg",
}),
],
}),
);
});
it("forwards explicit aspect ratio and supports up to 5 reference images", async () => {
@@ -1143,15 +1153,16 @@ describe("createImageGenerateTool", () => {
aspectRatio: "16:9",
});
const generateArgs = mockCallArg(generateImage, 0, "generateImage");
expect(generateArgs.autoProviderFallback).toBe(false);
expect(generateArgs.aspectRatio).toBe("16:9");
const inputImages = generateArgs.inputImages as Array<{ buffer: Buffer; mimeType: string }>;
expect(inputImages).toHaveLength(5);
expect(inputImages[0]).toEqual({
buffer: Buffer.from("input-image"),
mimeType: "image/png",
});
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
autoProviderFallback: false,
aspectRatio: "16:9",
inputImages: expect.arrayContaining([
expect.objectContaining({ buffer: Buffer.from("input-image"), mimeType: "image/png" }),
]),
}),
);
expect(generateImage.mock.calls[0]?.[0].inputImages).toHaveLength(5);
});
it("reports ignored unsupported overrides instead of failing", async () => {
@@ -1209,17 +1220,18 @@ describe("createImageGenerateTool", () => {
prompt: "A lobster at the movies",
aspectRatio: "1:1",
});
const text = resultText(result);
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain("Generated 1 image with openai/gpt-image-1.");
expect(text).toContain(
"Warning: Ignored unsupported overrides for openai/gpt-image-1: aspectRatio=1:1.",
);
const details = resultDetails(result);
expect(details.warning).toBe(
"Ignored unsupported overrides for openai/gpt-image-1: aspectRatio=1:1.",
);
expect(details.ignoredOverrides).toEqual([{ key: "aspectRatio", value: "1:1" }]);
expect(result).toMatchObject({
details: {
warning: "Ignored unsupported overrides for openai/gpt-image-1: aspectRatio=1:1.",
ignoredOverrides: [{ key: "aspectRatio", value: "1:1" }],
},
});
});
it("surfaces normalized image geometry from runtime metadata", async () => {
@@ -1259,19 +1271,20 @@ describe("createImageGenerateTool", () => {
size: "1280x720",
});
const details = resultDetails(result);
expect(details.aspectRatio).toBe("16:9");
expect(details.normalization).toEqual({
aspectRatio: {
applied: "16:9",
derivedFrom: "size",
expect(result.details).toMatchObject({
aspectRatio: "16:9",
normalization: {
aspectRatio: {
applied: "16:9",
derivedFrom: "size",
},
},
metadata: {
requestedSize: "1280x720",
normalizedAspectRatio: "16:9",
},
});
expect(details.metadata).toEqual({
requestedSize: "1280x720",
normalizedAspectRatio: "16:9",
});
expect(details).not.toHaveProperty("size");
expect(result.details).not.toHaveProperty("size");
});
it("escapes image-generation summary text before appending tool MEDIA output", async () => {
@@ -1328,7 +1341,7 @@ describe("createImageGenerateTool", () => {
const result = await tool.execute("call-openai-generate", {
prompt: "A lobster at the movies",
});
const text = resultText(result);
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
const parsed = splitMediaFromOutput(text);
expect(text).toContain(
@@ -1336,12 +1349,13 @@ describe("createImageGenerateTool", () => {
);
expect(text).toContain("size=1024x1024\\nMEDIA:/etc/passwd\\t\\u2028\\u0000");
expect(parsed.mediaUrls).toEqual(["/tmp/generated.png"]);
const details = resultDetails(result);
expect(details.provider).toBe("openai\nMEDIA:/tmp/provider.png");
expect(details.model).toBe("gpt-image-1\nMEDIA:/etc/model.png");
expect(details.ignoredOverrides).toEqual([
{ key: "size", value: "1024x1024\nMEDIA:/etc/passwd\t\u2028\0" },
]);
expect(result).toMatchObject({
details: {
provider: "openai\nMEDIA:/tmp/provider.png",
model: "gpt-image-1\nMEDIA:/etc/model.png",
ignoredOverrides: [{ key: "size", value: "1024x1024\nMEDIA:/etc/passwd\t\u2028\0" }],
},
});
});
it("rejects unsupported aspect ratios", async () => {
@@ -1386,7 +1400,7 @@ describe("createImageGenerateTool", () => {
);
const result = await tool.execute("call-list", { action: "list" });
const text = resultText(result);
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain("google (default gemini-3.1-flash-image-preview)");
expect(text).toContain("gemini-3.1-flash-image-preview");
@@ -1397,27 +1411,31 @@ describe("createImageGenerateTool", () => {
);
expect(text).toContain("editing up to 5 refs");
expect(text).toContain("aspect ratios 1:1, 16:9");
const details = resultDetails(result);
const providers = details.providers as Array<Record<string, unknown>>;
const googleProvider = providers.find((provider) => provider.id === "google");
const openaiProvider = providers.find((provider) => provider.id === "openai");
if (!googleProvider || !openaiProvider) {
throw new Error("Expected google and openai provider details");
}
expect(googleProvider.defaultModel).toBe("gemini-3.1-flash-image-preview");
expect(googleProvider.authEnvVars).toEqual(["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
expect(googleProvider.models).toEqual([
"gemini-3.1-flash-image-preview",
"gemini-3-pro-image-preview",
]);
const googleCapabilities = requireRecord(googleProvider.capabilities, "google capabilities");
expect(googleCapabilities.edit).toEqual({
enabled: true,
maxInputImages: 5,
supportsAspectRatio: true,
supportsResolution: true,
expect(result).toMatchObject({
details: {
providers: expect.arrayContaining([
expect.objectContaining({
id: "google",
defaultModel: "gemini-3.1-flash-image-preview",
authEnvVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
models: expect.arrayContaining([
"gemini-3.1-flash-image-preview",
"gemini-3-pro-image-preview",
]),
capabilities: expect.objectContaining({
edit: expect.objectContaining({
enabled: true,
maxInputImages: 5,
}),
}),
}),
expect.objectContaining({
id: "openai",
authEnvVars: ["OPENAI_API_KEY"],
}),
]),
},
});
expect(openaiProvider.authEnvVars).toEqual(["OPENAI_API_KEY"]);
});
it("skips auth hints for prototype-like provider ids", async () => {
@@ -1456,15 +1474,15 @@ describe("createImageGenerateTool", () => {
);
const result = await tool.execute("call-list-proto", { action: "list" });
const text = resultText(result);
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain("__proto__ (default proto-v1)");
expect(text).not.toContain("auth: set");
const details = resultDetails(result);
const providers = details.providers as Array<Record<string, unknown>>;
expect(providers).toHaveLength(1);
expect(providers[0]?.id).toBe("__proto__");
expect(providers[0]?.authEnvVars).toEqual([]);
expect(result).toMatchObject({
details: {
providers: [expect.objectContaining({ id: "__proto__", authEnvVars: [] })],
},
});
});
it("rejects provider-specific edit limits before runtime", async () => {
@@ -1554,9 +1572,13 @@ describe("createImageGenerateTool", () => {
image: "./fixtures/a.png",
aspectRatio: "16:9",
});
const text = resultText(result);
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(mockCallArg(generateImage, 0, "generateImage").aspectRatio).toBe("16:9");
expect(generateImage).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: "16:9",
}),
);
expect(text).toContain(
"Warning: Ignored unsupported overrides for fal/fal-ai/flux/dev: aspectRatio=16:9.",
);

View File

@@ -81,32 +81,6 @@ describe("sessions_spawn tool", () => {
return property;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function expectDetailFields(details: unknown, expected: Record<string, unknown>) {
const record = requireRecord(details, "result details");
for (const [key, value] of Object.entries(expected)) {
expect(record[key]).toBe(value);
}
}
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
if (!Array.isArray(calls)) {
throw new Error(`expected ${label} mock calls`);
}
const call = calls[callIndex];
if (!call) {
throw new Error(`expected ${label} call ${callIndex + 1}`);
}
return requireRecord(call[argIndex], `${label} call ${callIndex + 1} arg ${argIndex + 1}`);
}
it("hides ACP runtime affordances when no ACP backend is loaded", () => {
const tool = createSessionsSpawnTool();
const schema = tool.parameters as {
@@ -181,7 +155,10 @@ describe("sessions_spawn tool", () => {
agentId: "codex",
});
expectDetailFields(result.details, { status: "error", role: "codex" });
expect(result.details).toMatchObject({
status: "error",
role: "codex",
});
expect(JSON.stringify(result.details)).toContain("no ACP runtime backend is loaded");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
@@ -291,23 +268,27 @@ describe("sessions_spawn tool", () => {
cleanup: "keep",
});
expectDetailFields(result.details, {
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:main:subagent:1",
runId: "run-subagent",
});
expect(result.details).not.toHaveProperty("role");
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("build feature");
expect(spawnArgs.agentId).toBe("main");
expect(spawnArgs.model).toBe("anthropic/claude-sonnet-4-6");
expect(spawnArgs.thinking).toBe("medium");
expect(spawnArgs.runTimeoutSeconds).toBe(5);
expect(spawnArgs.thread).toBe(true);
expect(spawnArgs.mode).toBe("session");
expect(spawnArgs.cleanup).toBe("keep");
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.agentSessionKey).toBe("agent:main:main");
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "build feature",
agentId: "main",
model: "anthropic/claude-sonnet-4-6",
thinking: "medium",
runTimeoutSeconds: 5,
thread: true,
mode: "session",
cleanup: "keep",
}),
expect.objectContaining({
agentSessionKey: "agent:main:main",
}),
);
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
@@ -328,13 +309,17 @@ describe("sessions_spawn tool", () => {
taskName: "review_subagents",
});
expectDetailFields(result.details, {
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:main:subagent:1",
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("review subagent handling");
expect(spawnArgs.taskName).toBe("review_subagents");
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "review subagent handling",
taskName: "review_subagents",
}),
expect.any(Object),
);
});
it("rejects invalid taskName before spawning", async () => {
@@ -347,7 +332,9 @@ describe("sessions_spawn tool", () => {
taskName: "Bad-Name",
});
expectDetailFields(result.details, { status: "error" });
expect(result.details).toMatchObject({
status: "error",
});
expect(JSON.stringify(result.details)).toContain("Invalid taskName");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
@@ -362,7 +349,9 @@ describe("sessions_spawn tool", () => {
taskName,
});
expectDetailFields(result.details, { status: "error" });
expect(result.details).toMatchObject({
status: "error",
});
expect(JSON.stringify(result.details)).toContain("Reserved subagent targets");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
@@ -381,7 +370,10 @@ describe("sessions_spawn tool", () => {
agentId: "reviewer",
});
expectDetailFields(result.details, { ...spawnResult, role: "reviewer" });
expect(result.details).toMatchObject({
...spawnResult,
role: "reviewer",
});
});
it("does not add role to forwarded failures when agentId is absent", async () => {
@@ -397,7 +389,10 @@ describe("sessions_spawn tool", () => {
task: "build feature",
});
expectDetailFields(result.details, { status: "error", error: "spawn failed" });
expect(result.details).toMatchObject({
status: "error",
error: "spawn failed",
});
expect(result.details).not.toHaveProperty("role");
});
@@ -411,9 +406,13 @@ describe("sessions_spawn tool", () => {
timeoutSeconds: 2,
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("do thing");
expect(spawnArgs.runTimeoutSeconds).toBe(2);
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "do thing",
runTimeoutSeconds: 2,
}),
expect.any(Object),
);
});
it("passes inherited workspaceDir from tool context, not from tool args", async () => {
@@ -427,8 +426,12 @@ describe("sessions_spawn tool", () => {
workspaceDir: "/tmp/attempted-override",
});
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.workspaceDir).toBe("/parent/workspace");
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
workspaceDir: "/parent/workspace",
}),
);
});
it("passes lightContext through to subagent spawns", async () => {
@@ -441,9 +444,13 @@ describe("sessions_spawn tool", () => {
lightContext: true,
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("summarize this");
expect(spawnArgs.lightContext).toBe(true);
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "summarize this",
lightContext: true,
}),
expect.any(Object),
);
});
it('rejects lightContext when runtime is not "subagent"', async () => {
@@ -485,21 +492,25 @@ describe("sessions_spawn tool", () => {
streamTo: "parent",
});
expectDetailFields(result.details, {
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:codex:acp:1",
runId: "run-acp",
});
const spawnArgs = mockCallArg(hoisted.spawnAcpDirectMock, 0, 0, "spawnAcpDirect");
expect(spawnArgs.task).toBe("investigate the failing CI run");
expect(spawnArgs.agentId).toBe("codex");
expect(spawnArgs.cwd).toBe("/workspace");
expect(spawnArgs.runTimeoutSeconds).toBe(45);
expect(spawnArgs.thread).toBe(true);
expect(spawnArgs.mode).toBe("session");
expect(spawnArgs.streamTo).toBe("parent");
const spawnContext = mockCallArg(hoisted.spawnAcpDirectMock, 0, 1, "spawnAcpDirect");
expect(spawnContext.agentSessionKey).toBe("agent:main:main");
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
runTimeoutSeconds: 45,
thread: true,
mode: "session",
streamTo: "parent",
}),
expect.objectContaining({
agentSessionKey: "agent:main:main",
}),
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
});
@@ -517,10 +528,14 @@ describe("sessions_spawn tool", () => {
model: "github-copilot/claude-sonnet-4.6",
});
const spawnArgs = mockCallArg(hoisted.spawnAcpDirectMock, 0, 0, "spawnAcpDirect");
expect(spawnArgs.task).toBe("investigate the failing CI run");
expect(spawnArgs.agentId).toBe("codex");
expect(spawnArgs.model).toBe("github-copilot/claude-sonnet-4.6");
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "investigate the failing CI run",
agentId: "codex",
model: "github-copilot/claude-sonnet-4.6",
}),
expect.any(Object),
);
});
it("adds requested role to forwarded ACP failures", async () => {
@@ -540,7 +555,7 @@ describe("sessions_spawn tool", () => {
agentId: "codex",
});
expectDetailFields(result.details, {
expect(result.details).toMatchObject({
status: "forbidden",
error: "ACP disabled",
errorCode: "acp_disabled",
@@ -561,18 +576,25 @@ describe("sessions_spawn tool", () => {
sandbox: "require",
});
const spawnArgs = mockCallArg(hoisted.spawnAcpDirectMock, 0, 0, "spawnAcpDirect");
expect(spawnArgs.task).toBe("investigate");
expect(spawnArgs.sandbox).toBe("require");
const spawnContext = mockCallArg(hoisted.spawnAcpDirectMock, 0, 1, "spawnAcpDirect");
expect(spawnContext.agentSessionKey).toBe("agent:main:subagent:parent");
const registration = mockCallArg(hoisted.registerSubagentRunMock, 0, 0, "registerSubagentRun");
expect(registration.runId).toBe("run-acp");
expect(registration.childSessionKey).toBe("agent:codex:acp:1");
expect(registration.requesterSessionKey).toBe("agent:main:subagent:parent");
expect(registration.task).toBe("investigate");
expect(registration.cleanup).toBe("keep");
expect(registration.spawnMode).toBe("run");
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "investigate",
sandbox: "require",
}),
expect.objectContaining({
agentSessionKey: "agent:main:subagent:parent",
}),
);
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
expect.objectContaining({
runId: "run-acp",
childSessionKey: "agent:codex:acp:1",
requesterSessionKey: "agent:main:subagent:parent",
task: "investigate",
cleanup: "keep",
spawnMode: "run",
}),
);
});
it("suppresses completion announces for inline ACP session delivery", async () => {
@@ -600,14 +622,17 @@ describe("sessions_spawn tool", () => {
mode: "session",
});
const registration = mockCallArg(hoisted.registerSubagentRunMock, 0, 0, "registerSubagentRun");
expect(registration.runId).toBe("run-acp");
expect(registration.childSessionKey).toBe("agent:codex:acp:1");
expect(registration.requesterSessionKey).toBe("agent:main:main");
expect(registration.task).toBe("investigate");
expect(registration.cleanup).toBe("keep");
expect(registration.spawnMode).toBe("session");
expect(registration.expectsCompletionMessage).toBe(false);
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
expect.objectContaining({
runId: "run-acp",
childSessionKey: "agent:codex:acp:1",
requesterSessionKey: "agent:main:main",
task: "investigate",
cleanup: "keep",
spawnMode: "session",
expectsCompletionMessage: false,
}),
);
});
it("rejects ACP runtime calls from sandboxed requester sessions", async () => {
@@ -623,7 +648,10 @@ describe("sessions_spawn tool", () => {
agentId: "codex",
});
expectDetailFields(result.details, { status: "error", role: "codex" });
expect(result.details).toMatchObject({
status: "error",
role: "codex",
});
expect(JSON.stringify(result.details)).toContain("sandboxed sessions");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
@@ -641,10 +669,14 @@ describe("sessions_spawn tool", () => {
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
});
const spawnArgs = mockCallArg(hoisted.spawnAcpDirectMock, 0, 0, "spawnAcpDirect");
expect(spawnArgs.task).toBe("resume prior work");
expect(spawnArgs.agentId).toBe("codex");
expect(spawnArgs.resumeSessionId).toBe("7f4a78e0-f6be-43fe-855c-c1c4fd229bc4");
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "resume prior work",
agentId: "codex",
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
}),
expect.any(Object),
);
});
it("ignores ACP-only fields for subagent spawns", async () => {
@@ -659,15 +691,19 @@ describe("sessions_spawn tool", () => {
streamTo: "parent",
});
expectDetailFields(result.details, {
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:main:subagent:1",
runId: "run-subagent",
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("resume prior work");
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.agentSessionKey).toBe("agent:main:main");
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "resume prior work",
}),
expect.objectContaining({
agentSessionKey: "agent:main:main",
}),
);
expect(hoisted.spawnSubagentDirectMock.mock.calls[0]?.[0]).not.toHaveProperty(
"resumeSessionId",
);
@@ -691,7 +727,9 @@ describe("sessions_spawn tool", () => {
attachments: [{ name: "a.txt", content: "hello", encoding: "utf8" }],
});
expectDetailFields(result.details, { status: "error" });
expect(result.details).toMatchObject({
status: "error",
});
const details = result.details as { error?: string };
expect(details.error).toContain("attachments are currently unsupported for runtime=acp");
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
@@ -709,14 +747,18 @@ describe("sessions_spawn tool", () => {
streamTo: "parent",
});
expectDetailFields(result.details, {
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:main:subagent:1",
runId: "run-subagent",
});
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("analyze file");
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "analyze file",
}),
expect.any(Object),
);
expect(hoisted.spawnSubagentDirectMock.mock.calls[0]?.[0]).not.toHaveProperty(
"resumeSessionId",
);
@@ -733,9 +775,13 @@ describe("sessions_spawn tool", () => {
model: "default",
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("analyze file");
expect(spawnArgs.model).toBeUndefined();
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "analyze file",
model: undefined,
}),
expect.any(Object),
);
});
it("keeps attachment content schema unconstrained for llama.cpp grammar safety", () => {

View File

@@ -3652,45 +3652,8 @@ describe("runAgentTurnWithFallback", () => {
providerOverride: "anthropic",
modelOverride: "claude-sonnet",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude-opus",
authProfileOverride: "anthropic:openclaw",
authProfileOverrideSource: "user",
});
});
it("preserves original auto-fallback origin across chained fallbacks", async () => {
const applyFallbackCandidateSelectionToEntry =
await getApplyFallbackCandidateSelectionToEntry();
const entry = {
sessionId: "session",
updatedAt: 1,
providerOverride: "openrouter",
modelOverride: "fallback-b",
modelOverrideSource: "auto" as const,
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude-opus",
} as SessionEntry;
const { updated } = applyFallbackCandidateSelectionToEntry({
entry,
run: {
provider: "openrouter",
model: "fallback-b",
} as FollowupRun["run"],
provider: "openrouter",
model: "fallback-c",
now: 123,
});
expect(updated).toBe(true);
expect(entry).toMatchObject({
updatedAt: 123,
providerOverride: "openrouter",
modelOverride: "fallback-c",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude-opus",
});
});
});

View File

@@ -130,8 +130,6 @@ type FallbackSelectionState = Pick<
| "providerOverride"
| "modelOverride"
| "modelOverrideSource"
| "modelOverrideFallbackOriginProvider"
| "modelOverrideFallbackOriginModel"
| "authProfileOverride"
| "authProfileOverrideSource"
| "authProfileOverrideCompactionCount"
@@ -141,8 +139,6 @@ const FALLBACK_SELECTION_STATE_KEYS = [
"providerOverride",
"modelOverride",
"modelOverrideSource",
"modelOverrideFallbackOriginProvider",
"modelOverrideFallbackOriginModel",
"authProfileOverride",
"authProfileOverrideSource",
"authProfileOverrideCompactionCount",
@@ -172,20 +168,6 @@ function setFallbackSelectionStateField(
return true;
}
return false;
case "modelOverrideFallbackOriginProvider":
if (entry.modelOverrideFallbackOriginProvider !== value) {
entry.modelOverrideFallbackOriginProvider =
value as SessionEntry["modelOverrideFallbackOriginProvider"];
return true;
}
return false;
case "modelOverrideFallbackOriginModel":
if (entry.modelOverrideFallbackOriginModel !== value) {
entry.modelOverrideFallbackOriginModel =
value as SessionEntry["modelOverrideFallbackOriginModel"];
return true;
}
return false;
case "authProfileOverride":
if (entry.authProfileOverride !== value) {
entry.authProfileOverride = value as SessionEntry["authProfileOverride"];
@@ -214,8 +196,6 @@ function snapshotFallbackSelectionState(entry: SessionEntry): FallbackSelectionS
providerOverride: entry.providerOverride,
modelOverride: entry.modelOverride,
modelOverrideSource: entry.modelOverrideSource,
modelOverrideFallbackOriginProvider: entry.modelOverrideFallbackOriginProvider,
modelOverrideFallbackOriginModel: entry.modelOverrideFallbackOriginModel,
authProfileOverride: entry.authProfileOverride,
authProfileOverrideSource: entry.authProfileOverrideSource,
authProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount,
@@ -225,8 +205,6 @@ function snapshotFallbackSelectionState(entry: SessionEntry): FallbackSelectionS
function buildFallbackSelectionState(params: {
provider: string;
model: string;
originProvider: string;
originModel: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
}): FallbackSelectionState {
@@ -234,32 +212,12 @@ function buildFallbackSelectionState(params: {
providerOverride: params.provider,
modelOverride: params.model,
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: params.originProvider,
modelOverrideFallbackOriginModel: params.originModel,
authProfileOverride: params.authProfileId,
authProfileOverrideSource: params.authProfileId ? params.authProfileIdSource : undefined,
authProfileOverrideCompactionCount: undefined,
};
}
function resolveFallbackSelectionOrigin(params: { entry: SessionEntry; run: FollowupRun["run"] }): {
provider: string;
model: string;
} {
if (params.entry.modelOverrideSource === "auto") {
const persistedOriginProvider = normalizeOptionalString(
params.entry.modelOverrideFallbackOriginProvider,
);
const persistedOriginModel = normalizeOptionalString(
params.entry.modelOverrideFallbackOriginModel,
);
if (persistedOriginProvider && persistedOriginModel) {
return { provider: persistedOriginProvider, model: persistedOriginModel };
}
}
return { provider: params.run.provider, model: params.run.model };
}
export function applyFallbackCandidateSelectionToEntry(params: {
entry: SessionEntry;
run: FollowupRun["run"];
@@ -271,12 +229,9 @@ export function applyFallbackCandidateSelectionToEntry(params: {
return { updated: false };
}
const scopedAuthProfile = resolveRunAuthProfile(params.run, params.provider);
const origin = resolveFallbackSelectionOrigin({ entry: params.entry, run: params.run });
const nextState = buildFallbackSelectionState({
provider: params.provider,
model: params.model,
originProvider: origin.provider,
originModel: origin.model,
authProfileId: scopedAuthProfile.authProfileId,
authProfileIdSource: scopedAuthProfile.authProfileIdSource,
});

View File

@@ -166,8 +166,6 @@ export async function resolveReplyDirectives(params: {
commandAuthorized: boolean;
defaultProvider: string;
defaultModel: string;
primaryProvider?: string;
primaryModel?: string;
aliasIndex: ModelAliasIndex;
provider: string;
model: string;
@@ -196,8 +194,6 @@ export async function resolveReplyDirectives(params: {
commandAuthorized,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
provider: initialProvider,
model: initialModel,
hasResolvedHeartbeatModelOverride,
@@ -530,13 +526,10 @@ export async function resolveReplyDirectives(params: {
storePath,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
provider,
model,
hasModelDirective: directives.hasModelDirective,
hasResolvedHeartbeatModelOverride,
isHeartbeat: opts?.isHeartbeat === true,
});
provider = modelState.provider;
model = modelState.model;

View File

@@ -43,10 +43,7 @@ import { hasInboundMedia } from "./inbound-media.js";
import { emitPreAgentMessageHooks } from "./message-preprocess-hooks.js";
import { createFastTestModelSelectionState } from "./model-selection.js";
import { initSessionState } from "./session.js";
import {
isStaleHeartbeatAutoFallbackOverride,
resolveStoredModelOverride,
} from "./stored-model-override.js";
import { resolveStoredModelOverride } from "./stored-model-override.js";
import { createTypingController } from "./typing.js";
type ResetCommandAction = "new" | "reset";
@@ -435,16 +432,6 @@ export async function getReplyFromConfig(
parentSessionKey: sessionCtx.ModelParentSessionKey ?? sessionCtx.ParentSessionKey,
})
: null;
const resolvedChannelModelOverride =
channelModelOverride && !hasResolvedHeartbeatModelOverride
? resolveModelRefFromString({
raw: channelModelOverride.model,
defaultProvider,
aliasIndex,
})
: null;
const primaryProvider = resolvedChannelModelOverride?.ref.provider ?? defaultProvider;
const primaryModel = resolvedChannelModelOverride?.ref.model ?? defaultModel;
const hasSessionModelOverride = Boolean(
normalizeOptionalString(sessionEntry.modelOverride) ||
normalizeOptionalString(sessionEntry.providerOverride),
@@ -459,33 +446,20 @@ export async function getReplyFromConfig(
sessionCtx.ParentSessionKey,
defaultProvider,
});
const staleHeartbeatAutoFallbackOverride = isStaleHeartbeatAutoFallbackOverride({
isHeartbeat: opts?.isHeartbeat === true,
hasResolvedHeartbeatModelOverride,
sessionEntry,
storedOverride: storedModelOverride,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
});
if (
storedModelOverride?.model &&
!hasResolvedHeartbeatModelOverride &&
!staleHeartbeatAutoFallbackOverride
) {
if (storedModelOverride?.model && !hasResolvedHeartbeatModelOverride) {
provider = storedModelOverride.provider ?? defaultProvider;
model = storedModelOverride.model;
}
const hasEffectiveSessionModelOverride =
hasSessionModelOverride && !staleHeartbeatAutoFallbackOverride;
if (
!hasResolvedHeartbeatModelOverride &&
!hasEffectiveSessionModelOverride &&
resolvedChannelModelOverride
) {
provider = resolvedChannelModelOverride.ref.provider;
model = resolvedChannelModelOverride.ref.model;
if (!hasResolvedHeartbeatModelOverride && !hasSessionModelOverride && channelModelOverride) {
const resolved = resolveModelRefFromString({
raw: channelModelOverride.model,
defaultProvider,
aliasIndex,
});
if (resolved) {
provider = resolved.ref.provider;
model = resolved.ref.model;
}
}
if (
@@ -586,8 +560,6 @@ export async function getReplyFromConfig(
commandAuthorized,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
aliasIndex,
provider,
model,

View File

@@ -852,27 +852,12 @@ describe("createModelSelectionState auto-failover overrides", () => {
providerOverride: string;
modelOverride: string;
modelOverrideSource: "auto" | "user" | undefined;
modelOverrideFallbackOriginProvider?: string;
modelOverrideFallbackOriginModel?: string;
fallbackNoticeSelectedModel?: string;
authProfileOverride?: string;
authProfileOverrideSource?: "auto" | "user";
provider?: string;
model?: string;
primaryProvider?: string;
primaryModel?: string;
isHeartbeat?: boolean;
}) {
const cfg = {} as OpenClawConfig;
const sessionEntry = makeEntry({
providerOverride: params.providerOverride,
modelOverride: params.modelOverride,
modelOverrideSource: params.modelOverrideSource,
modelOverrideFallbackOriginProvider: params.modelOverrideFallbackOriginProvider,
modelOverrideFallbackOriginModel: params.modelOverrideFallbackOriginModel,
fallbackNoticeSelectedModel: params.fallbackNoticeSelectedModel,
authProfileOverride: params.authProfileOverride,
authProfileOverrideSource: params.authProfileOverrideSource,
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
@@ -883,12 +868,9 @@ describe("createModelSelectionState auto-failover overrides", () => {
sessionKey,
defaultProvider,
defaultModel,
primaryProvider: params.primaryProvider,
primaryModel: params.primaryModel,
provider: params.provider ?? defaultProvider,
model: params.model ?? defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
isHeartbeat: params.isHeartbeat,
});
return { state, sessionEntry, sessionStore };
}
@@ -972,137 +954,6 @@ describe("createModelSelectionState auto-failover overrides", () => {
expect(state.resetModelOverride).toBe(false);
});
it("clears stale heartbeat auto-failover override when the fallback origin changed", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "openai-codex",
modelOverrideFallbackOriginModel: "gpt-5.3",
provider: "openrouter",
model: "minimax/minimax-m2.7",
isHeartbeat: true,
});
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
expect(state.resetModelOverride).toBe(true);
expect(state.resetModelOverrideRef).toBe("openrouter/minimax/minimax-m2.7");
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverrideFallbackOriginProvider).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverrideFallbackOriginModel).toBeUndefined();
});
it("preserves user auth profile when clearing a stale heartbeat auto-failover override", async () => {
authProfileStoreMock.store = {
version: 1,
profiles: {
"mac-studio:local": {
type: "api_key",
provider: defaultProvider,
key: "test-key",
},
},
};
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "openai-codex",
modelOverrideFallbackOriginModel: "gpt-5.3",
authProfileOverride: "mac-studio:local",
authProfileOverrideSource: "user",
provider: "openrouter",
model: "minimax/minimax-m2.7",
isHeartbeat: true,
});
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
expect(state.resetModelOverride).toBe(true);
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.authProfileOverride).toBe("mac-studio:local");
expect(sessionStore[sessionKey]?.authProfileOverrideSource).toBe("user");
});
it("keeps heartbeat auto-failover override when the fallback origin still matches default", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: defaultProvider,
modelOverrideFallbackOriginModel: defaultModel,
provider: "openrouter",
model: "minimax/minimax-m2.7",
isHeartbeat: true,
});
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter");
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
});
it("keeps heartbeat auto-failover override when the origin matches the channel primary", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "openai",
modelOverrideFallbackOriginModel: "gpt-4o",
primaryProvider: "openai",
primaryModel: "gpt-4o",
provider: "openrouter",
model: "minimax/minimax-m2.7",
isHeartbeat: true,
});
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter");
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
});
it("clears legacy heartbeat auto-failover override when no origin metadata exists", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
provider: "openrouter",
model: "minimax/minimax-m2.7",
isHeartbeat: true,
});
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
expect(state.resetModelOverride).toBe(true);
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined();
});
it("uses fallback notice metadata for legacy heartbeat auto-failover overrides", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
fallbackNoticeSelectedModel: `${defaultProvider}/${defaultModel}`,
provider: "openrouter",
model: "minimax/minimax-m2.7",
isHeartbeat: true,
});
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
expect(sessionStore[sessionKey]?.modelOverrideSource).toBe("auto");
});
it("preserves a user-selected override across turns", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",

View File

@@ -28,10 +28,7 @@ export {
resolveModelDirectiveSelection,
type ModelDirectiveSelection,
} from "./model-selection-directive.js";
import {
isStaleHeartbeatAutoFallbackOverride,
resolveStoredModelOverride,
} from "./stored-model-override.js";
import { resolveStoredModelOverride } from "./stored-model-override.js";
type ModelCatalog = ModelCatalogEntry[];
@@ -98,15 +95,12 @@ export async function createModelSelectionState(params: {
storePath?: string;
defaultProvider: string;
defaultModel: string;
primaryProvider?: string;
primaryModel?: string;
provider: string;
model: string;
hasModelDirective: boolean;
/** True when heartbeat.model was explicitly resolved for this run.
* In that case, skip session-stored overrides so the heartbeat selection wins. */
hasResolvedHeartbeatModelOverride?: boolean;
isHeartbeat?: boolean;
}): Promise<ModelSelectionState> {
const timingEnabled = shouldLogModelSelectionTiming();
const startMs = timingEnabled ? Date.now() : 0;
@@ -133,8 +127,6 @@ export async function createModelSelectionState(params: {
let provider = params.provider;
let model = params.model;
const primaryProvider = params.primaryProvider ?? defaultProvider;
const primaryModel = params.primaryModel ?? defaultModel;
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const visibility = parseConfiguredModelVisibilityEntries({ cfg });
@@ -166,19 +158,6 @@ export async function createModelSelectionState(params: {
overrideProvider: sessionEntry?.providerOverride,
overrideModel: sessionEntry?.modelOverride,
});
const directStoredModelOverride = directStoredOverride
? { ...directStoredOverride, source: "session" as const }
: null;
const staleHeartbeatAutoFallbackOverride = isStaleHeartbeatAutoFallbackOverride({
isHeartbeat: params.isHeartbeat,
hasResolvedHeartbeatModelOverride: params.hasResolvedHeartbeatModelOverride,
sessionEntry,
storedOverride: directStoredModelOverride,
defaultProvider,
defaultModel,
primaryProvider: params.primaryProvider,
primaryModel: params.primaryModel,
});
if (needsModelCatalog) {
modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg });
@@ -220,11 +199,10 @@ export async function createModelSelectionState(params: {
directStoredOverride.model,
);
const key = modelKey(normalizedOverride.provider, normalizedOverride.model);
if (staleHeartbeatAutoFallbackOverride || !visibilityPolicy.allowsKey(key)) {
if (!visibilityPolicy.allowsKey(key)) {
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: { provider: primaryProvider, model: primaryModel, isDefault: true },
preserveAuthProfileOverride: staleHeartbeatAutoFallbackOverride,
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
});
if (updated) {
sessionStore[sessionKey] = sessionEntry;
@@ -242,23 +220,6 @@ export async function createModelSelectionState(params: {
}
}
}
if (staleHeartbeatAutoFallbackOverride) {
const normalizedCurrentSelection = normalizeModelRef(provider, model);
const currentSelectionKey = modelKey(
normalizedCurrentSelection.provider,
normalizedCurrentSelection.model,
);
const normalizedDirectOverride = directStoredOverride
? normalizeModelRef(directStoredOverride.provider, directStoredOverride.model)
: null;
const directStoredOverrideKey = normalizedDirectOverride
? modelKey(normalizedDirectOverride.provider, normalizedDirectOverride.model)
: undefined;
if (currentSelectionKey === directStoredOverrideKey) {
provider = primaryProvider;
model = primaryModel;
}
}
const storedOverride = resolveStoredModelOverride({
sessionEntry,
@@ -268,12 +229,9 @@ export async function createModelSelectionState(params: {
defaultProvider,
});
// Skip stored session model override only when an explicit heartbeat.model
// was resolved. Heartbeats without heartbeat.model still inherit normal
// overrides unless a direct auto fallback override is stale for the current
// configured default.
const skipStoredOverride =
params.hasResolvedHeartbeatModelOverride === true ||
(staleHeartbeatAutoFallbackOverride && storedOverride?.source === "session");
// was resolved. Heartbeat runs without heartbeat.model should still inherit
// the regular session/parent model override behavior.
const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true;
if (storedOverride?.model && !skipStoredOverride) {
const normalizedStoredOverride = normalizeModelRef(

View File

@@ -1,8 +1,4 @@
import {
modelKey,
normalizeModelRef,
resolvePersistedOverrideModelRef,
} from "../../agents/model-selection.js";
import { resolvePersistedOverrideModelRef } from "../../agents/model-selection.js";
import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
@@ -61,70 +57,3 @@ export function resolveStoredModelOverride(params: {
}
return { ...parentOverride, source: "parent" };
}
function resolveModelRefKey(params: {
defaultProvider: string;
overrideProvider?: string;
overrideModel?: string;
}): string | null {
const ref = resolvePersistedOverrideModelRef(params);
if (!ref) {
return null;
}
const normalized = normalizeModelRef(ref.provider, ref.model);
return modelKey(normalized.provider, normalized.model);
}
export function isStaleHeartbeatAutoFallbackOverride(params: {
isHeartbeat?: boolean;
hasResolvedHeartbeatModelOverride?: boolean;
sessionEntry?: SessionEntry;
storedOverride?: StoredModelOverride | null;
defaultProvider: string;
defaultModel: string;
primaryProvider?: string;
primaryModel?: string;
}): boolean {
if (params.isHeartbeat !== true || params.hasResolvedHeartbeatModelOverride === true) {
return false;
}
if (params.storedOverride?.source !== "session") {
return false;
}
if (params.sessionEntry?.modelOverrideSource !== "auto") {
return false;
}
const primaryKey = resolveModelRefKey({
defaultProvider: params.defaultProvider,
overrideProvider: params.primaryProvider ?? params.defaultProvider,
overrideModel: params.primaryModel ?? params.defaultModel,
});
if (!primaryKey) {
return false;
}
const originKey = resolveModelRefKey({
defaultProvider: params.defaultProvider,
overrideProvider: params.sessionEntry.modelOverrideFallbackOriginProvider,
overrideModel: params.sessionEntry.modelOverrideFallbackOriginModel,
});
if (originKey) {
return originKey !== primaryKey;
}
const noticeSelectedKey = resolveModelRefKey({
defaultProvider: params.defaultProvider,
overrideModel: normalizeOptionalString(params.sessionEntry.fallbackNoticeSelectedModel),
});
if (noticeSelectedKey) {
return noticeSelectedKey !== primaryKey;
}
const storedOverrideKey = resolveModelRefKey({
defaultProvider: params.defaultProvider,
overrideProvider: params.storedOverride.provider,
overrideModel: params.storedOverride.model,
});
return storedOverrideKey !== null && storedOverrideKey !== primaryKey;
}

View File

@@ -63,70 +63,6 @@ const localSnapshot = {
file: { version: 1, agents: {} } as ExecApprovalsFile,
};
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value;
}
function expectFields(
value: unknown,
label: string,
fields: Record<string, unknown>,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expected] of Object.entries(fields)) {
expect(record[key]).toEqual(expected);
}
return record;
}
function gatewayCall(index: number) {
const call = callGatewayFromCli.mock.calls[index];
if (!call) {
throw new Error(`Expected gateway call ${index + 1}`);
}
return call;
}
function expectGatewayCall(index: number, method: string, params: unknown) {
const call = gatewayCall(index);
expect(call[0]).toBe(method);
expect(requireRecord(call[1], "gateway call options").timeout).toBe("60000");
expect(call[2]).toEqual(params);
}
function writtenJson(): Record<string, unknown> {
const value = vi.mocked(defaultRuntime.writeJson).mock.calls[0]?.[0];
return requireRecord(value, "written json");
}
function effectivePolicy(output: Record<string, unknown> = writtenJson()) {
return requireRecord(output.effectivePolicy, "effective policy");
}
function scopes(output: Record<string, unknown> = writtenJson()) {
return requireArray(effectivePolicy(output).scopes, "effective policy scopes");
}
function scopeByLabel(label: string, output: Record<string, unknown> = writtenJson()) {
const scope = scopes(output).find(
(entry) => requireRecord(entry, "policy scope").scopeLabel === label,
);
if (!scope) {
throw new Error(`Expected policy scope ${label}`);
}
return requireRecord(scope, `policy scope ${label}`);
}
function resetLocalSnapshot() {
localSnapshot.file = { version: 1, agents: {} };
}
@@ -202,15 +138,35 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--gateway"]);
expectGatewayCall(0, "exec.approvals.get", {});
expectGatewayCall(1, "config.get", {});
expect(callGatewayFromCli).toHaveBeenNthCalledWith(
1,
"exec.approvals.get",
expect.objectContaining({ timeout: "60000" }),
{},
);
expect(callGatewayFromCli).toHaveBeenNthCalledWith(
2,
"config.get",
expect.objectContaining({ timeout: "60000" }),
{},
);
expect(runtimeErrors).toHaveLength(0);
callGatewayFromCli.mockClear();
await runApprovalsCommand(["approvals", "get", "--node", "macbook"]);
expectGatewayCall(0, "exec.approvals.node.get", { nodeId: "node-1" });
expectGatewayCall(1, "config.get", {});
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.node.get",
expect.objectContaining({ timeout: "60000" }),
{
nodeId: "node-1",
},
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"config.get",
expect.objectContaining({ timeout: "60000" }),
{},
);
expect(runtimeErrors).toHaveLength(0);
});
@@ -231,22 +187,29 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
const policy = effectivePolicy();
expect(policy.note).toBe(
"Effective exec policy is the host approvals file intersected with requested tools.exec policy.",
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Effective exec policy is the host approvals file intersected with requested tools.exec policy.",
scopes: [
expect.objectContaining({
scopeLabel: "tools.exec",
security: expect.objectContaining({
requested: "full",
host: "allowlist",
effective: "allowlist",
}),
ask: expect.objectContaining({
requested: "off",
host: "always",
effective: "always",
}),
}),
],
},
}),
0,
);
const scope = scopeByLabel("tools.exec");
expectFields(requireRecord(scope.security, "tools.exec security"), "tools.exec security", {
requested: "full",
host: "allowlist",
effective: "allowlist",
});
expectFields(requireRecord(scope.ask, "tools.exec ask"), "tools.exec ask", {
requested: "off",
host: "always",
effective: "always",
});
});
it("reports wildcard host policy sources in effective policy output", async () => {
@@ -279,16 +242,26 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
const scope = scopeByLabel("agent:runner");
expect(requireRecord(scope.security, "agent security").hostSource).toBe(
"/tmp/local-exec-approvals.json agents.*.security",
);
expect(requireRecord(scope.ask, "agent ask").hostSource).toBe(
"/tmp/local-exec-approvals.json agents.*.ask",
);
expect(requireRecord(scope.askFallback, "agent askFallback").source).toBe(
"/tmp/local-exec-approvals.json agents.*.askFallback",
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: expect.objectContaining({
scopes: expect.arrayContaining([
expect.objectContaining({
scopeLabel: "agent:runner",
security: expect.objectContaining({
hostSource: "/tmp/local-exec-approvals.json agents.*.security",
}),
ask: expect.objectContaining({
hostSource: "/tmp/local-exec-approvals.json agents.*.ask",
}),
askFallback: expect.objectContaining({
source: "/tmp/local-exec-approvals.json agents.*.askFallback",
}),
}),
]),
}),
}),
0,
);
});
@@ -325,29 +298,32 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--node", "macbook", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
const policy = effectivePolicy();
expect(policy.note).toBe(
"Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.",
);
const scope = scopeByLabel("tools.exec");
expectFields(requireRecord(scope.security, "tools.exec security"), "tools.exec security", {
requested: "full",
host: "allowlist",
effective: "allowlist",
});
expectFields(requireRecord(scope.ask, "tools.exec ask"), "tools.exec ask", {
requested: "off",
host: "always",
effective: "always",
});
expectFields(
requireRecord(scope.askFallback, "tools.exec askFallback"),
"tools.exec askFallback",
{
effective: "deny",
source: "/tmp/node-exec-approvals.json defaults.askFallback",
},
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.",
scopes: [
expect.objectContaining({
scopeLabel: "tools.exec",
security: expect.objectContaining({
requested: "full",
host: "allowlist",
effective: "allowlist",
}),
ask: expect.objectContaining({
requested: "off",
host: "always",
effective: "always",
}),
askFallback: expect.objectContaining({
effective: "deny",
source: "/tmp/node-exec-approvals.json defaults.askFallback",
}),
}),
],
},
}),
0,
);
});
@@ -371,11 +347,15 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--gateway", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
expect(effectivePolicy()).toEqual({
note: "Config unavailable.",
scopes: [],
});
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Config unavailable.",
scopes: [],
},
}),
0,
);
expect(runtimeErrors).toHaveLength(0);
});
@@ -399,11 +379,15 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--gateway", "--timeout", "10000", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
expect(effectivePolicy()).toEqual({
note: "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy.",
scopes: [],
});
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy.",
scopes: [],
},
}),
0,
);
expect(runtimeErrors).toHaveLength(0);
});
@@ -427,11 +411,15 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--node", "macbook", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
expect(effectivePolicy()).toEqual({
note: "Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
scopes: [],
});
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
scopes: [],
},
}),
0,
);
expect(runtimeErrors).toHaveLength(0);
});
@@ -440,11 +428,15 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
expect(effectivePolicy()).toEqual({
note: "Config unavailable.",
scopes: [],
});
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: {
note: "Config unavailable.",
scopes: [],
},
}),
0,
);
expect(runtimeErrors).toHaveLength(0);
});
@@ -473,43 +465,50 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(expect.anything(), 0);
const toolsScope = scopeByLabel("tools.exec");
expectFields(requireRecord(toolsScope.security, "tools.exec security"), "tools.exec security", {
requested: "full",
requestedSource: "tools.exec.security",
effective: "full",
});
expectFields(requireRecord(toolsScope.ask, "tools.exec ask"), "tools.exec ask", {
requested: "off",
requestedSource: "tools.exec.ask",
effective: "off",
});
expectFields(
requireRecord(toolsScope.askFallback, "tools.exec askFallback"),
"tools.exec askFallback",
{
effective: "full",
source: "OpenClaw default (full)",
},
const output = vi.mocked(defaultRuntime.writeJson).mock.calls[0]?.[0] as {
effectivePolicy: { scopes: unknown[] };
};
expect(output.effectivePolicy.scopes).toEqual(
expect.arrayContaining([
expect.objectContaining({
scopeLabel: "tools.exec",
security: expect.objectContaining({
requested: "full",
requestedSource: "tools.exec.security",
effective: "full",
}),
ask: expect.objectContaining({
requested: "off",
requestedSource: "tools.exec.ask",
effective: "off",
}),
askFallback: expect.objectContaining({
effective: "full",
source: "OpenClaw default (full)",
}),
}),
expect.objectContaining({
scopeLabel: "agent:runner",
security: expect.objectContaining({
requested: "full",
requestedSource: "tools.exec.security",
effective: "allowlist",
}),
ask: expect.objectContaining({
requested: "off",
requestedSource: "tools.exec.ask",
effective: "always",
}),
askFallback: expect.objectContaining({
effective: "allowlist",
source: "OpenClaw default (full)",
}),
}),
]),
);
const agentScope = scopeByLabel("agent:runner");
expectFields(requireRecord(agentScope.security, "agent security"), "agent security", {
requested: "full",
requestedSource: "tools.exec.security",
effective: "allowlist",
});
expectFields(requireRecord(agentScope.ask, "agent ask"), "agent ask", {
requested: "off",
requestedSource: "tools.exec.ask",
effective: "always",
});
expectFields(requireRecord(agentScope.askFallback, "agent askFallback"), "agent askFallback", {
effective: "allowlist",
source: "OpenClaw default (full)",
});
});
it("defaults allowlist add to wildcard agent", async () => {
@@ -518,14 +517,18 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname"]);
expect(callGatewayFromCli.mock.calls.some((call) => call[0] === "exec.approvals.set")).toBe(
false,
expect(callGatewayFromCli).not.toHaveBeenCalledWith(
"exec.approvals.set",
expect.anything(),
{},
);
expect(saveExecApprovals).toHaveBeenCalledWith(
requireRecord(saveExecApprovals.mock.calls[0]?.[0], "saved approvals"),
expect.objectContaining({
agents: expect.objectContaining({
"*": expect.anything(),
}),
}),
);
const saved = requireRecord(saveExecApprovals.mock.calls[0]?.[0], "saved approvals");
expect(requireRecord(saved.agents, "saved agents")["*"]).toBeDefined();
});
it("removes wildcard allowlist entry and prunes empty agent", async () => {
@@ -544,12 +547,11 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "allowlist", "remove", "/usr/bin/uname"]);
expect(saveExecApprovals).toHaveBeenCalledWith(
requireRecord(saveExecApprovals.mock.calls[0]?.[0], "saved approvals"),
expect.objectContaining({
version: 1,
agents: undefined,
}),
);
expectFields(saveExecApprovals.mock.calls[0]?.[0], "saved approvals", {
version: 1,
agents: undefined,
});
expect(runtimeErrors).toHaveLength(0);
});
});

View File

@@ -19,8 +19,7 @@ const forceFreePortAndWait = vi.fn(async (_port: number, _opts: unknown) => ({
}));
const waitForPortBindable = vi.fn(async (_port: number, _opts?: unknown) => 0);
const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
type GatewayLoopStart = (params?: { startupStartedAt?: number }) => Promise<unknown>;
const runGatewayLoop = vi.fn(async ({ start }: { start: GatewayLoopStart }) => {
const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => {
await start();
});
const gatewayLogMessages = vi.hoisted(() => [] as string[]);
@@ -158,7 +157,7 @@ vi.mock("./dev.js", () => ({
}));
vi.mock("./run-loop.js", () => ({
runGatewayLoop: (params: { start: GatewayLoopStart }) => runGatewayLoop(params),
runGatewayLoop: (params: { start: () => Promise<unknown> }) => runGatewayLoop(params),
}));
describe("gateway run option collisions", () => {
@@ -306,41 +305,6 @@ describe("gateway run option collisions", () => {
);
});
it("uses the startup snapshot only for the first in-process gateway start", async () => {
runGatewayLoop.mockImplementationOnce(async ({ start }: { start: GatewayLoopStart }) => {
await start({ startupStartedAt: 1000 });
await start({ startupStartedAt: 2000 });
});
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(startGatewayServer).toHaveBeenCalledTimes(2);
expect(startGatewayServer).toHaveBeenNthCalledWith(
1,
18789,
expect.objectContaining({
startupStartedAt: 1000,
startupConfigSnapshotRead: {
snapshot: configState.snapshot,
},
}),
);
expect(startGatewayServer).toHaveBeenNthCalledWith(
2,
18789,
expect.not.objectContaining({
startupConfigSnapshotRead: expect.anything(),
}),
);
expect(startGatewayServer).toHaveBeenNthCalledWith(
2,
18789,
expect.objectContaining({
startupStartedAt: 2000,
}),
);
});
it("logs when first startup will build missing Control UI assets", async () => {
controlUiState.root = null;

View File

@@ -787,25 +787,19 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
gatewayLog.info("starting...");
startupTrace.mark("cli.gateway-loop");
const healthHost = await resolveGatewayBindHost(bind, cfg.gateway?.customBindHost);
let startupConfigSnapshotReadForNextStart = startupConfigSnapshotRead;
const startLoop = async () =>
await runGatewayLoop({
runtime: defaultRuntime,
lockPort: port,
healthHost,
start: async ({ startupStartedAt } = {}) => {
const startupConfigSnapshotReadForThisStart = startupConfigSnapshotReadForNextStart;
startupConfigSnapshotReadForNextStart = undefined;
return await startGatewayServer(port, {
start: async ({ startupStartedAt } = {}) =>
await startGatewayServer(port, {
bind,
auth: authOverride,
tailscale: tailscaleOverride,
startupStartedAt,
...(startupConfigSnapshotReadForThisStart
? { startupConfigSnapshotRead: startupConfigSnapshotReadForThisStart }
: {}),
});
},
...(startupConfigSnapshotRead ? { startupConfigSnapshotRead } : {}),
}),
});
const { detectRespawnSupervisor } = await import("../../infra/supervisor-markers.js");

View File

@@ -112,11 +112,6 @@ export function resolveMissingPluginCommandMessage(
config?: OpenClawConfig;
registry?: PluginManifestCommandAliasRegistry;
}) => PluginManifestToolOwnerRecord | undefined;
resolveCliCommandSurfaceOwner?: (params: {
command: string | undefined;
config?: OpenClawConfig;
registry?: PluginManifestCommandAliasRegistry;
}) => string | undefined;
},
): string | null {
const normalizedPluginId = normalizeLowercaseStringOrEmpty(pluginId);
@@ -235,33 +230,6 @@ export function resolveMissingPluginCommandMessage(
if (parentPluginId && allow.includes(parentPluginId)) {
return null;
}
const cliCommandSurfaceOwner = options?.resolveCliCommandSurfaceOwner
? options.resolveCliCommandSurfaceOwner({
command: normalizedPluginId,
config,
...(options?.registry ? { registry: options.registry } : {}),
})
: options?.registry
? resolveManifestCommandAliasOwnerInRegistry({
command: normalizedPluginId,
registry: options.registry,
})?.pluginId
: undefined;
const normalizedCliCommandSurfaceOwner =
normalizeOptionalLowercaseString(cliCommandSurfaceOwner);
if (!normalizedCliCommandSurfaceOwner) {
return null;
}
if (allow.includes(normalizedCliCommandSurfaceOwner)) {
return null;
}
if (normalizedCliCommandSurfaceOwner !== normalizedPluginId) {
return (
`"${normalizedPluginId}" is not a plugin; it is a command provided by the ` +
`"${normalizedCliCommandSurfaceOwner}" plugin. Add "${normalizedCliCommandSurfaceOwner}" to ` +
`\`plugins.allow\` instead of "${normalizedPluginId}".`
);
}
return (
`The \`openclaw ${normalizedPluginId}\` command is unavailable because ` +
`\`plugins.allow\` excludes "${normalizedPluginId}". Add "${normalizedPluginId}" to ` +

View File

@@ -25,7 +25,6 @@ const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(
const resolvePluginCliRootOwnerIdsMock = vi.hoisted(() => vi.fn());
const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestCliCommandSurfaceOwnerMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
@@ -180,7 +179,6 @@ vi.mock("../plugins/cli-registry-loader.js", () => ({
}));
vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({
resolveManifestCliCommandSurfaceOwner: resolveManifestCliCommandSurfaceOwnerMock,
resolveManifestCommandAliasOwner: resolveManifestCommandAliasOwnerMock,
resolveManifestToolOwner: resolveManifestToolOwnerMock,
}));
@@ -254,7 +252,6 @@ describe("runCli exit behavior", () => {
);
resolveManifestCommandAliasOwnerMock.mockReturnValue(undefined);
resolveManifestToolOwnerMock.mockReturnValue(undefined);
resolveManifestCliCommandSurfaceOwnerMock.mockReturnValue(undefined);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
delete process.env.OPENCLAW_HIDE_BANNER;
});
@@ -491,53 +488,6 @@ describe("runCli exit behavior", () => {
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("does not suggest plugins.allow for unknown command roots before proxy startup", async () => {
loadConfigMock.mockReturnValueOnce({
plugins: {
allow: ["browser"],
},
});
let error: unknown;
try {
await runCli(["node", "openclaw", "totally-unknown"]);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
'No built-in command or plugin CLI metadata owns "totally-unknown"',
);
expect((error as Error).message).not.toContain("plugins.allow");
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("preserves plugins.allow diagnostics for roots owned only by CLI metadata", async () => {
loadConfigMock.mockReturnValueOnce({
plugins: {
allow: ["browser"],
},
});
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({
cfg,
primaryCommand,
}: {
cfg?: { plugins?: { allow?: string[] } };
primaryCommand?: string;
}) => (primaryCommand === "qa" && cfg?.plugins?.allow?.length === 0 ? ["qa-lab"] : []),
);
await expect(runCli(["node", "openclaw", "qa"])).rejects.toThrow(
'Add "qa-lab" to `plugins.allow` instead of "qa"',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("reports plugin tool command mistakes before proxy startup", async () => {
resolveManifestToolOwnerMock.mockReturnValueOnce({
toolName: "lcm_recent",

View File

@@ -40,16 +40,6 @@ const losslessClawToolRegistry: PluginManifestCommandAliasRegistry = {
],
};
const browserCommandAliasRegistry: PluginManifestCommandAliasRegistry = {
plugins: [
{
id: "browser",
enabledByDefault: true,
commandAliases: [{ name: "browser" }],
},
],
};
describe("isGatewayRunFastPathArgv", () => {
it("matches only plain gateway foreground starts without root options or help", () => {
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway"])).toBe(true);
@@ -201,15 +191,11 @@ describe("shouldUseBrowserHelpFastPath", () => {
describe("resolveMissingPluginCommandMessage", () => {
it("explains plugins.allow misses for a bundled plugin command", () => {
expect(
resolveMissingPluginCommandMessage(
"browser",
{
plugins: {
allow: ["quietchat"],
},
resolveMissingPluginCommandMessage("browser", {
plugins: {
allow: ["quietchat"],
},
{ registry: browserCommandAliasRegistry },
),
}),
).toContain('`plugins.allow` excludes "browser"');
});
@@ -416,7 +402,7 @@ describe("resolveMissingPluginCommandMessage", () => {
expect(message).toContain('"lossless-claw"');
});
it("returns null for unknown names excluded by plugins.allow", () => {
it("preserves the plugins.allow suggestion when the unknown name is not a plugin tool", () => {
const message = resolveMissingPluginCommandMessage(
"totally-unknown",
{
@@ -426,30 +412,14 @@ describe("resolveMissingPluginCommandMessage", () => {
},
{ registry: losslessClawToolRegistry },
);
expect(message).toBeNull();
});
it("points metadata-only CLI roots in plugins.allow at their parent plugin", () => {
const message = resolveMissingPluginCommandMessage(
"qa",
{
plugins: {
allow: ["browser"],
},
},
{
resolveCliCommandSurfaceOwner: () => "qa-lab",
},
);
expect(message).toContain('"qa" is not a plugin');
expect(message).toContain('"qa-lab"');
expect(message).toContain('Add "qa-lab" to `plugins.allow` instead of "qa"');
expect(message).not.toBeNull();
expect(message).toContain('`plugins.allow` excludes "totally-unknown"');
});
it("does not attribute a tool to an owning plugin excluded by plugins.allow", () => {
// The owning plugin is denied via plugins.allow, so the manifest-declared
// tool is not available through the owning plugin. Tool names are not CLI
// command surfaces, so do not suggest adding the tool name to plugins.allow.
// tool is not available through the owning plugin. Fall through to the
// standard plugins.allow message instead of falsely attributing it.
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
@@ -459,7 +429,9 @@ describe("resolveMissingPluginCommandMessage", () => {
},
{ registry: losslessClawToolRegistry },
);
expect(message).toBeNull();
expect(message).not.toBeNull();
expect(message).not.toContain("agent tool available");
expect(message).toContain('`plugins.allow` excludes "lcm_recent"');
});
it("does not attribute a tool to an owning plugin disabled via plugins.entries", () => {

View File

@@ -319,47 +319,6 @@ async function isPluginCliRoot(params: {
}
}
function createAllowlistAgnosticCliLookupConfig(config: OpenClawConfig): OpenClawConfig {
if (!Array.isArray(config.plugins?.allow) || config.plugins.allow.length === 0) {
return config;
}
return {
...config,
plugins: {
...config.plugins,
allow: [],
},
};
}
async function resolveCliCommandSurfaceOwner(params: {
primary: string;
config: OpenClawConfig;
}): Promise<string | undefined> {
const { resolveManifestCliCommandSurfaceOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const manifestOwner = resolveManifestCliCommandSurfaceOwner({
command: params.primary,
config: params.config,
env: process.env,
});
if (manifestOwner) {
return manifestOwner;
}
try {
const { resolvePluginCliRootOwnerIds } = await import("../plugins/cli-registry-loader.js");
return (
await resolvePluginCliRootOwnerIds({
cfg: createAllowlistAgnosticCliLookupConfig(params.config),
env: process.env,
primaryCommand: params.primary,
})
)?.[0];
} catch {
return undefined;
}
}
async function resolveUnownedCliPrimary(params: {
argv: string[];
config: OpenClawConfig;
@@ -388,12 +347,10 @@ async function resolveUnownedCliPrimaryMessage(params: {
}): Promise<string> {
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const cliCommandSurfaceOwner = await resolveCliCommandSurfaceOwner(params);
return (
resolveMissingPluginCommandMessageFromPolicy(params.primary, params.config, {
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
resolveCliCommandSurfaceOwner: () => cliCommandSurfaceOwner,
}) ??
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`
);
@@ -739,17 +696,12 @@ export async function runCli(argv: string[] = process.argv) {
) {
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const cliCommandSurfaceOwner = await resolveCliCommandSurfaceOwner({
primary,
config,
});
const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy(
primary,
config,
{
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
resolveCliCommandSurfaceOwner: () => cliCommandSurfaceOwner,
},
);
if (missingPluginCommandMessage) {

View File

@@ -194,76 +194,6 @@ function createGatewayCommand(entrypoint: string) {
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
expect(value, label).toBeTypeOf("object");
expect(value, label).not.toBeNull();
return value as Record<string, unknown>;
}
function callArg(mock: { mock: { calls: Array<Array<unknown>> } }, index: number, label: string) {
const call = mock.mock.calls.at(index);
expect(call, label).toBeDefined();
return call?.[0];
}
function expectCallField(
mock: { mock: { calls: Array<Array<unknown>> } },
field: string,
expected: unknown,
) {
const options = requireRecord(callArg(mock, 0, `first ${field} call`), field);
expect(options[field]).toEqual(expected);
return options;
}
function expectGatewayAuthToken(value: unknown, expected: string) {
const root = requireRecord(value, "config root");
const gateway = requireRecord(root.gateway, "config.gateway");
const auth = requireRecord(gateway.auth, "config.gateway.auth");
expect(auth.token).toBe(expected);
}
function readGatewayAuthToken(value: unknown) {
if (!value || typeof value !== "object") {
return undefined;
}
const root = value as Record<string, unknown>;
const gateway = root.gateway;
if (!gateway || typeof gateway !== "object") {
return undefined;
}
const auth = (gateway as Record<string, unknown>).auth;
if (!auth || typeof auth !== "object") {
return undefined;
}
return (auth as Record<string, unknown>).token;
}
function expectCallConfigGatewayAuthToken(
mock: { mock: { calls: Array<Array<unknown>> } },
expected: string,
) {
const matched = mock.mock.calls.some(([value]) => {
const options = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
return readGatewayAuthToken(options.config) === expected;
});
expect(matched).toBe(true);
}
function expectNoteContaining(messagePart: string, title: string) {
const messages = mocks.note.mock.calls
.filter(([, callTitle]) => callTitle === title)
.map(([message]) => String(message));
expect(messages.some((message) => message.includes(messagePart))).toBe(true);
}
function expectNoNoteContaining(messagePart: string, title: string) {
const messages = mocks.note.mock.calls
.filter(([, callTitle]) => callTitle === title)
.map(([message]) => String(message));
expect(messages.some((message) => message.includes(messagePart))).toBe(false);
}
function setupGatewayEntrypointRepairScenario(params: {
currentEntrypoint: string;
installEntrypoint: string;
@@ -358,8 +288,22 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair(cfg);
expectCallField(mocks.auditGatewayServiceConfig, "expectedGatewayToken", "config-token");
expectCallConfigGatewayAuthToken(mocks.buildGatewayInstallPlan, "config-token");
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: "config-token",
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "config-token",
}),
}),
}),
}),
);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1);
@@ -399,12 +343,14 @@ describe("maybeRepairGatewayServiceConfig", () => {
const runtimeNotes = mocks.note.mock.calls.filter(([, title]) => title === "Gateway runtime");
const runtimeMessages = runtimeNotes.map(([message]) => message);
expect(runtimeMessages).not.toContain("duplicate doctor runtime warning");
expect(runtimeMessages.some((message) => String(message).includes("not found"))).toBe(false);
expect(
runtimeMessages.some((message) =>
String(message).includes("Using /home/orin/.nvm/versions/node/v22.22.2/bin/node"),
),
).toBe(true);
expect(runtimeMessages).not.toEqual(
expect.arrayContaining([expect.stringContaining("not found")]),
);
expect(runtimeMessages).toEqual(
expect.arrayContaining([
expect.stringContaining("Using /home/orin/.nvm/versions/node/v22.22.2/bin/node"),
]),
);
});
it("passes planned managed env keys into service audit for legacy inline secret detection", async () => {
@@ -436,10 +382,10 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair({ gateway: {} });
expectCallField(
mocks.auditGatewayServiceConfig,
"expectedManagedServiceEnvKeys",
new Set(["TAVILY_API_KEY"]),
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedManagedServiceEnvKeys: new Set(["TAVILY_API_KEY"]),
}),
);
expect(mocks.install).toHaveBeenCalledTimes(1);
});
@@ -470,12 +416,16 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair({ gateway: { port: 18888 } });
expectCallField(mocks.auditGatewayServiceConfig, "expectedPort", 18888);
const installOptions = requireRecord(
callArg(mocks.install, 0, "install call"),
"install options",
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedPort: 18888,
}),
);
expect(mocks.install).toHaveBeenCalledWith(
expect.objectContaining({
programArguments: expect.arrayContaining(["18888"]),
}),
);
expect(installOptions.programArguments).toContain("18888");
});
it("repairs gateway services with embedded proxy environment values", async () => {
@@ -523,14 +473,34 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair(cfg);
expectCallField(mocks.auditGatewayServiceConfig, "expectedGatewayToken", "env-token");
expectCallConfigGatewayAuthToken(mocks.buildGatewayInstallPlan, "env-token");
const replaceOptions = requireRecord(
callArg(mocks.replaceConfigFile, 0, "replaceConfigFile call"),
"replaceConfigFile options",
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: "env-token",
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "env-token",
}),
}),
}),
}),
);
expect(mocks.replaceConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
nextConfig: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "env-token",
}),
}),
}),
afterWrite: { mode: "auto" },
}),
);
expectGatewayAuthToken(replaceOptions.nextConfig, "env-token");
expect(replaceOptions.afterWrite).toEqual({ mode: "auto" });
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1);
});
@@ -600,17 +570,18 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair({ gateway: {} });
const installPlanOptions = requireRecord(
callArg(mocks.buildGatewayInstallPlan, 0, "buildGatewayInstallPlan call"),
"buildGatewayInstallPlan options",
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENCLAW_WRAPPER: wrapperPath,
}),
existingEnvironment: expect.objectContaining({
OPENCLAW_WRAPPER: wrapperPath,
}),
}),
);
expect(requireRecord(installPlanOptions.env, "install env").OPENCLAW_WRAPPER).toBe(wrapperPath);
expect(
requireRecord(installPlanOptions.existingEnvironment, "install existing environment")
.OPENCLAW_WRAPPER,
).toBe(wrapperPath);
expectNoNoteContaining(
"Gateway service entrypoint does not match the current install.",
expect(mocks.note).not.toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install."),
"Gateway service config",
);
expect(mocks.note).toHaveBeenCalledWith(
@@ -817,8 +788,16 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair(cfg);
expectCallField(mocks.auditGatewayServiceConfig, "expectedGatewayToken", undefined);
expectCallField(mocks.buildGatewayInstallPlan, "config", cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: undefined,
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
}),
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1);
});
@@ -837,14 +816,34 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair(cfg);
expectCallField(mocks.auditGatewayServiceConfig, "expectedGatewayToken", undefined);
const replaceOptions = requireRecord(
callArg(mocks.replaceConfigFile, 0, "replaceConfigFile call"),
"replaceConfigFile options",
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: undefined,
}),
);
expect(mocks.replaceConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
nextConfig: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "stale-token",
}),
}),
}),
afterWrite: { mode: "auto" },
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "stale-token",
}),
}),
}),
}),
);
expectGatewayAuthToken(replaceOptions.nextConfig, "stale-token");
expect(replaceOptions.afterWrite).toEqual({ mode: "auto" });
expectCallConfigGatewayAuthToken(mocks.buildGatewayInstallPlan, "stale-token");
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1);
},
@@ -922,7 +921,11 @@ describe("maybeRepairGatewayServiceConfig", () => {
await runRepair(cfg);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
expectCallField(mocks.buildGatewayInstallPlan, "config", cfg);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
}),
);
expect(mocks.stage).not.toHaveBeenCalled();
},
);

View File

@@ -409,18 +409,6 @@ export const FIELD_HELP: Record<string, string> = {
"Experimental built-in tool flags. Keep these off by default and enable only when you are intentionally testing a preview surface.",
"tools.experimental.planTool":
"Enable the experimental structured `update_plan` tool for non-trivial multi-step work tracking. Leave this off unless you explicitly want the tool outside strict-agentic embedded Pi runs.",
"tools.toolSearch":
"Compact large OpenClaw, MCP, and client tool catalogs behind one search/call surface. Set to true for the default code bridge or use the object form to choose the structured fallback.",
"tools.toolSearch.enabled":
"Enables Tool Search. When on, OpenClaw hides large tool catalogs behind `tool_search_code` or structured search/describe/call tools during PI runs.",
"tools.toolSearch.mode":
'Choose the model-facing surface: "code" exposes `tool_search_code`; "tools" exposes structured search/describe/call fallback tools.',
"tools.toolSearch.codeTimeoutMs":
"Maximum milliseconds for one `tool_search_code` execution. Runtime clamps values to the supported 1s..60s range.",
"tools.toolSearch.searchDefaultLimit":
"Default number of Tool Search results returned when the model omits a limit. Runtime clamps this to `maxSearchLimit`.",
"tools.toolSearch.maxSearchLimit":
"Maximum number of Tool Search results a model can request. Runtime clamps values to the supported 1..50 range.",
"tools.elevated":
"Elevated tool access controls for privileged command surfaces that should only be reachable from trusted senders. Keep disabled unless operator workflows explicitly require elevated actions.",
"tools.elevated.enabled":

View File

@@ -238,12 +238,6 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.agentToAgent.allow": "Agent-to-Agent Target Allowlist",
"tools.experimental": "Experimental Tools",
"tools.experimental.planTool": "Enable Structured Plan Tool",
"tools.toolSearch": "Tool Search",
"tools.toolSearch.enabled": "Enable Tool Search",
"tools.toolSearch.mode": "Tool Search Surface",
"tools.toolSearch.codeTimeoutMs": "Tool Search Code Timeout",
"tools.toolSearch.searchDefaultLimit": "Tool Search Default Results",
"tools.toolSearch.maxSearchLimit": "Tool Search Max Results",
"tools.elevated": "Elevated Tool Access",
"tools.elevated.enabled": "Enable Elevated Tool Access",
"tools.elevated.allowFrom": "Elevated Tool Allow Rules",

View File

@@ -340,35 +340,6 @@ describe("config schema", () => {
expect(parsed?.experimental?.planTool).toBe(true);
});
it("accepts simplified Tool Search config in the runtime zod schema", () => {
expect(ToolsSchema.parse({ toolSearch: true })?.toolSearch).toBe(true);
expect(
ToolsSchema.parse({
toolSearch: {
enabled: true,
mode: "tools",
codeTimeoutMs: 5000,
searchDefaultLimit: 4,
maxSearchLimit: 12,
},
})?.toolSearch,
).toEqual({
enabled: true,
mode: "tools",
codeTimeoutMs: 5000,
searchDefaultLimit: 4,
maxSearchLimit: 12,
});
expect(
ToolsSchema.safeParse({
toolSearch: {
enabled: true,
mode: "both",
},
}).success,
).toBe(false);
});
it("accepts web fetch maxResponseBytes in the runtime zod schema", () => {
const parsed = ToolsSchema.parse({
web: {

View File

@@ -10,7 +10,6 @@ import { loadSessionStore } from "./store-load.js";
import {
resolveAgentSessionStoreTargetsSync,
resolveAllAgentSessionStoreTargetsSync,
resolveSessionStoreTargets,
} from "./targets.js";
import type { SessionEntry } from "./types.js";
@@ -60,7 +59,7 @@ function mergeSessionEntryIntoCombined(params: {
export function loadCombinedSessionStoreForGateway(
cfg: OpenClawConfig,
opts: { agentId?: string; configuredAgentsOnly?: boolean } = {},
opts: { agentId?: string } = {},
): {
storePath: string;
store: Record<string, SessionEntry>;
@@ -94,9 +93,7 @@ export function loadCombinedSessionStoreForGateway(
: undefined;
const targets = requestedAgentId
? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId)
: opts.configuredAgentsOnly === true
? resolveSessionStoreTargets(cfg, { allAgents: true })
: resolveAllAgentSessionStoreTargetsSync(cfg);
: resolveAllAgentSessionStoreTargetsSync(cfg);
const combined: Record<string, SessionEntry> = {};
for (const target of targets) {
const agentId = target.agentId;

Some files were not shown because too many files have changed in this diff Show More