Compare commits

..

7 Commits

Author SHA1 Message Date
scoootscooob
f0635aeca7 plugins: match installed plugin records by root 2026-05-15 20:30:35 -07:00
scoootscooob
2171e714f5 plugins: keep model-profile runtime llm compat 2026-05-15 20:28:33 -07:00
scoootscooob
9dc496986a docs: align runtime llm profile override docs 2026-05-15 20:28:33 -07:00
Eva (agent)
80c54f8288 Normalize same-model profile selection in runtime LLM 2026-05-15 20:28:33 -07:00
Eva (agent)
074621dfd9 Test auth-profile override gates across runtime LLM lanes 2026-05-15 20:28:33 -07:00
Eva (agent)
9588d72156 Gate structured LLM auth profiles consistently 2026-05-15 20:28:33 -07:00
Eva (agent)
9c0e21f239 plugin-sdk: add host-owned structured runtime llm 2026-05-15 20:28:32 -07:00
249 changed files with 2254 additions and 12636 deletions

View File

@@ -6,36 +6,23 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugin SDK: add `openclaw/plugin-sdk/effect-runtime` for typed Effect programs, services, retries, and stream bridges, and prove it through the DuckDuckGo plugin runtime.
- Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`.
- CLI/cron: add `openclaw cron run --wait` with timeout and poll interval controls, plus exact `cron.runs --run-id` filtering so automation can block on one queued manual run. (#81929) Thanks @ificator.
- Maintainer tooling: route Crabbox skill defaults through the repo brokered AWS config, leaving Blacksmith Testbox as an explicit opt-in instead of the broad-proof default.
- CLI/onboarding: localize the setup wizard and bundled channel setup flows for English, Simplified Chinese, and Traditional Chinese. (#80645) Thanks @GaosCode.
- Agents/skills: cache hydrated `resolvedSkills` across warm gateway turns while keying reuse by the redacted effective config, reducing redundant skill snapshot rebuilds without crossing config-gated skill boundaries. (#81451) Thanks @solodmd.
- Telegram/group chat: add opt-in `messages.groupChat.ambientTurns: "room_event"` handling so always-on ambient chatter can run as quiet room context and speak visibly only via the message tool. (#81317) Thanks @obviyus.
- Codex/context engines: bind thread-bootstrap projection epochs to Codex app-server threads, carry redacted tool-result context into fresh threads, and rotate backend threads when projection state changes. (#82351) Thanks @jalehman.
### Fixes
- MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. Fixes #82424. (#82443) Thanks @joshavant.
- Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged.
- Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs.
- Gateway/media: prevent image filenames from overriding generic non-image byte sniffing, so zip/octet-stream payloads mislabeled as images are offloaded or rejected before they become inline image attachments.
- Plugins/web search: downgrade stale optional provider installs to warnings so Gateway and doctor repair paths keep running after startup provider selection. Refs #82313. Thanks @crackmac.
- Telegram/Gateway: route targeted Telegram `/stop@bot` messages onto the control lane without cached bot metadata and match gateway stop requests across raw/canonical session aliases. (#82298) Thanks @VACInc.
- MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images.
- Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs.
- Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging.
- Update/doctor: keep plugin-validation bypasses on the top-level `$include` config write path, so package repair can update included plugin config files without flattening them into the root config.
- Agents/subagents: warn and continue completion announce cleanup when lifecycle cleanup fails, preventing ended subagent runs from becoming silent ghosts. Fixes #82306. Thanks @SebTardif.
- Telegram: let authorized text `/stop` commands use the fast-abort path before queued agent work, so active turns stop immediately instead of processing the abort after the turn finishes; foreign-bot `/stop@otherbot` mentions now stay on the regular topic lane instead of being routed into our control lane. Fixes #82162. Thanks @civiltox.
- Sessions: drop persisted entries with invalid session ids and strip malformed transcript file metadata before hydrating session runtime state.
- Auth/device: normalize malformed persisted device-auth token metadata before returning or preserving token entries.
- Pairing: skip malformed persisted pending pairing requests before approving valid channel pairing codes.
- Commitments: strip malformed optional reminder scope metadata from persisted commitments before matching pending follow-ups.
- Config persistence: normalize malformed auth profile credential fields/state, skip JSON-valid garbage transcript checkpoint rows, and let `openclaw doctor --fix` remove unrepairable cron job rows.
- Cron: skip persisted job rows with malformed schedule or payload shapes in memory, leaving the store for `openclaw doctor --fix` instead of hydrating them into runtime state.
- Task persistence: drop malformed array/scalar requester-origin JSON from task and task-flow SQLite sidecars instead of restoring it as delivery metadata.
- Agents/timeouts: clarify model idle-timeout errors and docs so provider `timeoutSeconds` is shown as bounded by the whole agent/run timeout ceiling.
- Release tooling: align the published launcher Node floor, `npm start`, package script checks, sharded lint locking, Vitest root project coverage, and plugin-SDK declaration build cache metadata so release/package validation does not silently skip or ship stale surfaces.
- Cron/agents: honor configured subagent model fallbacks for isolated scheduled runs and forward that fallback policy into embedded agent timeout failover. Fixes #74985. Thanks @chrisgwynne.
@@ -43,31 +30,18 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI Responses: clamp `input_tokens - cached_tokens` at zero and reconstruct `totalTokens` from input + output + cached components so Responses-API streams report consistent usage when providers under-report `input_tokens` relative to `cached_tokens`.
- Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries.
- Plugins: reject package metadata records whose `package.json` resolves outside the plugin root instead of trusting persisted or reconstructed registry snapshots.
- Plugins: ignore malformed persisted package channel/install metadata instead of crashing catalog reconstruction or leaking invalid install hints.
- Plugin releases: reject package `files` negations that would omit advertised package-local runtime entries from npm plugin tarballs.
- Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text.
- Plugins/dependencies: scrub stale managed-root `openclaw` ownership metadata without deleting a linked active host package, preventing plugin installs from downgrading npm-global hosts. Fixes #79462. Thanks @lisandromachado.
- Gateway/update: keep shutdown hook-runner imports on a stable dist entry and ship a legacy chunk alias so package swaps do not strand running gateways on missing shutdown chunks. Fixes #81819. Thanks @najef1979-code.
- Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records.
- Config persistence: strip malformed pending final-delivery session fields on load so replay/recovery paths skip poisoned reply metadata instead of crashing on raw objects.
- Config persistence: strip malformed plugin extension state and promoted session-slot ownership on load so corrupted session rows do not leak poisoned plugin metadata into replay/projection paths.
- Gateway/sessions: ignore malformed compaction checkpoint rows during session projection so corrupted stores do not crash session list/describe responses or show bogus checkpoint counts.
- Gateway/sessions: keep reachable transcript history when imported tree transcripts reference missing or legacy parent rows, preventing session history reads from going empty after a partial import.
- Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling.
- Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results.
- Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling.
- Providers/audio: reject malformed successful OpenAI-compatible, ElevenLabs, and Deepgram speech responses with provider-owned errors instead of raw parser failures, wrong-shaped transcripts, or JSON/text bodies treated as audio.
- Providers/embeddings: reject malformed successful OpenAI-compatible, Google Gemini, and Amazon Bedrock embedding responses instead of silently returning empty or coerced vectors.
- Providers/catalogs: reject malformed successful LM Studio, GitHub Copilot, DeepInfra, Vercel AI Gateway, and Kilocode model-list responses with provider-owned errors instead of raw parser/type failures or silent fallback catalogs.
- Providers/polling: reject array, null, or scalar successful operation status responses with provider-owned malformed JSON errors instead of waiting until timeout.
- ACPX/Codex: reap plugin-local Codex ACP adapter orphans on startup after wrapper crashes while keeping direct adapter commands out of launch-lease injection. Fixes #82364. (#82459) Thanks @joshavant.
- Telegram: send presentation-only payloads by rendering fallback text and inline buttons instead of treating them as empty. Fixes #82404. (#82449) Thanks @joshavant.
- Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export.
- Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart.
- Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags.
- Config/doctor: replace source-only official Brave and Slack plugin installs from trusted catalog metadata during `openclaw doctor --fix`, unblocking externalized stock plugin recovery after upgrade. (#82425) Thanks @joshavant.
- Agents/bootstrap: ignore stale completed root `BOOTSTRAP.md` context after workspace setup cleanup fails, preventing channel agent turns from treating it as a directory. (#82463) Thanks @joshavant.
- Update/doctor: re-enable the Codex plugin during `openclaw doctor --fix` when configured OpenAI agent models require the Codex runtime, preventing upgraded configs from failing with an unregistered Codex harness. Fixes #82368. (#82502) Thanks @joshavant.
- Configure: show one OpenAI provider entry with ChatGPT/Codex sign-in and API key choices, and keep browsed Codex models in the saved `/model` picker allowlist.
- Agents/model fallback: preserve auto fallback chains across deferred config reloads when session fallback provenance survives but `modelOverrideSource` is missing. Fixes #81982. Thanks @joshavant.
- Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer.
@@ -85,9 +59,7 @@ Docs: https://docs.openclaw.ai
- Telegram: drain queued outbound deliveries after polling reconnect confirms fresh `getUpdates` activity, so stale-socket and network recovery do not leave failed replies stranded. Fixes #50040. Refs #82175. Thanks @dmitriiforpost-commits and @shellyrocklobster.
- Gateway/model auth: abort active provider runs when saved auth is removed through the Gateway control plane, refresh live runtime auth snapshots, and surface `stopReason: "auth-revoked"` to clients. Fixes #81987. (#82346) Thanks @joshavant.
- Codex app-server: keep the raw tool-output idle watchdog armed after `custom_tool_call_output` notifications, so post-tool stream silence fails fast instead of waiting for the terminal idle timeout. Fixes #82274. (#82378) Thanks @joshavant.
- Codex app-server: enforce OpenClaw `before_tool_call` policy for Codex-native app-server shell and approval paths, preventing native tool execution from bypassing plugin policy. Fixes #82372. (#82496) Thanks @joshavant.
- Telegram: mark isolated polling ingress unhealthy when a spooled inbound backlog stalls while Bot API polling still succeeds, so gateway/channel health no longer stays green after Telegram DM processing wedges. Fixes #82175. Thanks @shellyrocklobster.
- Telegram: drop expired approval callbacks from isolated polling after approval id expiry so stale inline-button updates do not retry forever across restarts. Fixes #82347. (#82455) Thanks @joshavant.
- Agents: strip Gemini/Gemma `<final>` tags with attributes or self-closing syntax from delivered replies, including strict final-tag streaming enforcement. Fixes #65867. Thanks @grizdum.
- macOS/update: disarm legacy `ai.openclaw.update.*` LaunchAgents when `openclaw update` starts from one, preventing KeepAlive relaunch loops that repeatedly restart the Gateway and replay update continuations. Fixes #82167. Thanks @DougButdorf.
- Agents/replay: strip internal runtime-context metadata and `NO_REPLY` sentinels from provider replay and pending final-delivery recovery so restart and heartbeat resumes do not feed control text back to the model. Fixes #76629. Thanks @fuyizheng3120, @bryan-chx, and @cael-dandelion-cult.
@@ -105,8 +77,6 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter: stop adding empty DeepSeek V4 `reasoning_content` placeholders to assistant tool-call replay messages and strip empty replay artifacts before follow-up Chat Completions requests, so `openrouter/deepseek/deepseek-v4-pro` no longer fails after tool use. Fixes #82150. (#82158) Thanks @luyao618 and @Suquir0.
- OpenAI-compatible providers: honor streaming-usage compatibility metadata when deciding whether to send `stream_options.include_usage`, while keeping bundled Volcengine routes opted in to Ark streaming usage. Refs #44845. (#82181) Thanks @xuruiray.
- Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng.
- OC Path: add `openclaw path set --dry-run --diff` so addressed edits can be reviewed as a unified diff before writing.
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
- Cron: mirror successful direct scheduled deliveries into the resolved destination session transcript while preserving isolated-delivery awareness policy. (#80786) Thanks @cavit99.
- Cron: preserve rotated transcript identity after session-bound scheduled runs compact, so `sessionTarget: "current"` keeps the next user message on the same conversation. Fixes #82164. Thanks @weissfl.
@@ -127,8 +97,6 @@ Docs: https://docs.openclaw.ai
- Providers: preserve required `reasoning_content` replay for Kimi K2.6/K2 thinking and MiMo V2.6 OpenAI-compatible tool-call follow-up turns while keeping the stock OpenAI/Qwen strip path intact. Fixes #82139. Thanks @yimao.
- Memory search: stop using chokidar write-stability polling for memory and QMD watchers so large Markdown extraPath trees no longer build up regular file descriptors; changed files now settle through the existing debounced sync queue. Fixes #77327 and #78224. (#81802) Thanks @frankekn, @loyur, and @JanPlessow.
- Message tool: rename the Discord channel-create schema field exposed to models from `type` to `channelType`, avoiding NVIDIA NIM JSON Schema parser failures while still accepting legacy `type` tool calls. (#78920) Thanks @YashSaliya.
- Feishu: send CardKit streaming cards as delivered deltas and retry failed updates, preventing duplicated or dropped streamed text. Fixes #82417. (#82419) Thanks @hclsys.
- Gateway/Gmail: stop queued post-ready Gmail sidecars before hot reload and abort stale Tailscale setup, so cancelled watcher restarts cannot rewrite an old public hook target or report abort-killed commands as success. (#82395) Thanks @samzong.
## 2026.5.14
@@ -446,6 +414,7 @@ Docs: https://docs.openclaw.ai
- Plugin SDK: remove the owner-specific `provider-auth-login` public subpath after moving Chutes, GitHub Copilot, and OpenAI Codex auth flows back to provider-owned modules.
- Plugin SDK: remove provider-specific model, stream, and xAI compatibility helpers from public exports after moving bundled callers to provider-owned modules.
- Plugin SDK: expose runtime-supplied active model metadata to native plugin tool factories for diagnostics and plugin-owned policy decisions. Fixes #77857. Thanks @jamiezigelbaum.
- Plugin SDK/runtime: add `api.runtime.llm.completeStructured(...)` for host-owned structured plugin inference with optional image inputs, JSON/schema validation, auth-profile selection, and the same model/agent override trust gates as `api.runtime.llm.complete`.
- 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.
@@ -647,7 +616,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: honor `OPENCLAW_WORKSPACE_DIR` when resolving the default agent workspace, preserving explicit config precedence while keeping env-backed deployments out of the system prompt fallback path. Fixes #66786.
- Doctor/Codex: stop warning that the message tool is unavailable for source-reply paths where OpenClaw grants `message` at runtime, keeping update and doctor output aligned with the OpenAI happy path. Thanks @pashpashpash.
- Channels/Weixin: bump the external Weixin catalog entry to `@tencent-weixin/openclaw-weixin@2.4.3` with the matching package integrity. (#81730) Thanks @scotthuang.
- Agents/subagents: apply `agents.defaults.subagents.model` before target agent primary models during `sessions_spawn`, so model-scoped runtimes such as `claude-cli` stay attached to default child runs. Fixes #81395. (#81783) Thanks @joshavant.
@@ -686,7 +654,6 @@ Docs: https://docs.openclaw.ai
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
- iMessage: stop sending visible `<media:image>` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte.
- Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet.
- Google models: honor configured `reasoning: false` when resolving thinking policy, preventing non-thinking Google/Gemma models from advertising `thinking=medium`. Fixes #81424.
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
- Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong.
- GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy.

View File

@@ -1,2 +1,2 @@
56a29bf137ba67d2c4a428c9d45bf207bc61278f83a28fea972c583f698be62e plugin-sdk-api-baseline.json
0f1c320de15ec315e95acfc4b3acb3333c8b7f86cd14df03070bc540ab4a598e plugin-sdk-api-baseline.jsonl
55aab1bccac852ddd34e529fedfc1e51be0bd51b49fa75cd758fe11fa9d63255 plugin-sdk-api-baseline.json
71297a69c418a5090ac4f5007da677ef35e8d9ac26e8cfb3a8084c2f898aedb9 plugin-sdk-api-baseline.jsonl

View File

@@ -135,7 +135,6 @@ matches you can inspect before choosing one to write.
| `--json` | Force JSON output (default when stdout is not a TTY). |
| `--human` | Force human output (default when stdout is a TTY). |
| `--dry-run` | (only on `set`) print the bytes that would be written without writing. |
| `--diff` | (with `set --dry-run`) print a unified diff instead of the full bytes. |
## `oc://` syntax
@@ -203,8 +202,6 @@ the per-kind AST shape.
Use `--dry-run` before user-visible writes when the exact bytes matter. The
substrate preserves byte-identical output for parse/emit round-trips, but a
mutation can canonicalize the edited region or file depending on kind.
Add `--diff` when you want the preview as a focused before/after patch instead
of the full rendered file.
## Examples
@@ -221,9 +218,6 @@ openclaw path find 'oc://session.jsonl/*/event' --file ./logs/session.jsonl
# Dry-run a write
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run
# Dry-run a write as a unified diff
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run --diff
# Apply the write
openclaw path set 'oc://gateway.jsonc/version' '2.0'
@@ -381,13 +375,12 @@ openclaw path find 'oc://config.jsonc/plugins/{github,slack}/enabled'
### `set <oc-path> <value>`
Write a leaf. Pair with `--dry-run` to preview the bytes that would be
written without touching the file. Add `--diff` for a unified diff preview.
Exits `0` on a successful write, `1` if the substrate refuses (for example, a
sentinel guard hit), `2` on parse errors.
written without touching the file. Exits `0` on a successful write, `1` if
the substrate refuses (for example, a sentinel guard hit), `2` on parse
errors.
```bash
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run --diff
openclaw path set 'oc://gateway.jsonc/version' '2.0'
openclaw path set 'oc://AGENTS.md/Tools/+gh/risk' 'low'
```

View File

@@ -15,7 +15,7 @@ top-level keys, see [Configuration reference](/gateway/configuration-reference).
### `agents.defaults.workspace`
Default: `OPENCLAW_WORKSPACE_DIR` when set, otherwise `~/.openclaw/workspace`.
Default: `~/.openclaw/workspace`.
```json5
{
@@ -23,10 +23,6 @@ Default: `OPENCLAW_WORKSPACE_DIR` when set, otherwise `~/.openclaw/workspace`.
}
```
An explicit `agents.defaults.workspace` value takes precedence over
`OPENCLAW_WORKSPACE_DIR`. Use the environment variable to point default agents
at a mounted workspace when you do not want to write that path into config.
### `agents.defaults.repoRoot`
Optional repository root shown in the system prompt's Runtime line. If unset, OpenClaw auto-detects by walking upward from the workspace.

View File

@@ -228,9 +228,10 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, and `agent_end`.
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.llm.allowModelOverride`: explicitly trust this plugin to request model overrides for `api.runtime.llm.complete`.
- `plugins.entries.<id>.llm.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted plugin LLM completion overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.llm.allowAgentIdOverride`: explicitly trust this plugin to run `api.runtime.llm.complete` against a non-default agent id.
- `plugins.entries.<id>.llm.allowModelOverride`: explicitly trust this plugin to request model overrides for `api.runtime.llm.complete` and `api.runtime.llm.completeStructured`.
- `plugins.entries.<id>.llm.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted plugin runtime LLM overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.llm.allowAgentIdOverride`: explicitly trust this plugin to run `api.runtime.llm.complete` / `completeStructured` against a non-default agent id.
- `plugins.entries.<id>.llm.allowProfileOverride`: explicitly trust this plugin to select a non-default auth profile for runtime LLM completions through either a `profile` field or a `provider/model@profile` model ref. For compatibility, a `provider/model@profile` ref is also accepted when the plugin already has model-override trust for that selected model.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
- Channel plugin account/runtime settings live under `channels.<id>` and should be described by the owning plugin's manifest `channelConfigs` metadata, not by a central OpenClaw option registry.

View File

@@ -1057,16 +1057,6 @@ export default function (api) {
The factory `ctx` exposes optional `config`, `agentDir`, and `workspaceDir`
values for construction-time initialization.
`assemble()` may return `contextProjection` when the active harness has a
persistent backend thread. Omit it for legacy per-turn projection. Return
`{ mode: "thread_bootstrap", epoch }` when the assembled context should be
injected once into a backend thread and reused until the epoch changes. Change
the epoch after the engine's semantic context changes, such as after an
engine-owned compaction pass. Hosts may preserve tool-call metadata, input
shape, and redacted tool results in a thread-bootstrap projection so fresh
backend threads retain tool continuity without copying raw secret-bearing
payloads.
If your engine does **not** own the compaction algorithm, keep `compact()`
implemented and delegate it explicitly:

View File

@@ -20,8 +20,7 @@ diagnostic surfaces around that boundary.
OpenClaw still owns channel routing, session files, visible message delivery,
OpenClaw dynamic tools, approvals, media delivery, and a transcript mirror.
Codex owns the canonical native thread, native model loop, native tool
continuation, and native compaction unless the active OpenClaw context engine
declares that it owns compaction.
continuation, and native compaction.
## Thread bindings and model changes
@@ -185,17 +184,8 @@ diagnostics bundle.
## Compaction and transcript mirror
When the selected model uses the Codex harness, native thread compaction is
delegated to Codex app-server unless an active context engine declares
`ownsCompaction: true`. Owning context engines compact first and cause OpenClaw
to abandon the old Codex backend thread so the next turn can rehydrate a fresh
thread from engine-managed context. OpenClaw keeps a transcript mirror for
channel history, search, `/new`, `/reset`, and future model or harness
switching.
When a context engine requests Codex thread-bootstrap projection, OpenClaw
projects tool-call names and ids, input shapes, and redacted tool-result content
into the fresh Codex thread. It does not copy raw tool-call argument values into
that projection.
delegated to Codex app-server. OpenClaw keeps a transcript mirror for channel
history, search, `/new`, `/reset`, and future model or harness switching.
The mirror includes the user prompt, final assistant text, and lightweight Codex
reasoning or plan records when the app-server emits them. Today, OpenClaw only

View File

@@ -119,9 +119,8 @@ Use `openai/gpt-*` model refs for Codex-backed OpenAI agent turns. Prefer
`openai-codex:*` auth profiles and `auth.order.openai-codex` remain valid, but
do not write new `openai-codex/gpt-*` model refs.
Do not set `compaction.model` or `compaction.provider` on Codex-backed agents
unless a selected context engine owns compaction. Without an owning context
engine, Codex compacts through its native app-server thread state, so OpenClaw
Do not set `compaction.model` or `compaction.provider` on Codex-backed agents.
Codex owns compaction through its native app-server thread state, so OpenClaw
ignores those local summarizer overrides at runtime and `openclaw doctor --fix`
removes them when the agent uses Codex.
@@ -132,12 +131,6 @@ Lossless remains supported as a context engine. Configure it through
`compaction.provider: "lossless-claw"` shape to the Lossless context-engine slot
when Codex is the active runtime.
When the active context engine reports `ownsCompaction: true`, `/compact` runs
that engine's compaction lifecycle and invalidates the bound Codex app-server
thread. The next Codex turn starts a fresh backend thread and rehydrates it from
the context engine instead of layering Codex native compaction on top of the
engine-owned semantic summary.
```json5
{
auth: {
@@ -632,10 +625,8 @@ The Codex harness changes the low-level embedded agent executor only.
- Codex-native shell, patch, MCP, and native app tools are owned by Codex.
OpenClaw can observe or block selected native events through the supported
relay, but it does not rewrite native tool arguments.
- Codex owns native compaction unless the active OpenClaw context engine
declares `ownsCompaction: true`. OpenClaw keeps a transcript mirror for
channel history, search, `/new`, `/reset`, and future model or harness
switching.
- Codex owns native compaction. OpenClaw keeps a transcript mirror for channel
history, search, `/new`, `/reset`, and future model or harness switching.
- Media generation, media understanding, TTS, approvals, and messaging-tool
output continue through the matching OpenClaw provider/model settings.
- `tool_result_persist` applies to OpenClaw-owned transcript tool results, not

View File

@@ -56,41 +56,6 @@ Provider and channel execution paths must use the active runtime config snapshot
## Reusable runtime utilities
Use `openclaw/plugin-sdk/effect-runtime` when plugin code needs OpenClaw's
pinned Effect integration for typed async programs, injectable services, retry
policies, or stream bridges. This subpath keeps plugin code on a supported SDK
surface instead of importing host internals or relying on a different Effect
version.
```typescript
import {
Context,
Effect,
Layer,
runOpenClawEffect,
} from "openclaw/plugin-sdk/effect-runtime";
type SearchRuntime = {
now: () => number;
};
const SearchRuntime = Context.GenericTag<SearchRuntime>("my-plugin/SearchRuntime");
const program = SearchRuntime.pipe(
Effect.map((runtime) => ({ startedAt: runtime.now() })),
);
const result = await runOpenClawEffect(
program.pipe(
Effect.provide(
Layer.succeed(SearchRuntime, {
now: Date.now,
}),
),
),
);
```
Use the channel-turn `botLoopProtection` facts for bot-authored inbound messages. Core applies the shared in-memory sliding-window guard before session record and dispatch, without tying the policy to one channel. The guard tracks `(scopeId, conversationId, participant pair)` keys, counts both directions of a pair together, applies a cooldown once the window budget is exceeded, and prunes inactive entries opportunistically.
Channel plugins that expose this behavior to operators should prefer the shared `channels.defaults.botLoopProtection` shape for baseline budgets, then layer channel/provider-specific overrides on top. The shared config uses seconds because it is user-facing:
@@ -230,8 +195,49 @@ two-party event loops that do not go through the shared channel-turn kernel.
result includes provider/model/agent attribution plus normalized token,
cache, and estimated cost usage when available.
For bounded structured work, use `api.runtime.llm.completeStructured(...)`.
```typescript
const structured = await api.runtime.llm.completeStructured({
instructions: "Extract vendor, total, and searchable tags.",
input: [
{
type: "image",
buffer: receiptBuffer,
mimeType: "image/png",
fileName: "receipt.png",
},
{ type: "text", text: "Prefer the printed total over handwritten notes." },
],
schemaName: "receipt.evidence",
jsonSchema: {
type: "object",
properties: {
vendor: { type: "string" },
total: { type: "number" },
tags: { type: "array", items: { type: "string" } },
},
required: ["vendor", "total"],
},
purpose: "receipts.extract",
profile: "openai-codex:work",
maxTokens: 512,
timeoutMs: 30000,
});
```
`completeStructured(...)` keeps auth, provider routing, and runtime execution
host-owned. Plugins provide instructions, optional text/image inputs, and an
optional JSON Schema; the host returns the raw text plus parsed JSON only
when `jsonMode: true` or `jsonSchema` is provided.
For provider/model-explicit media capability routing, use
`api.runtime.mediaUnderstanding.extractStructuredWithModel(...)` instead.
`completeStructured(...)` is the generic agent-bound runtime lane; the
media-understanding helper is the narrower provider-owned image/media lane.
<Warning>
Model overrides require operator opt-in via `plugins.entries.<id>.llm.allowModelOverride: true` in config. Use `plugins.entries.<id>.llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries.<id>.llm.allowAgentIdOverride: true`.
Model overrides require operator opt-in via `plugins.entries.<id>.llm.allowModelOverride: true` in config. Use `plugins.entries.<id>.llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries.<id>.llm.allowAgentIdOverride: true`. New code should use `plugins.entries.<id>.llm.allowProfileOverride: true` for auth-profile selection through either the `profile` field or a `provider/model@profile` model ref. For compatibility, a model ref with a profile suffix also remains valid when the plugin is already trusted to override that selected model. The same host-owned trust gate applies across runtime LLM surfaces.
</Warning>
</Accordion>

View File

@@ -1,9 +1,7 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
import {
cleanupOpenClawOwnedAcpxProcessTree,
isOpenClawLeaseAwareAcpxProcessCommand,
isOpenClawOwnedAcpxProcessCommand,
reapStaleOpenClawOwnedAcpxOrphans,
type AcpxProcessInfo,
@@ -15,12 +13,6 @@ const CODEX_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_WRAPPER_COMMAND} ${OPENCLAW_AC
const CLAUDE_WRAPPER_COMMAND = `node ${WRAPPER_ROOT}/claude-agent-acp-wrapper.mjs`;
const PLUGIN_DEPS_CODEX_COMMAND =
"node /tmp/openclaw/plugin-runtime-deps/node_modules/@zed-industries/codex-acp/bin/codex-acp.js";
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node ${path.resolve(
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
)}`;
const LOCAL_NODE_MODULES_CODEX_PLATFORM_COMMAND = path.resolve(
"node_modules/@zed-industries/codex-acp-linux-x64/bin/codex-acp",
);
function cleanupDeps(processes: AcpxProcessInfo[]) {
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
@@ -72,21 +64,6 @@ describe("process reaper", () => {
).toBe(false);
});
it("only treats generated wrappers as launch-lease aware", () => {
expect(
isOpenClawLeaseAwareAcpxProcessCommand({
command: CODEX_WRAPPER_COMMAND,
wrapperRoot: WRAPPER_ROOT,
}),
).toBe(true);
expect(
isOpenClawLeaseAwareAcpxProcessCommand({ command: LOCAL_NODE_MODULES_CODEX_COMMAND }),
).toBe(false);
expect(isOpenClawLeaseAwareAcpxProcessCommand({ command: PLUGIN_DEPS_CODEX_COMMAND })).toBe(
false,
);
});
it("recognizes OpenClaw plugin-runtime-deps ACP adapter children", () => {
expect(isOpenClawOwnedAcpxProcessCommand({ command: PLUGIN_DEPS_CODEX_COMMAND })).toBe(true);
expect(isOpenClawOwnedAcpxProcessCommand({ command: "npx @zed-industries/codex-acp" })).toBe(
@@ -94,17 +71,6 @@ describe("process reaper", () => {
);
});
it("recognizes plugin-local ACP adapter package paths without trusting arbitrary installs", () => {
expect(isOpenClawOwnedAcpxProcessCommand({ command: LOCAL_NODE_MODULES_CODEX_COMMAND })).toBe(
true,
);
expect(
isOpenClawOwnedAcpxProcessCommand({
command: "node /tmp/other-project/node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
}),
).toBe(false);
});
it("kills an owned recorded process tree children first", async () => {
const { deps, killed } = cleanupDeps([
{ pid: 100, ppid: 1, command: CODEX_WRAPPER_COMMAND },
@@ -294,28 +260,6 @@ describe("process reaper", () => {
).toEqual([402, 401, 400, 404, 403, 405]);
});
it("reaps plugin-local Codex ACP adapter orphans when the generated wrapper is already gone", async () => {
const { deps, killed } = cleanupDeps([
{ pid: 500, ppid: 1, command: LOCAL_NODE_MODULES_CODEX_COMMAND },
{ pid: 501, ppid: 500, command: LOCAL_NODE_MODULES_CODEX_PLATFORM_COMMAND },
]);
const result = await reapStaleOpenClawOwnedAcpxOrphans({
wrapperRoot: WRAPPER_ROOT,
deps,
});
expect(result.skippedReason).toBeUndefined();
expect(result.inspectedPids).toEqual([500, 501]);
expect(
collectMatching(
killed,
(entry) => entry.signal === "SIGTERM",
(entry) => entry.pid,
),
).toEqual([501, 500]);
});
it("keeps startup scans quiet when process listing is unavailable", async () => {
const result = await reapStaleOpenClawOwnedAcpxOrphans({
wrapperRoot: WRAPPER_ROOT,

View File

@@ -1,27 +1,13 @@
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { promisify } from "node:util";
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
const execFileAsync = promisify(execFile);
const requireFromHere = createRequire(import.meta.url);
const GENERATED_WRAPPER_BASENAMES = new Set([
"codex-acp-wrapper.mjs",
"claude-agent-acp-wrapper.mjs",
]);
const OPENCLAW_PLUGIN_DEPS_MARKER = "/plugin-runtime-deps/";
const OWNED_ACP_PACKAGE_NAMES = [
"@zed-industries/codex-acp",
"@zed-industries/codex-acp-darwin-arm64",
"@zed-industries/codex-acp-darwin-x64",
"@zed-industries/codex-acp-linux-arm64",
"@zed-industries/codex-acp-linux-x64",
"@zed-industries/codex-acp-win32-arm64",
"@zed-industries/codex-acp-win32-x64",
"@agentclientprotocol/claude-agent-acp",
"acpx",
];
const ACP_PACKAGE_MARKERS = [
"/@zed-industries/codex-acp/",
"/@agentclientprotocol/claude-agent-acp/",
@@ -56,22 +42,6 @@ function normalizePathLike(value: string): string {
return value.replaceAll("\\", "/");
}
function resolvePackageRoot(packageName: string): string | undefined {
try {
return normalizePathLike(path.dirname(requireFromHere.resolve(`${packageName}/package.json`)));
} catch {
return undefined;
}
}
const OWNED_ACP_PACKAGE_ROOTS = OWNED_ACP_PACKAGE_NAMES.map(resolvePackageRoot).filter(
(root): root is string => Boolean(root),
);
function commandBelongsToResolvedAcpPackage(command: string): boolean {
return OWNED_ACP_PACKAGE_ROOTS.some((root) => command.includes(`${root}/`));
}
function commandMentionsGeneratedWrapper(command: string): boolean {
return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) => command.includes(basename));
}
@@ -87,21 +57,6 @@ function commandWrapperBelongsToRoot(command: string, wrapperRoot: string | unde
);
}
export function isOpenClawLeaseAwareAcpxProcessCommand(params: {
command: string | undefined;
wrapperRoot?: string;
}): boolean {
const command = params.command?.trim();
if (!command) {
return false;
}
const normalized = normalizePathLike(command);
return (
commandMentionsGeneratedWrapper(normalized) &&
commandWrapperBelongsToRoot(normalized, params.wrapperRoot)
);
}
function commandsReferToSameRootCommand(liveCommand: string, storedCommand: string | undefined) {
if (!storedCommand?.trim()) {
return true;
@@ -192,16 +147,8 @@ export function isOpenClawOwnedAcpxProcessCommand(params: {
return false;
}
const normalized = normalizePathLike(command);
if (
isOpenClawLeaseAwareAcpxProcessCommand({
command: normalized,
wrapperRoot: params.wrapperRoot,
})
) {
return true;
}
if (commandBelongsToResolvedAcpPackage(normalized)) {
return true;
if (commandMentionsGeneratedWrapper(normalized)) {
return commandWrapperBelongsToRoot(normalized, params.wrapperRoot);
}
if (!normalized.includes(OPENCLAW_PLUGIN_DEPS_MARKER)) {
return false;

View File

@@ -16,9 +16,6 @@ const DOCUMENTED_OPENCLAW_BRIDGE_COMMAND =
const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@0.13.0";
const CODEX_ACP_WRAPPER_COMMAND = `node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"`;
const CODEX_ACP_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_ACP_WRAPPER_COMMAND} ${OPENCLAW_ACPX_LEASE_ID_ARG} lease-close ${OPENCLAW_GATEWAY_INSTANCE_ID_ARG} gateway-test`;
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node "${path.resolve(
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
)}"`;
function makeRuntime(
baseStore: TestSessionStore,
@@ -907,50 +904,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(savedRecords[0]?.openclawLeaseId).toBe(lease?.leaseId);
});
it("does not create launch leases for direct plugin-local ACP adapter commands", async () => {
const launchCommands: string[] = [];
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const leaseStore = makeLeaseStore();
const { runtime, delegate, wrappedStore } = makeRuntime(baseStore, {
openclawGatewayInstanceId: "gateway-test",
openclawProcessLeaseStore: leaseStore.store,
openclawWrapperRoot: "/tmp/openclaw/acpx",
agentRegistry: {
resolve: (agentName: string) =>
agentName === "codex" ? LOCAL_NODE_MODULES_CODEX_COMMAND : agentName,
list: () => ["codex"],
},
});
vi.spyOn(delegate, "ensureSession").mockImplementation(async (input) => {
const command = (
runtime as unknown as { scopedAgentRegistry: { resolve(agent: string): string } }
).scopedAgentRegistry.resolve("codex");
launchCommands.push(command);
await wrappedStore.save({
name: input.sessionKey,
agentCommand: command,
pid: 777,
});
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: input.sessionKey,
};
});
await runtime.ensureSession({
sessionKey: "agent:codex:acp:binding:test",
agent: "codex",
mode: "persistent",
});
expect(leaseStore.store.save).not.toHaveBeenCalled();
expect(launchCommands).toEqual([LOCAL_NODE_MODULES_CODEX_COMMAND]);
});
it("keeps reusable persistent ACP launch commands stable across ensures", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => ({

View File

@@ -27,7 +27,7 @@ import {
} from "./process-lease.js";
import {
cleanupOpenClawOwnedAcpxProcessTree,
isOpenClawLeaseAwareAcpxProcessCommand,
isOpenClawOwnedAcpxProcessCommand,
type AcpxProcessCleanupDeps,
} from "./process-reaper.js";
@@ -785,7 +785,7 @@ export class AcpxRuntime implements AcpRuntime {
!this.wrapperRoot ||
!this.gatewayInstanceId ||
!this.processLeaseStore ||
!isOpenClawLeaseAwareAcpxProcessCommand({
!isOpenClawOwnedAcpxProcessCommand({
command: params.command,
wrapperRoot: this.wrapperRoot,
})

View File

@@ -76,34 +76,4 @@ describe("bedrock embedding response parsers", () => {
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects non-object embedding JSON", () => {
expect(() => __testing.parseSingle("titan-v2", "[]")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects missing single embedding vectors", () => {
expect(() => __testing.parseSingle("titan-v2", "{}")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects wrong single embedding vector element types", () => {
expect(() => __testing.parseSingle("titan-v2", '{"embedding":[1,"bad"]}')).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects missing batch embedding vectors", () => {
expect(() => __testing.parseCohereBatch("cohere-v3", "{}")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects wrong batch embedding vector shapes", () => {
expect(() =>
__testing.parseCohereBatch("cohere-v3", '{"embeddings":[[1],{"bad":true}]}'),
).toThrow("Amazon Bedrock embedding response returned malformed JSON");
});
});

View File

@@ -233,42 +233,20 @@ type BedrockEmbeddingResponseJson = {
function parseBedrockEmbeddingResponseJson(raw: string): BedrockEmbeddingResponseJson {
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Amazon Bedrock embedding response returned malformed JSON");
}
return parsed as BedrockEmbeddingResponseJson;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as BedrockEmbeddingResponseJson)
: {};
} catch {
throw new Error("Amazon Bedrock embedding response returned malformed JSON");
}
}
function malformedBedrockEmbeddingResponse(): Error {
return new Error("Amazon Bedrock embedding response returned malformed JSON");
}
function asNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) {
throw malformedBedrockEmbeddingResponse();
}
for (const entry of value) {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw malformedBedrockEmbeddingResponse();
}
}
return value;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
return Array.isArray(value) ? (value as number[]) : [];
}
function asNumberArrayBatch(value: unknown): number[][] {
if (!Array.isArray(value)) {
throw malformedBedrockEmbeddingResponse();
}
return value.map((entry) => asNumberArray(entry));
return Array.isArray(value) ? (value.filter(Array.isArray) as number[][]) : [];
}
function parseSingle(family: Family, raw: string): number[] {
@@ -278,11 +256,10 @@ function parseSingle(family: Family, raw: string): number[] {
return asNumberArray(Array.isArray(data.embeddings) ? data.embeddings[0]?.embedding : null);
case "twelvelabs": {
if (Array.isArray(data.data)) {
return asNumberArray(asRecord(data.data[0])?.embedding);
return asNumberArray(data.data[0]?.embedding);
}
const dataRecord = asRecord(data.data);
if (dataRecord) {
return asNumberArray(dataRecord.embedding);
if (data.data && typeof data.data === "object") {
return asNumberArray((data.data as { embedding?: unknown }).embedding);
}
return asNumberArray(data.embedding);
}
@@ -295,14 +272,12 @@ function parseCohereBatch(family: Family, raw: string): number[][] {
const data = parseBedrockEmbeddingResponseJson(raw);
const embeddings = data.embeddings;
if (!embeddings) {
throw malformedBedrockEmbeddingResponse();
return [];
}
if (family === "cohere-v4" && !Array.isArray(embeddings)) {
const embeddingRecord = asRecord(embeddings);
if (!embeddingRecord) {
throw malformedBedrockEmbeddingResponse();
}
return asNumberArrayBatch(embeddingRecord.float);
return embeddings && typeof embeddings === "object"
? asNumberArrayBatch((embeddings as { float?: unknown }).float)
: [];
}
return asNumberArrayBatch(embeddings);
}

View File

@@ -17,8 +17,7 @@
"install": {
"npmSpec": "@openclaw/brave-plugin",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10",
"allowInvalidConfigRecovery": true
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.5.16"

View File

@@ -38,10 +38,7 @@ export function createCodexAppServerAgentHarness(options?: {
},
runAttempt: async (params) => {
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
return runCodexAppServerAttempt(params, {
pluginConfig: options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
return runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig });
},
runSideQuestion: async (params) => {
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");

View File

@@ -4,12 +4,6 @@ import { describe, expect, it, vi } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import plugin from "./index.js";
const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn());
vi.mock("./src/app-server/run-attempt.js", () => ({
runCodexAppServerAttempt: runCodexAppServerAttemptMock,
}));
function mockCall(mock: { mock: { calls: unknown[][] } }, index = 0) {
return mock.mock.calls.at(index);
}
@@ -129,20 +123,4 @@ describe("codex plugin", () => {
});
expect(unsupported.supported).toBe(false);
});
it("enables the native hook relay for public Codex app-server attempts", async () => {
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const result = { success: true };
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
await expect(harness.runAttempt({ prompt: "hello" } as never)).resolves.toBe(result);
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "hello" },
{
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},
);
});
});

View File

@@ -1,6 +1,5 @@
import {
callGatewayTool,
runBeforeToolCallHook,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -9,14 +8,9 @@ import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./ap
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
callGatewayTool: vi.fn(),
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
blocked: false,
params,
})),
}));
const mockCallGatewayTool = vi.mocked(callGatewayTool);
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -47,13 +41,7 @@ function gatewayCallMethod(callIndex = 0) {
function findApprovalEvent(
params: EmbeddedRunAttemptParams,
fields: {
status?: string;
approvalId?: string;
command?: string;
reason?: string;
message?: string;
},
fields: { status?: string; approvalId?: string; command?: string; reason?: string },
) {
const onAgentEvent = params.onAgentEvent as unknown as { mock?: { calls?: unknown[][] } };
const calls = onAgentEvent.mock?.calls;
@@ -70,8 +58,7 @@ function findApprovalEvent(
(!fields.status || data.status === fields.status) &&
(!fields.approvalId || data.approvalId === fields.approvalId) &&
(!fields.command || data.command === fields.command) &&
(!fields.reason || data.reason === fields.reason) &&
(!fields.message || data.message === fields.message)
(!fields.reason || data.reason === fields.reason)
) {
return data;
}
@@ -94,11 +81,6 @@ function createParams(): EmbeddedRunAttemptParams {
describe("Codex app-server approval bridge", () => {
beforeEach(() => {
mockCallGatewayTool.mockReset();
mockRunBeforeToolCallHook.mockReset();
mockRunBeforeToolCallHook.mockImplementation(async ({ params }) => ({
blocked: false,
params,
}));
});
it("routes command approvals through plugin approvals and accepts allowed commands", async () => {
@@ -134,123 +116,10 @@ describe("Codex app-server approval bridge", () => {
expect(requestPayload.turnSourceChannel).toBe("telegram");
expect(requestPayload.turnSourceTo).toBe("chat-1");
expect(gatewayCallOptions()).toEqual({ expectFinal: false });
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith({
toolName: "bash",
params: {
command: "pnpm test extensions/codex/src/app-server",
approval: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-1",
command: "pnpm test extensions/codex/src/app-server",
},
},
toolCallId: "cmd-1",
approvalMode: "report",
signal: undefined,
ctx: {
agentId: "main",
sessionKey: "agent:main:session-1",
channelId: "telegram",
},
});
findApprovalEvent(params, { status: "pending", approvalId: "plugin:approval-1" });
findApprovalEvent(params, { status: "approved", approvalId: "plugin:approval-1" });
});
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {
const params = createParams();
mockRunBeforeToolCallHook.mockResolvedValueOnce({
blocked: true,
kind: "veto",
deniedReason: "plugin-before-tool-call",
reason: "blocked by policy",
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-blocked",
command: "cat /tmp/private_key",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({ decision: "decline" });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
findApprovalEvent(params, { status: "denied" });
});
it("denies command approvals when OpenClaw tool policy rewrites params", async () => {
const params = createParams();
mockRunBeforeToolCallHook.mockResolvedValueOnce({
blocked: false,
params: {
command: "echo rewritten",
approval: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-rewritten",
command: "echo rewritten",
},
},
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-rewritten",
command: "cat /tmp/private_key",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({ decision: "decline" });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
findApprovalEvent(params, {
status: "denied",
message:
"OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.",
});
});
it("denies command approvals when OpenClaw tool policy requires approval", async () => {
const params = createParams();
mockRunBeforeToolCallHook.mockResolvedValueOnce({
blocked: true,
kind: "failure",
deniedReason: "plugin-approval",
reason: "Plugin approval required",
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-needs-approval",
command: "pnpm test",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({ decision: "decline" });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
findApprovalEvent(params, {
status: "denied",
message: "Plugin approval required",
});
});
it("describes command approvals from parsed command actions when available", async () => {
const params = createParams();
mockCallGatewayTool
@@ -274,12 +143,6 @@ describe("Codex app-server approval bridge", () => {
const requestPayload = gatewayRequestPayload();
expect(String(requestPayload.description)).toContain("Command: pnpm test extensions/codex");
expect(String(requestPayload.description)).not.toContain("bash -lc");
expect(mockRunBeforeToolCallHook.mock.calls.at(0)?.[0]).toMatchObject({
toolName: "bash",
params: {
command: "bash -lc 'pnpm test extensions/codex'",
},
});
findApprovalEvent(params, { command: "pnpm test extensions/codex" });
});

View File

@@ -2,7 +2,6 @@ import {
type AgentApprovalEventData,
formatApprovalDisplayPath,
type EmbeddedRunAttemptParams,
runBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { formatCodexDisplayText } from "../command-formatters.js";
import {
@@ -70,26 +69,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
});
try {
const policyOutcome = await runOpenClawToolPolicyForApprovalRequest({
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
context,
signal: params.signal,
});
if (policyOutcome?.blocked) {
emitApprovalEvent(params.paramsForRun, {
phase: "resolved",
kind: context.kind,
status: "denied",
title: context.title,
...context.eventDetails,
...approvalEventScope(params.method, "denied"),
message: policyOutcome.reason,
});
return buildApprovalResponse(params.method, context.requestParams, "denied");
}
const requestResult = await requestPluginApproval({
paramsForRun: params.paramsForRun,
title: context.title,
@@ -288,117 +267,6 @@ function buildApprovalContext(params: {
};
}
type ApprovalContext = ReturnType<typeof buildApprovalContext>;
async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
context: ApprovalContext;
signal?: AbortSignal;
}): Promise<{ blocked: true; reason: string } | undefined> {
const policyRequest = buildOpenClawToolPolicyRequest(params.method, params.requestParams);
if (!policyRequest) {
return undefined;
}
const cwd = readString(params.requestParams, "cwd") ?? params.paramsForRun.workspaceDir;
const outcome = await runBeforeToolCallHook({
toolName: policyRequest.toolName,
params: policyRequest.params,
...(params.context.itemId ? { toolCallId: params.context.itemId } : {}),
approvalMode: "report",
signal: params.signal,
ctx: {
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
...(cwd ? { cwd } : {}),
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
...(params.paramsForRun.messageChannel || params.paramsForRun.messageProvider
? { channelId: params.paramsForRun.messageChannel ?? params.paramsForRun.messageProvider }
: {}),
},
});
if (outcome.blocked) {
return { blocked: true, reason: outcome.reason };
}
if ("params" in outcome && toolPolicyParamsWereRewritten(policyRequest.params, outcome.params)) {
return {
blocked: true,
reason:
"OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.",
};
}
return undefined;
}
function buildOpenClawToolPolicyRequest(
method: string,
requestParams: JsonObject | undefined,
): { toolName: string; params: JsonObject } | undefined {
if (method === "item/commandExecution/requestApproval") {
const command = readPolicyCommand(requestParams);
return {
toolName: "bash",
params: {
...(command ? { command } : {}),
...(readString(requestParams, "cwd") ? { cwd: readString(requestParams, "cwd") } : {}),
approval: requestParams ?? {},
},
};
}
if (method === "item/fileChange/requestApproval") {
return { toolName: "apply_patch", params: requestParams ?? {} };
}
if (method === "item/permissions/requestApproval") {
return { toolName: "codex_permission_approval", params: requestParams ?? {} };
}
return undefined;
}
function toolPolicyParamsWereRewritten(original: JsonObject, candidate: unknown): boolean {
if (candidate === original) {
return false;
}
const originalText = stableJsonText(original);
const candidateText = stableJsonText(candidate);
return !candidateText || candidateText !== originalText;
}
function stableJsonText(value: unknown): string | undefined {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
const items = value.map((item) => stableJsonText(item));
return items.every((item): item is string => item !== undefined)
? `[${items.join(",")}]`
: undefined;
}
if (isPlainRecord(value)) {
const entries = Object.entries(value)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => {
const text = stableJsonText(item);
return text === undefined ? undefined : `${JSON.stringify(key)}:${text}`;
});
return entries.every((entry): entry is string => entry !== undefined)
? `{${entries.join(",")}}`
: undefined;
}
return undefined;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function commandApprovalDecision(
requestParams: JsonObject | undefined,
outcome: AppServerApprovalOutcome,
@@ -890,36 +758,19 @@ function readDisplayCommandPreview(
return readCommandPreview(record);
}
function readPolicyCommand(record: JsonObject | undefined): string | undefined {
const command = record?.command;
if (typeof command === "string") {
return command;
}
if (Array.isArray(command) && command.every((part): part is string => typeof part === "string")) {
return command.join(" ");
}
const actionCommands = readCommandActions(record);
if (actionCommands.length > 0) {
return actionCommands.join(" && ");
}
return undefined;
}
function readCommandActions(record: JsonObject | undefined): string[] {
const actions = record?.commandActions;
if (!Array.isArray(actions)) {
return [];
}
return actions
.map((action) => (isJsonObject(action) ? readString(action, "command") : undefined))
.filter((command): command is string => Boolean(command));
}
function readCommandActionsPreview(
record: JsonObject | undefined,
): ApprovalPreviewSource | undefined {
const actions = record?.commandActions;
if (!Array.isArray(actions)) {
return undefined;
}
let source: ApprovalPreviewSource | undefined;
for (const command of readCommandActions(record)) {
for (const action of actions) {
const command = isJsonObject(action) ? readString(action, "command") : undefined;
if (!command) {
continue;
}
source = appendPreviewPart(source, command, " && ");
if (source.clipped) {
break;

View File

@@ -10,7 +10,7 @@ import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
import type { CodexServerNotification } from "./protocol.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import { writeCodexAppServerBinding } from "./session-binding.js";
let tempDir: string;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
@@ -298,15 +298,17 @@ describe("maybeCompactCodexAppServerSession", () => {
it("does not warn for legacy Lossless config when the Lossless context engine slot is active", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const contextEngine: ContextEngine = {
info: { id: "lcm", name: "Lossless Context Manager", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact: vi.fn(async () => ({ ok: true, compacted: false, reason: "below threshold" })),
compact: vi.fn() as never,
};
await maybeCompactCodexAppServerSession({
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
@@ -328,6 +330,14 @@ describe("maybeCompactCodexAppServerSession", () => {
},
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(warn).not.toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
@@ -338,15 +348,17 @@ describe("maybeCompactCodexAppServerSession", () => {
it("does not warn for inherited legacy Lossless provider when the Lossless slot is active", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const contextEngine: ContextEngine = {
info: { id: "lcm", name: "Lossless Context Manager", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact: vi.fn(async () => ({ ok: true, compacted: false, reason: "below threshold" })),
compact: vi.fn() as never,
};
await maybeCompactCodexAppServerSession({
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:nik:session-1",
sessionFile,
@@ -375,6 +387,14 @@ describe("maybeCompactCodexAppServerSession", () => {
},
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(warn).not.toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
@@ -410,8 +430,9 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(factory).not.toHaveBeenCalled();
});
it("runs owning context-engine compaction and invalidates the Codex thread binding", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
it("keeps context engines in maintenance mode after native compaction", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
@@ -449,29 +470,26 @@ describe("maybeCompactCodexAppServerSession", () => {
currentTokenCount: 123,
trigger: "manual",
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.summary).toBe("engine summary");
expect(result.result?.firstKeptEntryId).toBe("entry-1");
expect(result.result?.tokensBefore).toBe(55);
expect(result.result?.summary).toBe("");
expect(result.result?.firstKeptEntryId).toBe("");
expect(result.result?.tokensBefore).toBe(123);
const details = compactDetails(result);
expect(details.engine).toBe("lossless-claw");
expect(details.codexThreadBindingInvalidated).toBe(true);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(compact).toHaveBeenCalledTimes(1);
expect(compact).toHaveBeenCalledWith({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
tokenBudget: 777,
currentTokenCount: 123,
compactionTarget: "threshold",
customInstructions: undefined,
force: true,
runtimeContext: { workspaceDir: tempDir, provider: "codex" },
});
expect(details.backend).toBe("codex-app-server");
expect(details.threadId).toBe("thread-1");
expect(details.signal).toBe("thread/compacted");
expect(details.turnId).toBe("turn-1");
expect(compact).not.toHaveBeenCalled();
expect(maintain).toHaveBeenCalledTimes(1);
const [maintainCall] = maintain.mock.calls[0] ?? [];
const maintainParams = maintainCall as
@@ -487,94 +505,11 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(maintainParams?.sessionFile).toBe(sessionFile);
expect(maintainParams?.runtimeContext?.workspaceDir).toBe(tempDir);
expect(maintainParams?.runtimeContext?.provider).toBe("codex");
expect(info).toHaveBeenCalledWith(
"starting context-engine-owned Codex app-server compaction",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
tokenBudget: 777,
currentTokenCount: 123,
trigger: "manual",
compactionTarget: "threshold",
force: true,
}),
);
expect(info).toHaveBeenCalledWith(
"completed context-engine-owned Codex app-server compaction",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
ok: true,
compacted: true,
codexThreadBindingInvalidated: true,
}),
);
});
it("adopts successor transcript handles after owning context-engine compaction", async () => {
const sessionFile = await writeTestBinding();
const successorFile = path.join(tempDir, "session.compacted.jsonl");
await writeCodexAppServerBinding(successorFile, {
threadId: "thread-successor",
cwd: tempDir,
});
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 55,
sessionId: "session-1-compacted",
sessionFile: successorFile,
},
}));
const maintain = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["maintain"]>>[0]) => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}),
);
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
}),
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.sessionId).toBe("session-1-compacted");
expect(result.result?.sessionFile).toBe(successorFile);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(await readCodexAppServerBinding(successorFile)).toBeUndefined();
expect(maintain).toHaveBeenCalledTimes(1);
const [maintainCall] = maintain.mock.calls[0] ?? [];
const maintainParams = maintainCall as
| {
sessionId?: string;
sessionFile?: string;
}
| undefined;
expect(maintainParams?.sessionId).toBe("session-1-compacted");
expect(maintainParams?.sessionFile).toBe(successorFile);
});
it("returns context-engine compaction success when maintenance fails", async () => {
it("returns native compaction success when context-engine maintenance fails", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
@@ -602,17 +537,24 @@ describe("maybeCompactCodexAppServerSession", () => {
workspaceDir: tempDir,
contextEngine,
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.summary).toBe("engine summary");
const details = compactDetails(result);
expect(details.codexThreadBindingInvalidated).toBe(true);
expect(compact).toHaveBeenCalledTimes(1);
expect(details.backend).toBe("codex-app-server");
expect(details.threadId).toBe("thread-1");
expect(compact).not.toHaveBeenCalled();
});
it("does not require a Codex binding when the owning context engine compacts", async () => {
it("does not fall back to context-engine compaction when native compaction cannot run", async () => {
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
@@ -644,14 +586,14 @@ describe("maybeCompactCodexAppServerSession", () => {
});
const compactResult = requireCompactResult(result);
expect(compactResult.ok).toBe(true);
expect(compactResult.compacted).toBe(true);
expect(compactResult.result?.summary).toBe("engine summary");
expect(compact).toHaveBeenCalledTimes(1);
expect(maintain).toHaveBeenCalledTimes(1);
expect(compactResult.ok).toBe(false);
expect(compactResult.compacted).toBe(false);
expect(compactResult.reason).toBe("no codex app-server thread binding");
expect(compact).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
});
it("does not run context-engine maintenance when owning compaction does not compact", async () => {
it("does not run context-engine maintenance when native compaction is skipped", async () => {
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
@@ -663,8 +605,7 @@ describe("maybeCompactCodexAppServerSession", () => {
ingest: vi.fn() as never,
compact: vi.fn(async () => ({
ok: true,
compacted: false,
reason: "below threshold",
compacted: true,
})),
maintain,
};
@@ -678,9 +619,8 @@ describe("maybeCompactCodexAppServerSession", () => {
});
const compactResult = requireCompactResult(result);
expect(compactResult.ok).toBe(true);
expect(compactResult.ok).toBe(false);
expect(compactResult.compacted).toBe(false);
expect(compactResult.reason).toBe("below threshold");
expect(maintain).not.toHaveBeenCalled();
});
});

View File

@@ -14,7 +14,7 @@ import {
import type { CodexAppServerClient, CodexServerNotificationHandler } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
import { clearCodexAppServerBinding, readCodexAppServerBinding } from "./session-binding.js";
import { readCodexAppServerBinding } from "./session-binding.js";
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
turnId?: string;
@@ -36,9 +36,6 @@ export async function maybeCompactCodexAppServerSession(
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
if (activeContextEngine?.info.ownsCompaction) {
return await compactOwningContextEngine(params, activeContextEngine);
}
warnIfIgnoringOpenClawCompactionOverrides(params);
const nativeResult = await compactCodexNativeThread(params, options);
if (activeContextEngine && nativeResult?.ok && nativeResult.compacted) {
@@ -63,119 +60,6 @@ export async function maybeCompactCodexAppServerSession(
return nativeResult;
}
async function compactOwningContextEngine(
params: CompactEmbeddedPiSessionParams,
contextEngine: NonNullable<CompactEmbeddedPiSessionParams["contextEngine"]>,
): Promise<EmbeddedPiCompactResult> {
embeddedAgentLog.info("starting context-engine-owned Codex app-server compaction", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
trigger: params.trigger,
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
force: params.trigger === "manual",
});
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
try {
result = await contextEngine.compact({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
customInstructions: params.customInstructions,
force: params.trigger === "manual",
runtimeContext: params.contextEngineRuntimeContext,
});
} catch (error) {
embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
error: formatErrorMessage(error),
});
return {
ok: false,
compacted: false,
reason: `context engine compaction failed: ${formatErrorMessage(error)}`,
};
}
if (result.ok && result.compacted) {
const compactedSessionId = result.result?.sessionId ?? params.sessionId;
const compactedSessionFile = result.result?.sessionFile ?? params.sessionFile;
try {
await runHarnessContextEngineMaintenance({
contextEngine,
sessionId: compactedSessionId,
sessionKey: params.sessionKey,
sessionFile: compactedSessionFile,
reason: "compaction",
runtimeContext: params.contextEngineRuntimeContext,
config: params.config,
});
} catch (error) {
embeddedAgentLog.warn("context engine compaction maintenance failed", {
sessionId: compactedSessionId,
engineId: contextEngine.info.id,
error: formatErrorMessage(error),
});
}
await clearCodexAppServerBinding(params.sessionFile);
if (compactedSessionFile !== params.sessionFile) {
await clearCodexAppServerBinding(compactedSessionFile);
}
}
embeddedAgentLog.info("completed context-engine-owned Codex app-server compaction", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
codexThreadBindingInvalidated: result.ok && result.compacted,
});
return {
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
result: result.result
? {
...result.result,
summary: result.result.summary ?? "",
firstKeptEntryId: result.result.firstKeptEntryId ?? "",
details: mergeContextEngineCompactionDetails(result.result.details, {
codexThreadBindingInvalidated: result.ok && result.compacted,
}),
}
: result.ok && result.compacted
? {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: { codexThreadBindingInvalidated: true },
}
: undefined,
};
}
function mergeContextEngineCompactionDetails(
details: unknown,
extra: Record<string, unknown>,
): unknown {
if (details && typeof details === "object" && !Array.isArray(details)) {
return {
...(details as Record<string, unknown>),
...extra,
};
}
return extra;
}
function warnIfIgnoringOpenClawCompactionOverrides(params: CompactEmbeddedPiSessionParams): void {
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine

View File

@@ -389,24 +389,6 @@ export function resolveCodexAppServerRuntimeOptions(
};
}
export function isCodexAppServerApprovalPolicyAllowedByRequirements(
policy: CodexAppServerApprovalPolicy,
params: {
env?: NodeJS.ProcessEnv;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
} = {},
): boolean {
const content = readCodexRequirementsToml(params);
if (content === undefined) {
return true;
}
const allowedApprovalPolicies = parseAllowedApprovalPoliciesFromCodexRequirements(content);
return allowedApprovalPolicies === undefined || allowedApprovalPolicies.has(policy);
}
export function resolveCodexComputerUseConfig(
params: {
pluginConfig?: unknown;

View File

@@ -95,54 +95,6 @@ describe("projectContextEngineAssemblyForCodex", () => {
expect(result.promptText).not.toContain("cat .env");
});
it("preserves redacted tool payload context for thread bootstrap projections", () => {
const result = projectContextEngineAssemblyForCodex({
assembledMessages: [
{
role: "assistant",
content: [
{
type: "toolCall",
name: "exec",
input: {
token: "sk-1234567890abcdef",
cmd: "cat .env",
options: { recursive: true },
},
},
],
timestamp: 1,
} as unknown as AgentMessage,
{
role: "toolResult",
content: [
{
type: "toolResult",
toolUseId: "call-1",
content: "OPENAI_API_KEY=sk-1234567890abcdef\nstatus ok",
},
],
timestamp: 2,
} as unknown as AgentMessage,
],
originalHistoryMessages: [],
prompt: "continue",
toolPayloadMode: "preserve",
});
expect(result.promptText).toContain("tool call: exec");
expect(result.promptText).toContain('"inputShape"');
expect(result.promptText).toContain('"token": "[string]"');
expect(result.promptText).toContain('"cmd": "[string]"');
expect(result.promptText).toContain('"recursive": "[boolean]"');
expect(result.promptText).toContain("tool result: call-1");
expect(result.promptText).toContain('"content"');
expect(result.promptText).toContain("OPENAI_API_KEY=");
expect(result.promptText).toContain("status ok");
expect(result.promptText).not.toContain("cat .env");
expect(result.promptText).not.toContain("sk-1234567890abcdef");
});
it("bounds oversized text context", () => {
const result = projectContextEngineAssemblyForCodex({
assembledMessages: [textMessage("assistant", "x".repeat(30_000))],

View File

@@ -1,5 +1,4 @@
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import { redactSensitiveFieldValue, redactToolPayloadText } from "openclaw/plugin-sdk/logging-core";
type CodexContextProjection = {
developerInstructionAddition?: string;
@@ -32,14 +31,12 @@ export function projectContextEngineAssemblyForCodex(params: {
prompt: string;
systemPromptAddition?: string;
maxRenderedContextChars?: number;
toolPayloadMode?: "elide" | "preserve";
}): CodexContextProjection {
const prompt = params.prompt.trim();
const contextMessages = dropDuplicateTrailingPrompt(params.assembledMessages, prompt);
const maxRenderedContextChars = normalizeRenderedContextMaxChars(params.maxRenderedContextChars);
const renderedContext = renderMessagesForCodexContext(contextMessages, {
maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars),
toolPayloadMode: params.toolPayloadMode ?? "elide",
});
const promptText = renderedContext
? [
@@ -148,7 +145,7 @@ function dropDuplicateTrailingPrompt(messages: AgentMessage[], prompt: string):
function renderMessagesForCodexContext(
messages: AgentMessage[],
options: { maxTextPartChars: number; toolPayloadMode: "elide" | "preserve" },
options: { maxTextPartChars: number },
): string {
return messages
.map((message) => {
@@ -159,10 +156,7 @@ function renderMessagesForCodexContext(
.join("\n\n");
}
function renderMessageBody(
message: AgentMessage,
options: { maxTextPartChars: number; toolPayloadMode: "elide" | "preserve" },
): string {
function renderMessageBody(message: AgentMessage, options: { maxTextPartChars: number }): string {
if (!hasMessageContent(message)) {
return "";
}
@@ -179,10 +173,7 @@ function renderMessageBody(
.trim();
}
function renderMessagePart(
part: unknown,
options: { maxTextPartChars: number; toolPayloadMode: "elide" | "preserve" },
): string {
function renderMessagePart(part: unknown, options: { maxTextPartChars: number }): string {
if (!part || typeof part !== "object") {
return "";
}
@@ -197,143 +188,16 @@ function renderMessagePart(
return "[image omitted]";
}
if (type === "toolCall" || type === "tool_use") {
const label = `tool call${typeof record.name === "string" ? `: ${record.name}` : ""}`;
if (options.toolPayloadMode === "preserve") {
return truncateText(
`${label}\n${stableJson(renderToolCallPayload(record))}`,
options.maxTextPartChars,
);
}
return `${label} [input omitted]`;
return `tool call${typeof record.name === "string" ? `: ${record.name}` : ""} [input omitted]`;
}
if (type === "toolResult" || type === "tool_result") {
const label =
typeof record.toolUseId === "string" ? `tool result: ${record.toolUseId}` : "tool result";
if (options.toolPayloadMode === "preserve") {
return truncateText(
`${label}\n${stableJson(renderToolResultPayload(record))}`,
options.maxTextPartChars,
);
}
return `${label} [content omitted]`;
}
return `[${type ?? "non-text"} content omitted]`;
}
function renderToolCallPayload(record: Record<string, unknown>): Record<string, unknown> {
const payload: Record<string, unknown> = pickToolPayloadMetadata(record);
const input = record.input ?? record.arguments;
if (input !== undefined) {
payload.inputShape = summarizeToolInputShape(input);
}
return payload;
}
function renderToolResultPayload(record: Record<string, unknown>): Record<string, unknown> {
const payload: Record<string, unknown> = pickToolPayloadMetadata(record);
for (const [key, value] of Object.entries(record)) {
if (TOOL_PAYLOAD_METADATA_KEYS.has(key)) {
continue;
}
payload[key] = redactPreservedToolValue(key, value);
}
return payload;
}
const TOOL_PAYLOAD_METADATA_KEYS = new Set([
"type",
"name",
"id",
"callId",
"toolCallId",
"toolUseId",
]);
function pickToolPayloadMetadata(record: Record<string, unknown>): Record<string, unknown> {
const payload: Record<string, unknown> = {};
for (const key of TOOL_PAYLOAD_METADATA_KEYS) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
payload[key] = redactSensitiveFieldValue(key, value);
}
}
return payload;
}
// Tool-call inputs can contain shell commands and credentials. For bootstrap
// continuity, retain object structure and primitive types instead of values.
function summarizeToolInputShape(value: unknown, seen = new WeakSet<object>()): unknown {
if (value === null) {
return null;
}
if (Array.isArray(value)) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
return value.map((entry) => summarizeToolInputShape(entry, seen));
}
if (value && typeof value === "object") {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
out[key] = summarizeToolInputShape(child, seen);
}
return out;
}
return `[${typeof value}]`;
}
// Tool results are the useful carried context for a fresh Codex thread, so keep
// their content while applying the same text/field redaction used for tool logs.
function redactPreservedToolValue(
key: string,
value: unknown,
seen = new WeakSet<object>(),
): unknown {
if (typeof value === "string") {
return redactSensitiveFieldValue(key, redactToolPayloadText(value));
}
if (
value === null ||
value === undefined ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
return value.map((entry) => redactPreservedToolValue(key, entry, seen));
}
if (value && typeof value === "object") {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
const out: Record<string, unknown> = {};
for (const [childKey, child] of Object.entries(value as Record<string, unknown>)) {
out[childKey] = redactPreservedToolValue(childKey, child, seen);
}
return out;
}
return `[${typeof value}]`;
}
function stableJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2) ?? "";
} catch {
return "[unserializable payload omitted]";
}
}
function extractMessageText(message: AgentMessage): string {
if (!hasMessageContent(message)) {
return "";

View File

@@ -16,6 +16,7 @@ describe("Codex native hook relay config", () => {
"features.hooks": true,
"hooks.PreToolUse": [
{
matcher: null,
hooks: [
{
type: "command",
@@ -30,6 +31,7 @@ describe("Codex native hook relay config", () => {
],
"hooks.PostToolUse": [
{
matcher: null,
hooks: [
{
type: "command",
@@ -44,6 +46,7 @@ describe("Codex native hook relay config", () => {
],
"hooks.PermissionRequest": [
{
matcher: null,
hooks: [
{
type: "command",
@@ -58,6 +61,7 @@ describe("Codex native hook relay config", () => {
],
"hooks.Stop": [
{
matcher: null,
hooks: [
{
type: "command",
@@ -70,43 +74,8 @@ describe("Codex native hook relay config", () => {
],
},
],
"hooks.state": {
"/<session-flags>/config.toml:pre_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:pre_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"/<session-flags>/config.toml:post_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:post_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"/<session-flags>/config.toml:permission_request:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:permission_request:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"/<session-flags>/config.toml:stop:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:stop:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
},
});
expect(JSON.stringify(config)).not.toContain("timeoutSec");
expect(JSON.stringify(config)).not.toContain('"matcher":null');
expect(config).not.toHaveProperty("hooks.SessionStart");
expect(config).not.toHaveProperty("hooks.UserPromptSubmit");
});
@@ -121,6 +90,7 @@ describe("Codex native hook relay config", () => {
"features.hooks": true,
"hooks.PermissionRequest": [
{
matcher: null,
hooks: [
{
type: "command",
@@ -133,31 +103,17 @@ describe("Codex native hook relay config", () => {
],
},
],
"hooks.state": {
"/<session-flags>/config.toml:permission_request:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:permission_request:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
},
});
});
it("omits matchers so Codex MCP tool names reach the relay with a stable trust hash", () => {
it("leaves matchers open so Codex MCP tool names reach the relay", () => {
const config = buildCodexNativeHookRelayConfig({
relay: createRelay(),
events: ["pre_tool_use", "post_tool_use"],
});
expect((config["hooks.PreToolUse"] as Array<{ matcher?: unknown }>)[0]).not.toHaveProperty(
"matcher",
);
expect((config["hooks.PostToolUse"] as Array<{ matcher?: unknown }>)[0]).not.toHaveProperty(
"matcher",
);
expect((config["hooks.PreToolUse"] as Array<{ matcher: unknown }>)[0]?.matcher).toBeNull();
expect((config["hooks.PostToolUse"] as Array<{ matcher: unknown }>)[0]?.matcher).toBeNull();
});
it("builds deterministic clearing config when the relay is disabled", () => {

View File

@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import type {
NativeHookRelayEvent,
NativeHookRelayRegistrationHandle,
@@ -21,18 +20,6 @@ const CODEX_HOOK_EVENT_BY_NATIVE_EVENT: Record<NativeHookRelayEvent, CodexHookEv
before_agent_finalize: "Stop",
};
const CODEX_HOOK_KEY_LABEL_BY_NATIVE_EVENT: Record<NativeHookRelayEvent, string> = {
pre_tool_use: "pre_tool_use",
post_tool_use: "post_tool_use",
permission_request: "permission_request",
before_agent_finalize: "stop",
};
const CODEX_SESSION_FLAGS_HOOK_SOURCE_PATHS = [
"/<session-flags>/config.toml",
"<session-flags>/config.toml",
] as const;
export function buildCodexNativeHookRelayConfig(params: {
relay: NativeHookRelayRegistrationHandle;
events?: readonly NativeHookRelayEvent[];
@@ -42,39 +29,23 @@ export function buildCodexNativeHookRelayConfig(params: {
const config: JsonObject = {
"features.hooks": true,
};
const hookState: JsonObject = {};
for (const event of events) {
const codexEvent = CODEX_HOOK_EVENT_BY_NATIVE_EVENT[event];
const command = params.relay.commandForEvent(event);
const timeout = normalizeHookTimeoutSec(params.hookTimeoutSec);
config[`hooks.${codexEvent}`] = [
{
matcher: null,
hooks: [
{
type: "command",
command,
timeout,
command: params.relay.commandForEvent(event),
timeout: normalizeHookTimeoutSec(params.hookTimeoutSec),
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
] satisfies JsonValue;
const state = {
enabled: true,
trusted_hash: codexCommandHookTrustedHash({
event,
command,
timeout,
statusMessage: "OpenClaw native hook relay",
}),
};
for (const sourcePath of CODEX_SESSION_FLAGS_HOOK_SOURCE_PATHS) {
hookState[`${sourcePath}:${CODEX_HOOK_KEY_LABEL_BY_NATIVE_EVENT[event]}:0:0`] =
state satisfies JsonValue;
}
}
config["hooks.state"] = hookState;
return config;
}
@@ -91,44 +62,3 @@ export function buildCodexNativeHookRelayDisabledConfig(): JsonObject {
function normalizeHookTimeoutSec(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 5;
}
function codexCommandHookTrustedHash(params: {
event: NativeHookRelayEvent;
command: string;
timeout: number;
statusMessage: string;
}): string {
// Keep the match-all matcher omitted rather than null. Codex app-server
// converts JSON null to an empty TOML string before hashing, which changes the
// trust identity even though both forms match all tools.
const identity = {
event_name: CODEX_HOOK_KEY_LABEL_BY_NATIVE_EVENT[params.event],
hooks: [
{
async: false,
command: params.command,
statusMessage: params.statusMessage,
timeout: params.timeout,
type: "command",
},
],
};
const hash = createHash("sha256")
.update(JSON.stringify(sortJsonValue(identity)))
.digest("hex");
return `sha256:${hash}`;
}
function sortJsonValue(value: JsonValue): JsonValue {
if (!value || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
const sorted: JsonObject = {};
for (const key of Object.keys(value).toSorted()) {
sorted[key] = sortJsonValue(value[key]);
}
return sorted;
}

View File

@@ -267,15 +267,7 @@ function expectRequestInputTextContains(
}
function getRequestInputText(harness: ReturnType<typeof createStartedThreadHarness>): string {
return getRequestInputTextAt(harness, 0);
}
function getRequestInputTextAt(
harness: ReturnType<typeof createStartedThreadHarness>,
index: number,
): string {
const request = harness.requests.filter((entry) => entry.method === "turn/start").at(index);
const params = requireRecord(request?.params, "turn/start params");
const params = requireRequestParams(harness, "turn/start");
const input = requireArray(params.input, "turn/start input");
return input
.map((entry) => {
@@ -407,317 +399,9 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("projects thread-bootstrap context only once for a matching context-engine epoch", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("bootstrap-only context", Date.now()) as never,
);
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ messages, prompt }) => ({
messages: [...messages, userMessage(prompt ?? "", 10)],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
})),
});
const firstHarness = createStartedThreadHarness();
const firstParams = createParams(sessionFile, workspaceDir);
firstParams.contextEngine = contextEngine;
const firstRun = runCodexAppServerAttempt(firstParams);
await firstHarness.waitForMethod("turn/start");
expectRequestInputTextContains(firstHarness, "OpenClaw assembled context for this turn:");
expectRequestInputTextContains(firstHarness, "bootstrap-only context");
await firstHarness.completeTurn();
await firstRun;
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine?.projection).toEqual({
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: undefined,
});
const secondHarness = createStartedThreadHarness(async (method) => {
if (method === "thread/resume") {
return threadStartResult("thread-1");
}
return undefined;
});
const secondRun = runCodexAppServerAttempt(firstParams);
await secondHarness.waitForMethod("turn/start");
expect(secondHarness.requests.map((request) => request.method)).toEqual([
"thread/resume",
"turn/start",
]);
const secondInputText = getRequestInputText(secondHarness);
expect(secondInputText).not.toContain("OpenClaw assembled context for this turn:");
expect(secondInputText).not.toContain("bootstrap-only context");
expect(secondInputText).toBe("hello");
const projectionLogs = info.mock.calls.filter(
([message]) => message === "codex app-server context-engine projection decision",
);
expect(projectionLogs).toEqual([
[
"codex app-server context-engine projection decision",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
mode: "thread_bootstrap",
epoch: "epoch-1",
projected: true,
reason: "missing-thread-binding",
}),
],
[
"codex app-server context-engine projection decision",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
mode: "thread_bootstrap",
epoch: "epoch-1",
previousThreadId: "thread-1",
previousEpoch: "epoch-1",
projected: false,
reason: "matching-thread-bootstrap-binding",
}),
],
]);
await secondHarness.completeTurn();
await secondRun;
});
it("starts a fresh Codex thread and reprojects when context-engine epoch changes", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-old",
},
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [assistantMessage("new epoch context", 10), userMessage(prompt ?? "", 11)],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-new" },
})),
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult("thread-new");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
expectRequestInputTextContains(harness, "new epoch context");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-new",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }],
},
},
});
await run;
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-new");
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-new");
expect(info).toHaveBeenCalledWith(
"codex app-server context-engine projection decision",
expect.objectContaining({
sessionId: "session-1",
engineId: "lossless-claw",
epoch: "epoch-new",
previousThreadId: "thread-old",
previousEpoch: "epoch-old",
projected: true,
reason: "context-engine-binding-mismatch",
}),
);
expect(info).toHaveBeenCalledWith(
"codex app-server wrote context-engine thread binding",
expect.objectContaining({
sessionId: "session-1",
threadId: "thread-new",
engineId: "lossless-claw",
epoch: "epoch-new",
action: "rotated",
}),
);
});
it("reprojects thread-bootstrap context when context-engine policy changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
},
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [assistantMessage("policy changed context", 10), userMessage(prompt ?? "", 11)],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
})),
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult("thread-new");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 80_000;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
expectRequestInputTextContains(harness, "policy changed context");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-new",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }],
},
},
});
await run;
});
it("starts a fresh Codex thread when thread-bootstrap projection falls back to per-turn projection", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
},
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [assistantMessage("per-turn context", 10), userMessage(prompt ?? "", 11)],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
})),
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/resume") {
return threadStartResult("thread-old");
}
if (method === "thread/start") {
return threadStartResult("thread-new");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
expectRequestInputTextContains(harness, "per-turn context");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-new",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }],
},
},
});
await run;
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-new");
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
});
it("retries a resumed context-engine thread on a fresh Codex thread after early context overflow", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const successorFile = path.join(tempDir, "session.compacted.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("pre-compaction context", Date.now()) as never,
);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
@@ -727,44 +411,9 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-before",
},
},
});
let epoch = "epoch-before";
const compact = vi.fn(async () => {
epoch = "epoch-after";
SessionManager.open(successorFile).appendMessage(
assistantMessage("successor compacted context", Date.now()) as never,
);
return {
ok: true,
compacted: true,
result: {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 10,
sessionId: "session-1-compacted",
sessionFile: successorFile,
},
};
});
const assemble = vi.fn(
async ({ messages, prompt }: Parameters<ContextEngine["assemble"]>[0]) => ({
messages: [
...messages,
assistantMessage(`context ${epoch}`, 10),
userMessage(prompt ?? "", 11),
],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch },
}),
);
const contextEngine = createContextEngine({ assemble, compact });
const contextEngine = createContextEngine();
const harness = createStartedThreadHarness(async (method, requestParams) => {
const request = requireRecord(requestParams, `${method} params`);
if (method === "thread/resume") {
@@ -809,37 +458,9 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const result = await run;
expect(result.assistantTexts).toContain("fresh answer");
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
tokenBudget: 400_000,
currentTokenCount: 400_000,
compactionTarget: "threshold",
force: true,
}),
);
expect(assemble).toHaveBeenCalledTimes(2);
const retryAssembleParams = assemble.mock.calls[1]?.[0];
expect(retryAssembleParams?.messages.map((message) => message.role)).toEqual(["assistant"]);
const retryAssembleMessageTexts = retryAssembleParams?.messages.map((message) => {
if (!("content" in message) || !Array.isArray(message.content)) {
return "";
}
const firstContent = message.content[0];
return typeof firstContent === "object" && firstContent !== null && "text" in firstContent
? firstContent.text
: "";
});
expect(retryAssembleMessageTexts).toEqual(["successor compacted context"]);
const retryInputText = getRequestInputTextAt(harness, -1);
expect(retryInputText).toContain("successor compacted context");
expect(retryInputText).not.toContain("pre-compaction context");
const savedBinding = await readCodexAppServerBinding(successorFile);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-fresh");
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-after");
});
it("keeps current-turn context at the front of the Codex context-engine prompt", async () => {

View File

@@ -3079,91 +3079,10 @@ describe("runCodexAppServerAttempt", () => {
expect(preToolUseCommand?.type).toBe("command");
expect(preToolUseCommand?.timeout).toBe(9);
expect(preToolUseCommand?.command).toContain("--event pre_tool_use --timeout 4321");
const hookState = startConfig?.["hooks.state"] as Record<
string,
{ enabled?: unknown; trusted_hash?: unknown }
>;
const preToolUseState = hookState?.["/<session-flags>/config.toml:pre_tool_use:0:0"];
expect(preToolUseState?.enabled).toBe(true);
expect(preToolUseState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("promotes implicit Codex yolo approval policy when OpenClaw tool policy exists", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = harness.requests.find((request) => request.method === "thread/start");
const startParams = startRequest?.params as Record<string, unknown> | undefined;
expect(startParams?.approvalPolicy).toBe("untrusted");
expect(startParams?.sandbox).toBe("danger-full-access");
});
it("keeps implicit Codex yolo approval policy when untrusted approvals are disallowed", () => {
const appServer = resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null });
const resolved = __testing.resolveCodexAppServerForOpenClawToolPolicy({
appServer,
pluginConfig: readCodexPluginConfig({}),
env: {},
shouldPromote: true,
canUseUntrustedApprovalPolicy: false,
});
expect(resolved.approvalPolicy).toBe("never");
});
it("keeps explicit Codex yolo mode unpromoted when OpenClaw tool policy exists", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
pluginConfig: { appServer: { mode: "yolo" } },
});
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = harness.requests.find((request) => request.method === "thread/start");
const startParams = startRequest?.params as Record<string, unknown> | undefined;
expect(startParams?.approvalPolicy).toBe("never");
expect(startParams?.sandbox).toBe("danger-full-access");
});
it("ignores invalid Codex app-server env overrides when promoting tool policy approval", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
);
vi.stubEnv("OPENCLAW_CODEX_APP_SERVER_MODE", " ");
vi.stubEnv("OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY", "always");
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = harness.requests.find((request) => request.method === "thread/start");
const startParams = startRequest?.params as Record<string, unknown> | undefined;
expect(startParams?.approvalPolicy).toBe("untrusted");
});
it("keeps the native hook relay default floor for short Codex turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -12,7 +12,6 @@ import {
emitAgentEvent as emitGlobalAgentEvent,
finalizeHarnessContextEngineTurn,
formatErrorMessage,
hasBeforeToolCallPolicy,
isActiveHarnessContextEngine,
isSubagentSessionKey,
loadCodexBundleMcpThreadConfig,
@@ -37,7 +36,6 @@ import {
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type EmbeddedContextFile,
type ContextEngineProjection,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -65,7 +63,6 @@ import {
} from "./client.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import {
isCodexAppServerApprovalPolicyAllowedByRequirements,
readCodexPluginConfig,
resolveCodexPluginsPolicy,
resolveCodexAppServerRuntimeOptions,
@@ -126,13 +123,10 @@ import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
import {
areCodexDynamicToolFingerprintsCompatible,
buildDeveloperInstructions,
buildContextEngineBinding,
buildTurnStartParams,
codexDynamicToolsFingerprint,
isContextEngineBindingCompatible,
startOrResumeThread,
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import {
inferCodexDynamicToolMeta,
@@ -431,45 +425,6 @@ function restrictCodexAppServerSandboxForOpenClawSandbox(
};
}
function resolveCodexAppServerForOpenClawToolPolicy(params: {
appServer: CodexAppServerRuntimeOptions;
pluginConfig: CodexPluginConfig;
env: NodeJS.ProcessEnv;
shouldPromote: boolean;
canUseUntrustedApprovalPolicy: boolean;
}): CodexAppServerRuntimeOptions {
if (
!params.shouldPromote ||
!params.canUseUntrustedApprovalPolicy ||
params.appServer.approvalPolicy !== "never"
) {
return params.appServer;
}
const explicitMode =
params.pluginConfig.appServer?.mode !== undefined ||
isCodexAppServerPolicyMode(params.env.OPENCLAW_CODEX_APP_SERVER_MODE);
const explicitApprovalPolicy =
params.pluginConfig.appServer?.approvalPolicy !== undefined ||
isCodexAppServerApprovalPolicy(params.env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY);
if (explicitMode || explicitApprovalPolicy) {
return params.appServer;
}
return {
...params.appServer,
approvalPolicy: "untrusted",
};
}
function isCodexAppServerPolicyMode(value: unknown): boolean {
return value === "guardian" || value === "yolo";
}
function isCodexAppServerApprovalPolicy(value: unknown): boolean {
return (
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
);
}
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: {
@@ -507,15 +462,7 @@ export async function runCodexAppServerAttempt(
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
appServer: restrictCodexAppServerSandboxForOpenClawSandbox(configuredAppServer, sandbox),
pluginConfig,
env: process.env,
shouldPromote: hasBeforeToolCallPolicy(),
canUseUntrustedApprovalPolicy:
configuredAppServer.start.transport !== "stdio" ||
isCodexAppServerApprovalPolicyAllowedByRequirements("untrusted"),
});
const appServer = restrictCodexAppServerSandboxForOpenClawSandbox(configuredAppServer, sandbox);
let pluginAppServer: CodexAppServerRuntimeOptions = appServer;
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
configuredEvents: options.nativeHookRelay?.events,
@@ -559,23 +506,6 @@ export async function runCodexAppServerAttempt(
sessionKey: sandboxSessionKey,
...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}),
};
let activeSessionId = params.sessionId;
let activeSessionFile = params.sessionFile;
const buildActiveRunAttemptParams = (): EmbeddedRunAttemptParams => ({
...runtimeParams,
sessionId: activeSessionId,
sessionFile: activeSessionFile,
});
const adoptContextEngineCompactionTranscript = (compactResult: {
result?: { sessionId?: string; sessionFile?: string };
}): void => {
if (compactResult.result?.sessionId) {
activeSessionId = compactResult.result.sessionId;
}
if (compactResult.result?.sessionFile) {
activeSessionFile = compactResult.result.sessionFile;
}
};
const startupAuthAccountCacheKey = await resolveCodexAppServerAuthAccountCacheKey({
authProfileId: startupAuthProfileId,
authProfileStore: params.authProfileStore,
@@ -627,8 +557,8 @@ export async function runCodexAppServerAttempt(
runId: params.runId,
},
});
const hadSessionFile = await pathExists(activeSessionFile);
let historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? [];
const hadSessionFile = await pathExists(params.sessionFile);
let historyMessages = (await readMirroredSessionHistoryMessages(params.sessionFile)) ?? [];
const hookContextWindowFields = {
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
@@ -653,32 +583,28 @@ export async function runCodexAppServerAttempt(
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...hookContextWindowFields,
};
const activeContextEnginePluginId = activeContextEngine
? resolveContextEngineOwnerPluginId(activeContextEngine)
: undefined;
const buildActiveContextEngineRuntimeContext = () =>
buildHarnessContextEngineRuntimeContext({
attempt: buildActiveRunAttemptParams(),
workspaceDir: effectiveWorkspace,
agentDir,
activeAgentId: sessionAgentId,
contextEnginePluginId: activeContextEnginePluginId,
tokenBudget: params.contextTokenBudget,
});
if (activeContextEngine) {
const activeContextEnginePluginId = resolveContextEngineOwnerPluginId(activeContextEngine);
await bootstrapHarnessContextEngine({
hadSessionFile,
contextEngine: activeContextEngine,
sessionId: activeSessionId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
sessionFile: activeSessionFile,
runtimeContext: buildActiveContextEngineRuntimeContext(),
sessionFile: params.sessionFile,
runtimeContext: buildHarnessContextEngineRuntimeContext({
attempt: runtimeParams,
workspaceDir: effectiveWorkspace,
agentDir,
activeAgentId: sessionAgentId,
contextEnginePluginId: activeContextEnginePluginId,
tokenBudget: params.contextTokenBudget,
}),
runMaintenance: runHarnessContextEngineMaintenance,
config: params.config,
warn: (message) => embeddedAgentLog.warn(message),
});
historyMessages =
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
(await readMirroredSessionHistoryMessages(params.sessionFile)) ?? historyMessages;
}
const baseDeveloperInstructions = buildDeveloperInstructions(params);
// Build the workspace bootstrap block before finalizing developer
@@ -698,91 +624,41 @@ export async function runCodexAppServerAttempt(
workspaceBootstrapInstructions,
);
let prePromptMessageCount = historyMessages.length;
let contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
const resetCodexPromptInputs = () => {
promptText = params.prompt;
developerInstructions = joinPresentSections(
baseDeveloperInstructions,
workspaceBootstrapInstructions,
);
prePromptMessageCount = historyMessages.length;
contextEngineProjection = undefined;
};
const applyActiveContextEngineProjection = async (
decisionStartupBinding: CodexAppServerThreadBinding | undefined,
) => {
if (!activeContextEngine) {
return;
}
const assembled = await assembleHarnessContextEngine({
contextEngine: activeContextEngine,
sessionId: activeSessionId,
sessionKey: sandboxSessionKey,
messages: historyMessages,
tokenBudget: params.contextTokenBudget,
availableTools: new Set(toolBridge.specs.map((tool) => tool.name).filter(isNonEmptyString)),
citationsMode: params.config?.memory?.citations,
modelId: params.modelId,
prompt: params.prompt,
});
if (!assembled) {
throw new Error("context engine assemble returned no result");
}
contextEngineProjection = readContextEngineThreadBootstrapProjection(
assembled.contextProjection,
);
const projection = projectContextEngineAssemblyForCodex({
assembledMessages: assembled.messages,
originalHistoryMessages: historyMessages,
prompt: params.prompt,
systemPromptAddition: assembled.systemPromptAddition,
maxRenderedContextChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
}),
toolPayloadMode: contextEngineProjection ? "preserve" : "elide",
});
const projectionDecision = contextEngineProjection
? resolveContextEngineBootstrapProjectionDecision({
startupBinding: decisionStartupBinding,
expectedBinding: buildContextEngineBinding(
buildActiveRunAttemptParams(),
contextEngineProjection,
),
projection: contextEngineProjection,
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
})
: { project: true, reason: "per-turn-projection" };
embeddedAgentLog.info("codex app-server context-engine projection decision", {
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
engineId: activeContextEngine.info.id,
mode: contextEngineProjection?.mode ?? assembled.contextProjection?.mode ?? "per_turn",
epoch: contextEngineProjection?.epoch,
fingerprint: contextEngineProjection?.fingerprint,
previousThreadId: decisionStartupBinding?.threadId,
previousEpoch: decisionStartupBinding?.contextEngine?.projection?.epoch,
previousFingerprint: decisionStartupBinding?.contextEngine?.projection?.fingerprint,
projected: projectionDecision.project,
reason: projectionDecision.reason,
assembledMessages: assembled.messages.length,
originalHistoryMessages: historyMessages.length,
projectedPromptChars: projection.promptText.length,
developerInstructionAdditionChars: projection.developerInstructionAddition?.length ?? 0,
});
promptText = projectionDecision.project ? projection.promptText : params.prompt;
developerInstructions = joinPresentSections(
baseDeveloperInstructions,
workspaceBootstrapInstructions,
projection.developerInstructionAddition,
);
prePromptMessageCount = projection.prePromptMessageCount;
};
if (activeContextEngine) {
try {
await applyActiveContextEngineProjection(startupBinding);
const assembled = await assembleHarnessContextEngine({
contextEngine: activeContextEngine,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
messages: historyMessages,
tokenBudget: params.contextTokenBudget,
availableTools: new Set(toolBridge.specs.map((tool) => tool.name).filter(isNonEmptyString)),
citationsMode: params.config?.memory?.citations,
modelId: params.modelId,
prompt: params.prompt,
});
if (!assembled) {
throw new Error("context engine assemble returned no result");
}
const projection = projectContextEngineAssemblyForCodex({
assembledMessages: assembled.messages,
originalHistoryMessages: historyMessages,
prompt: params.prompt,
systemPromptAddition: assembled.systemPromptAddition,
maxRenderedContextChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
}),
});
promptText = projection.promptText;
developerInstructions = joinPresentSections(
baseDeveloperInstructions,
workspaceBootstrapInstructions,
projection.developerInstructionAddition,
);
prePromptMessageCount = projection.prePromptMessageCount;
} catch (assembleErr) {
embeddedAgentLog.warn("context engine assemble failed; using Codex baseline prompt", {
error: formatErrorMessage(assembleErr),
@@ -803,14 +679,13 @@ export async function runCodexAppServerAttempt(
promptText = projection.promptText;
prePromptMessageCount = projection.prePromptMessageCount;
}
const buildPromptFromCurrentInputs = () =>
resolveAgentHarnessBeforePromptBuildResult({
prompt: prependCurrentTurnContext(promptText, params.currentTurnContext),
developerInstructions,
messages: historyMessages,
ctx: hookContext,
});
let promptBuild = await buildPromptFromCurrentInputs();
promptText = prependCurrentTurnContext(promptText, params.currentTurnContext);
const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({
prompt: promptText,
developerInstructions,
messages: historyMessages,
ctx: hookContext,
});
const systemPromptReport = buildCodexSystemPromptReport({
attempt: params,
sessionKey: sandboxSessionKey,
@@ -919,40 +794,38 @@ export async function runCodexAppServerAttempt(
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
const buildThreadLifecycleParams = () =>
({
client: startupClient,
params: buildActiveRunAttemptParams(),
agentId: sessionAgentId,
cwd: effectiveWorkspace,
dynamicTools: toolBridge.specs,
appServer: pluginAppServer,
developerInstructions: promptBuild.developerInstructions,
config: threadConfig,
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
contextEngineProjection,
pluginThreadConfig: pluginThreadConfigEnabled
? {
enabled: true,
inputFingerprint: pluginThreadConfigInputFingerprint,
enabledPluginConfigKeys,
build: () =>
buildCodexPluginThreadConfig({
pluginConfig,
request: (method, requestParams) =>
startupClient.request(method, requestParams, {
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
}),
}
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
restartContextEngineCodexThread = () => startOrResumeThread(buildThreadLifecycleParams());
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
const threadLifecycleParams = {
client: startupClient,
params: runtimeParams,
agentId: sessionAgentId,
cwd: effectiveWorkspace,
dynamicTools: toolBridge.specs,
appServer: pluginAppServer,
developerInstructions: promptBuild.developerInstructions,
config: threadConfig,
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
pluginThreadConfig: pluginThreadConfigEnabled
? {
enabled: true,
inputFingerprint: pluginThreadConfigInputFingerprint,
enabledPluginConfigKeys,
build: () =>
buildCodexPluginThreadConfig({
pluginConfig,
request: (method, requestParams) =>
startupClient.request(method, requestParams, {
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
}),
}
: undefined,
} satisfies Parameters<typeof startOrResumeThread>[0];
restartContextEngineCodexThread = () => startOrResumeThread(threadLifecycleParams);
const startupThread = await startOrResumeThread(threadLifecycleParams);
return { client: startupClient, thread: startupThread };
};
for (
@@ -1627,91 +1500,7 @@ export async function runCodexAppServerAttempt(
}
});
const forceContextEngineCompactionForCodexOverflow = async (error: unknown): Promise<boolean> => {
if (!activeContextEngine?.info.ownsCompaction) {
return false;
}
embeddedAgentLog.warn(
"codex app-server context-engine turn overflowed; forcing context-engine compaction",
{
sessionId: activeSessionId,
sessionKey: sandboxSessionKey,
threadId: thread.threadId,
engineId: activeContextEngine.info.id,
tokenBudget: params.contextTokenBudget,
error: formatErrorMessage(error),
},
);
try {
const runtimeContext = buildActiveContextEngineRuntimeContext();
const overflowTokenCount = params.contextTokenBudget ?? params.contextWindowInfo?.tokens;
const compactResult = await activeContextEngine.compact({
sessionId: activeSessionId,
sessionKey: sandboxSessionKey,
sessionFile: activeSessionFile,
tokenBudget: params.contextTokenBudget,
force: true,
...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}),
compactionTarget: "threshold",
runtimeContext: overflowTokenCount
? {
...runtimeContext,
currentTokenCount: overflowTokenCount,
}
: runtimeContext,
});
embeddedAgentLog.info("codex app-server context-engine forced compaction result", {
sessionId: activeSessionId,
sessionKey: sandboxSessionKey,
engineId: activeContextEngine.info.id,
ok: compactResult.ok,
compacted: compactResult.compacted,
reason: compactResult.reason,
tokensBefore: compactResult.result?.tokensBefore,
tokensAfter: compactResult.result?.tokensAfter,
});
if (!compactResult.ok || !compactResult.compacted) {
return false;
}
adoptContextEngineCompactionTranscript(compactResult);
const maintenanceRuntimeContext = buildActiveContextEngineRuntimeContext();
await runHarnessContextEngineMaintenance({
contextEngine: activeContextEngine,
sessionId: activeSessionId,
sessionKey: sandboxSessionKey,
sessionFile: activeSessionFile,
reason: "compaction",
runtimeContext: maintenanceRuntimeContext,
config: params.config,
});
return true;
} catch (compactErr) {
embeddedAgentLog.warn("codex app-server context-engine forced compaction failed", {
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
engineId: activeContextEngine.info.id,
error: formatErrorMessage(compactErr),
});
return false;
}
};
const rebuildPromptAfterContextEngineCompaction = async () => {
historyMessages =
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
resetCodexPromptInputs();
try {
await applyActiveContextEngineProjection(undefined);
} catch (assembleErr) {
embeddedAgentLog.warn(
"context engine assemble failed after forced compaction; using Codex baseline prompt",
{
error: formatErrorMessage(assembleErr),
},
);
}
promptBuild = await buildPromptFromCurrentInputs();
};
const buildLlmInputEvent = () => ({
const llmInputEvent = {
runId: params.runId,
sessionId: params.sessionId,
provider: params.provider,
@@ -1720,8 +1509,8 @@ export async function runCodexAppServerAttempt(
prompt: promptBuild.prompt,
historyMessages,
imagesCount: params.images?.length ?? 0,
});
const buildTurnStartFailureMessages = () => [
};
const turnStartFailureMessages = [
...historyMessages,
buildCodexUserPromptMessage({ ...params, prompt: promptBuild.prompt }),
];
@@ -1742,7 +1531,7 @@ export async function runCodexAppServerAttempt(
);
try {
runAgentHarnessLlmInputHook({
event: buildLlmInputEvent(),
event: llmInputEvent,
ctx: hookContext,
});
emitCodexAppServerEvent(params, {
@@ -1767,15 +1556,7 @@ export async function runCodexAppServerAttempt(
error: formatErrorMessage(turnStartError),
},
);
const preRetrySessionFile = activeSessionFile;
const compactedForRetry = await forceContextEngineCompactionForCodexOverflow(turnStartError);
await clearCodexAppServerBinding(preRetrySessionFile);
if (activeSessionFile !== preRetrySessionFile) {
await clearCodexAppServerBinding(activeSessionFile);
}
if (compactedForRetry) {
await rebuildPromptAfterContextEngineCompaction();
}
await clearCodexAppServerBinding(params.sessionFile);
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
@@ -1826,7 +1607,7 @@ export async function runCodexAppServerAttempt(
});
runAgentHarnessAgentEndHook({
event: {
messages: buildTurnStartFailureMessages(),
messages: turnStartFailureMessages,
success: false,
error: turnStartErrorMessage,
durationMs: Date.now() - attemptStartedAt,
@@ -1855,7 +1636,7 @@ export async function runCodexAppServerAttempt(
return buildCodexTurnStartFailureResult({
params,
message: usageLimitError.message,
messagesSnapshot: buildTurnStartFailureMessages(),
messagesSnapshot: turnStartFailureMessages,
systemPromptReport,
});
}
@@ -2025,21 +1806,21 @@ export async function runCodexAppServerAttempt(
if (activeContextEngine) {
const activeContextEnginePluginId = resolveContextEngineOwnerPluginId(activeContextEngine);
const finalMessages =
(await readMirroredSessionHistoryMessages(activeSessionFile)) ??
(await readMirroredSessionHistoryMessages(params.sessionFile)) ??
historyMessages.concat(result.messagesSnapshot);
await finalizeHarnessContextEngineTurn({
contextEngine: activeContextEngine,
promptError: Boolean(finalPromptError),
aborted: finalAborted,
yieldAborted: Boolean(result.yieldDetected),
sessionIdUsed: activeSessionId,
sessionIdUsed: params.sessionId,
sessionKey: sandboxSessionKey,
sessionFile: activeSessionFile,
sessionFile: params.sessionFile,
messagesSnapshot: finalMessages,
prePromptMessageCount,
tokenBudget: params.contextTokenBudget,
runtimeContext: buildHarnessContextEngineRuntimeContextFromUsage({
attempt: buildActiveRunAttemptParams(),
attempt: runtimeParams,
workspaceDir: effectiveWorkspace,
agentDir,
activeAgentId: sessionAgentId,
@@ -2640,65 +2421,6 @@ function shouldProjectMirroredHistoryForCodexStart(params: {
});
}
function readContextEngineThreadBootstrapProjection(
projection: ContextEngineProjection | undefined,
): CodexContextEngineThreadBootstrapProjection | undefined {
if (projection?.mode !== "thread_bootstrap") {
return undefined;
}
const epoch = projection.epoch?.trim();
if (!epoch) {
embeddedAgentLog.warn(
"context engine requested Codex thread-bootstrap projection without an epoch; using per-turn projection",
);
return undefined;
}
const fingerprint = projection.fingerprint?.trim();
return {
mode: "thread_bootstrap",
epoch,
...(fingerprint ? { fingerprint } : {}),
};
}
function resolveContextEngineBootstrapProjectionDecision(params: {
startupBinding: CodexAppServerThreadBinding | undefined;
expectedBinding: ReturnType<typeof buildContextEngineBinding>;
projection: CodexContextEngineThreadBootstrapProjection;
dynamicToolsFingerprint: string;
}): { project: boolean; reason: string } {
const bindingProjection = params.startupBinding?.contextEngine?.projection;
if (!params.startupBinding?.threadId || !bindingProjection) {
return {
project: true,
reason: !params.startupBinding?.threadId
? "missing-thread-binding"
: "missing-projection-binding",
};
}
if (
!params.expectedBinding ||
!isContextEngineBindingCompatible(params.startupBinding.contextEngine, params.expectedBinding)
) {
return { project: true, reason: "context-engine-binding-mismatch" };
}
if (
!areCodexDynamicToolFingerprintsCompatible({
previous: params.startupBinding.dynamicToolsFingerprint,
next: params.dynamicToolsFingerprint,
})
) {
return { project: true, reason: "dynamic-tools-mismatch" };
}
const projectionChanged =
bindingProjection.mode !== "thread_bootstrap" ||
bindingProjection.epoch !== params.projection.epoch ||
bindingProjection.fingerprint !== params.projection.fingerprint;
return projectionChanged
? { project: true, reason: "projection-mismatch" }
: { project: false, reason: "matching-thread-bootstrap-binding" };
}
async function withCodexStartupTimeout<T>(params: {
timeoutMs: number;
signal: AbortSignal;
@@ -3593,7 +3315,6 @@ export const __testing = {
remapCodexContextFilePath,
resolveDynamicToolCallTimeoutMs,
restrictCodexAppServerSandboxForOpenClawSandbox,
resolveCodexAppServerForOpenClawToolPolicy,
resolveOpenClawCodingToolsSessionKeys,
shouldForceMessageTool,
setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void {

View File

@@ -54,14 +54,6 @@ export type CodexAppServerContextEngineBinding = {
schemaVersion: 1;
engineId: string;
policyFingerprint: string;
projection?: CodexAppServerContextEngineProjectionBinding;
};
export type CodexAppServerContextEngineProjectionBinding = {
schemaVersion: 1;
mode: "thread_bootstrap";
epoch: string;
fingerprint?: string;
};
export function resolveCodexAppServerBindingPath(sessionFile: string): string {
@@ -190,30 +182,6 @@ function readContextEngineBinding(value: unknown): CodexAppServerContextEngineBi
schemaVersion: 1,
engineId: record.engineId,
policyFingerprint: record.policyFingerprint,
projection: readContextEngineProjectionBinding(record.projection),
};
}
function readContextEngineProjectionBinding(
value: unknown,
): CodexAppServerContextEngineProjectionBinding | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
if (
record.schemaVersion !== 1 ||
record.mode !== "thread_bootstrap" ||
typeof record.epoch !== "string" ||
!record.epoch.trim()
) {
return undefined;
}
return {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: record.epoch,
fingerprint: typeof record.fingerprint === "string" ? record.fingerprint : undefined,
};
}

View File

@@ -41,7 +41,6 @@ import {
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
type CodexAppServerContextEngineBinding,
type CodexAppServerContextEngineProjectionBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
@@ -54,12 +53,6 @@ export type CodexAppServerThreadLifecycleBinding = CodexAppServerThreadBinding &
lifecycle: CodexAppServerThreadLifecycle;
};
export type CodexContextEngineThreadBootstrapProjection = {
mode: "thread_bootstrap";
epoch: string;
fingerprint?: string;
};
export type CodexPluginThreadConfigProvider = {
enabled: boolean;
inputFingerprint?: string;
@@ -88,13 +81,9 @@ export async function startOrResumeThread(params: {
mcpServersFingerprint?: string;
mcpServersFingerprintEvaluated?: boolean;
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
}): Promise<CodexAppServerThreadLifecycleBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const contextEngineBinding = buildContextEngineBinding(
params.params,
params.contextEngineProjection,
);
const contextEngineBinding = buildContextEngineBinding(params.params);
const userMcpServersConfigPatch = buildCodexUserMcpServersThreadConfigPatch(
params.params.config,
{
@@ -121,12 +110,6 @@ export async function startOrResumeThread(params: {
threadId: binding.threadId,
engineId: contextEngineBinding?.engineId,
previousEngineId: binding.contextEngine?.engineId,
epoch: contextEngineBinding?.projection?.epoch,
previousEpoch: binding.contextEngine?.projection?.epoch,
fingerprint: contextEngineBinding?.projection?.fingerprint,
previousFingerprint: binding.contextEngine?.projection?.fingerprint,
policyFingerprint: contextEngineBinding?.policyFingerprint,
previousPolicyFingerprint: binding.contextEngine?.policyFingerprint,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
@@ -278,17 +261,6 @@ export async function startOrResumeThread(params: {
config: params.params.config,
},
);
if (contextEngineBinding) {
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
threadId: response.thread.id,
engineId: contextEngineBinding.engineId,
epoch: contextEngineBinding.projection?.epoch,
fingerprint: contextEngineBinding.projection?.fingerprint,
action: "resumed",
});
}
return {
...binding,
threadId: response.thread.id,
@@ -371,17 +343,6 @@ export async function startOrResumeThread(params: {
config: params.params.config,
},
);
if (contextEngineBinding) {
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
threadId: response.thread.id,
engineId: contextEngineBinding.engineId,
epoch: contextEngineBinding.projection?.epoch,
fingerprint: contextEngineBinding.projection?.fingerprint,
action: rotatedContextEngineBinding ? "rotated" : "started",
});
}
}
return {
schemaVersion: 1,
@@ -407,9 +368,8 @@ export async function startOrResumeThread(params: {
};
}
export function buildContextEngineBinding(
function buildContextEngineBinding(
params: EmbeddedRunAttemptParams,
projection?: CodexContextEngineThreadBootstrapProjection,
): CodexAppServerContextEngineBinding | undefined {
const contextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
@@ -436,45 +396,17 @@ export function buildContextEngineBinding(
}),
}),
}),
projection: projection ? buildContextEngineProjectionBinding(projection) : undefined,
};
}
function buildContextEngineProjectionBinding(
projection: CodexContextEngineThreadBootstrapProjection,
): CodexAppServerContextEngineProjectionBinding {
return {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: projection.epoch,
fingerprint: projection.fingerprint,
};
}
export function isContextEngineBindingCompatible(
function isContextEngineBindingCompatible(
previous: CodexAppServerContextEngineBinding | undefined,
next: CodexAppServerContextEngineBinding,
): boolean {
return (
previous?.schemaVersion === next.schemaVersion &&
previous.engineId === next.engineId &&
previous.policyFingerprint === next.policyFingerprint &&
areContextEngineProjectionBindingsCompatible(previous.projection, next.projection)
);
}
function areContextEngineProjectionBindingsCompatible(
previous: CodexAppServerContextEngineProjectionBinding | undefined,
next: CodexAppServerContextEngineProjectionBinding | undefined,
): boolean {
if (!next) {
return previous === undefined;
}
return (
previous?.schemaVersion === next.schemaVersion &&
previous.mode === next.mode &&
previous.epoch === next.epoch &&
previous.fingerprint === next.fingerprint
previous.policyFingerprint === next.policyFingerprint
);
}

View File

@@ -3,7 +3,7 @@ import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { transcribeDeepgramAudio } from "./audio.js";
installPinnedHostnameTestHooks();
@@ -83,64 +83,4 @@ describe("transcribeDeepgramAudio", () => {
}),
).rejects.toThrow("Audio transcription response missing transcript");
});
it("wraps malformed successful transcription JSON with a stable provider error", async () => {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response("{ nope"));
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");
});
it("rejects non-object successful transcription JSON with a stable provider error", async () => {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response(JSON.stringify([])));
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");
});
it("rejects wrong nested transcript shapes with a stable provider error", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({
results: { channels: { alternatives: [{ transcript: "hello" }] } },
});
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");
});
it("rejects non-string transcript values with a stable provider error", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({
results: { channels: [{ alternatives: [{ transcript: 123 }] }] },
});
await expect(
transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.wav",
apiKey: "test-key",
timeoutMs: 1234,
fetchFn,
}),
).rejects.toThrow("Audio transcription failed: malformed JSON response");
});
});

View File

@@ -5,7 +5,6 @@ import type {
import {
assertOkOrThrowHttpError,
postTranscriptionRequest,
readProviderJsonObjectResponse,
resolveProviderHttpRequestConfig,
requireTranscriptionText,
} from "openclaw/plugin-sdk/provider-http";
@@ -18,36 +17,15 @@ function resolveModel(model?: string): string {
return trimmed || DEFAULT_DEEPGRAM_AUDIO_MODEL;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readDeepgramTranscript(payload: Record<string, unknown>): string | undefined {
const results = asRecord(payload.results);
if (!results) {
return undefined;
}
if (!Array.isArray(results.channels)) {
throw new Error("Audio transcription failed: malformed JSON response");
}
const channel = asRecord(results.channels[0]);
if (!channel) {
return undefined;
}
if (!Array.isArray(channel.alternatives)) {
throw new Error("Audio transcription failed: malformed JSON response");
}
const alternative = asRecord(channel.alternatives[0]);
if (!alternative) {
return undefined;
}
if (alternative.transcript !== undefined && typeof alternative.transcript !== "string") {
throw new Error("Audio transcription failed: malformed JSON response");
}
return alternative.transcript;
}
type DeepgramTranscriptResponse = {
results?: {
channels?: Array<{
alternatives?: Array<{
transcript?: string;
}>;
}>;
};
};
export async function transcribeDeepgramAudio(
params: AudioTranscriptionRequest,
@@ -97,9 +75,9 @@ export async function transcribeDeepgramAudio(
try {
await assertOkOrThrowHttpError(res, "Audio transcription failed");
const payload = await readProviderJsonObjectResponse(res, "Audio transcription failed");
const payload = (await res.json()) as DeepgramTranscriptResponse;
const transcript = requireTranscriptionText(
readDeepgramTranscript(payload),
payload.results?.channels?.[0]?.alternatives?.[0]?.transcript,
"Audio transcription response missing transcript",
);
return { text: transcript, model };

View File

@@ -177,25 +177,6 @@ describe("discoverDeepInfraModels", () => {
});
});
it("falls back without caching malformed successful model list payloads", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: {} }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeModelEntry({ id: "recovered/model" })] }),
});
await withFetchPathTest(mockFetch, async () => {
expect(await discoverDeepInfraModels()).toStrictEqual(expectedStaticCatalog());
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(["recovered/model"]);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
it("caches successful discovery responses only", async () => {
const mockFetch = vi
.fn()

View File

@@ -1,8 +1,5 @@
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
fetchWithTimeout,
readProviderJsonArrayFieldResponse,
} from "openclaw/plugin-sdk/provider-http";
import { fetchWithTimeout } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import manifest from "./openclaw.plugin.json" with { type: "json" };
@@ -54,6 +51,10 @@ interface DeepInfraModelEntry {
metadata: DeepInfraModelMetadata | null;
}
interface DeepInfraModelsResponse {
data?: DeepInfraModelEntry[];
}
function parseModality(metadata: DeepInfraModelMetadata): Array<"text" | "image"> {
return metadata.tags?.includes("vision") ? ["text", "image"] : ["text"];
}
@@ -99,17 +100,6 @@ function staticCatalog(): ModelDefinitionConfig[] {
return DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition);
}
function asDeepInfraModelEntry(value: unknown): DeepInfraModelEntry {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("DeepInfra model list: malformed JSON response");
}
const entry = value as Partial<DeepInfraModelEntry>;
if (typeof entry.id !== "string") {
throw new Error("DeepInfra model list: malformed JSON response");
}
return value as DeepInfraModelEntry;
}
export async function discoverDeepInfraModels(): Promise<ModelDefinitionConfig[]> {
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
return staticCatalog();
@@ -132,16 +122,15 @@ export async function discoverDeepInfraModels(): Promise<ModelDefinitionConfig[]
return staticCatalog();
}
const data = await readProviderJsonArrayFieldResponse(response, "DeepInfra model list", "data");
if (data.length === 0) {
const body = (await response.json()) as DeepInfraModelsResponse;
if (!Array.isArray(body.data) || body.data.length === 0) {
log.warn("No models found from DeepInfra API, using static catalog");
return staticCatalog();
}
const seen = new Set<string>();
const models: ModelDefinitionConfig[] = [];
for (const rawEntry of data) {
const entry = asDeepInfraModelEntry(rawEntry);
for (const entry of body.data) {
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
if (!id || seen.has(id) || !entry.metadata) {
continue;

View File

@@ -1,100 +0,0 @@
import { Effect } from "openclaw/plugin-sdk/effect-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __testing } from "./ddg-client.js";
type DuckDuckGoSearchRuntimeOverrides = NonNullable<
Parameters<typeof __testing.duckDuckGoSearchRuntimeLayer>[0]
>;
type DuckDuckGoRunEndpoint = NonNullable<DuckDuckGoSearchRuntimeOverrides["runEndpoint"]>;
function resultHtml(params: { title: string; url: string; snippet: string }) {
return `
<a class="result__a" href="${params.url}">${params.title}</a>
<a class="result__snippet">${params.snippet}</a>
`;
}
describe("duckduckgo effect runtime", () => {
beforeEach(() => {
__testing.DDG_SEARCH_CACHE.clear();
});
it("runs the search through an injectable Effect runtime and caches payloads", async () => {
const cache: NonNullable<DuckDuckGoSearchRuntimeOverrides["cache"]> = new Map();
const now = vi.fn().mockReturnValueOnce(1_000).mockReturnValueOnce(1_037);
const runEndpointMock = vi.fn();
const runEndpoint: DuckDuckGoRunEndpoint = async (request, run) => {
runEndpointMock(request);
expect(request.timeoutSeconds).toBe(12);
expect(new URL(request.url).searchParams.get("q")).toBe("openclaw effect");
expect(new URL(request.url).searchParams.get("kl")).toBe("us-en");
expect(new URL(request.url).searchParams.get("kp")).toBe("-2");
return await run(
new Response(
resultHtml({
title: "OpenClaw &amp; Effect",
url: "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.openclaw.ai%2F",
snippet: "Typed effects &amp; plugin runtime",
}),
{ status: 200 },
),
);
};
const runtime = __testing.duckDuckGoSearchRuntimeLayer({
cache,
now,
runEndpoint,
});
const search = {
query: "openclaw effect",
count: 3,
region: "us-en",
safeSearch: "off" as const,
timeoutSeconds: 12,
};
const first = await Effect.runPromise(
__testing.runDuckDuckGoSearchEffect(search).pipe(Effect.provide(runtime)),
);
const second = await Effect.runPromise(
__testing.runDuckDuckGoSearchEffect(search).pipe(Effect.provide(runtime)),
);
expect(runEndpointMock).toHaveBeenCalledOnce();
expect(first).toMatchObject({
count: 1,
provider: "duckduckgo",
query: "openclaw effect",
tookMs: 37,
});
expect(first.results).toEqual([
{
title: expect.stringContaining("OpenClaw & Effect"),
url: "https://docs.openclaw.ai/",
snippet: expect.stringContaining("Typed effects & plugin runtime"),
siteName: "docs.openclaw.ai",
},
]);
expect(second).toMatchObject({
cached: true,
count: 1,
provider: "duckduckgo",
});
});
it("fails through the Effect error channel for bot challenges", async () => {
const runtime = __testing.duckDuckGoSearchRuntimeLayer({
cache: new Map(),
runEndpoint: async (_request, run) =>
await run(new Response('<form id="challenge-form">Are you a human?</form>')),
});
await expect(
Effect.runPromise(
__testing
.runDuckDuckGoSearchEffect({ query: "openclaw" })
.pipe(Effect.provide(runtime)),
),
).rejects.toThrow("DuckDuckGo returned a bot-detection challenge.");
});
});

View File

@@ -1,5 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { Context, Effect, Layer } from "openclaw/plugin-sdk/effect-runtime";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_SEARCH_COUNT,
@@ -29,43 +28,6 @@ const DDG_SEARCH_CACHE = new Map<
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
>();
type DuckDuckGoEndpointRequest = {
url: string;
timeoutSeconds: number;
init: RequestInit;
signal?: AbortSignal;
};
type DuckDuckGoSearchRuntime = {
cache: typeof DDG_SEARCH_CACHE;
now: () => number;
runEndpoint: <T>(
request: DuckDuckGoEndpointRequest,
run: (response: Response) => Promise<T>,
) => Promise<T>;
};
const DuckDuckGoSearchRuntimeTag = Context.GenericTag<DuckDuckGoSearchRuntime>(
"openclaw/duckduckgo/SearchRuntime",
);
function createDefaultDuckDuckGoSearchRuntime(): DuckDuckGoSearchRuntime {
return {
cache: DDG_SEARCH_CACHE,
now: Date.now,
runEndpoint: withTrustedWebSearchEndpoint,
};
}
function duckDuckGoSearchRuntimeLayer(
runtime?: Partial<DuckDuckGoSearchRuntime>,
): Layer.Layer<DuckDuckGoSearchRuntime> {
return Layer.succeed(DuckDuckGoSearchRuntimeTag, {
...createDefaultDuckDuckGoSearchRuntime(),
...runtime,
});
}
type DuckDuckGoResult = {
title: string;
url: string;
@@ -159,116 +121,92 @@ export async function runDuckDuckGoSearch(params: {
timeoutSeconds?: number;
cacheTtlMinutes?: number;
}): Promise<Record<string, unknown>> {
return await Effect.runPromise(
runDuckDuckGoSearchEffect(params).pipe(Effect.provide(duckDuckGoSearchRuntimeLayer())),
);
}
function runDuckDuckGoSearchEffect(params: {
config?: OpenClawConfig;
query: string;
count?: number;
region?: string;
safeSearch?: DdgSafeSearch;
timeoutSeconds?: number;
cacheTtlMinutes?: number;
}): Effect.Effect<Record<string, unknown>, unknown, DuckDuckGoSearchRuntime> {
return Effect.flatMap(DuckDuckGoSearchRuntimeTag, (runtime) =>
Effect.tryPromise({
try: async () => {
const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT);
const region = params.region ?? resolveDdgRegion(params.config);
const safeSearch =
params.safeSearch === "strict" ||
params.safeSearch === "moderate" ||
params.safeSearch === "off"
? params.safeSearch
: resolveDdgSafeSearch(params.config);
const timeoutSeconds = resolveTimeoutSeconds(params.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS);
const cacheTtlMs = resolveCacheTtlMs(params.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES);
const cacheKey = normalizeCacheKey(
JSON.stringify({
provider: "duckduckgo",
query: params.query,
count,
region: region ?? "",
safeSearch,
}),
);
const cached = readCache(runtime.cache, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const url = new URL(DDG_HTML_ENDPOINT);
url.searchParams.set("q", params.query);
if (region) {
url.searchParams.set("kl", region);
}
url.searchParams.set("kp", DDG_SAFE_SEARCH_PARAM[safeSearch]);
const startedAt = runtime.now();
const results = await runtime.runEndpoint(
{
url: url.toString(),
timeoutSeconds,
init: {
method: "GET",
headers: {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
},
},
},
async (response) => {
if (!response.ok) {
const detail = (await readResponseText(response, { maxBytes: 64_000 })).text;
throw new Error(
`DuckDuckGo search error (${response.status}): ${detail || response.statusText}`,
);
}
const html = await response.text();
if (isBotChallenge(html)) {
throw new Error("DuckDuckGo returned a bot-detection challenge.");
}
return parseDuckDuckGoHtml(html).slice(0, count);
},
);
const payload = {
query: params.query,
provider: "duckduckgo",
count: results.length,
tookMs: runtime.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "duckduckgo",
wrapped: true,
},
results: results.map((result) => ({
title: wrapWebContent(result.title, "web_search"),
url: result.url,
snippet: result.snippet ? wrapWebContent(result.snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
})),
} satisfies Record<string, unknown>;
writeCache(runtime.cache, cacheKey, payload, cacheTtlMs);
return payload;
},
catch: (error) => error,
const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT);
const region = params.region ?? resolveDdgRegion(params.config);
const safeSearch =
params.safeSearch === "strict" ||
params.safeSearch === "moderate" ||
params.safeSearch === "off"
? params.safeSearch
: resolveDdgSafeSearch(params.config);
const timeoutSeconds = resolveTimeoutSeconds(params.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS);
const cacheTtlMs = resolveCacheTtlMs(params.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES);
const cacheKey = normalizeCacheKey(
JSON.stringify({
provider: "duckduckgo",
query: params.query,
count,
region: region ?? "",
safeSearch,
}),
);
const cached = readCache(DDG_SEARCH_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const url = new URL(DDG_HTML_ENDPOINT);
url.searchParams.set("q", params.query);
if (region) {
url.searchParams.set("kl", region);
}
url.searchParams.set("kp", DDG_SAFE_SEARCH_PARAM[safeSearch]);
const startedAt = Date.now();
const results = await withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds,
init: {
method: "GET",
headers: {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
},
},
},
async (response) => {
if (!response.ok) {
const detail = (await readResponseText(response, { maxBytes: 64_000 })).text;
throw new Error(
`DuckDuckGo search error (${response.status}): ${detail || response.statusText}`,
);
}
const html = await response.text();
if (isBotChallenge(html)) {
throw new Error("DuckDuckGo returned a bot-detection challenge.");
}
return parseDuckDuckGoHtml(html).slice(0, count);
},
);
const payload = {
query: params.query,
provider: "duckduckgo",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "duckduckgo",
wrapped: true,
},
results: results.map((result) => ({
title: wrapWebContent(result.title, "web_search"),
url: result.url,
snippet: result.snippet ? wrapWebContent(result.snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
})),
} satisfies Record<string, unknown>;
writeCache(DDG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
return payload;
}
export const __testing = {
DDG_SEARCH_CACHE,
decodeDuckDuckGoUrl,
decodeHtmlEntities,
duckDuckGoSearchRuntimeLayer,
isBotChallenge,
parseDuckDuckGoHtml,
runDuckDuckGoSearchEffect,
};

View File

@@ -60,36 +60,4 @@ describe("elevenLabsMediaUnderstandingProvider", () => {
expect(form.get("language_code")).toBe("en");
expect(form.get("file")).toBeInstanceOf(Blob);
});
it("wraps malformed successful speech-to-text JSON with a stable provider error", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response("{ nope"));
await expect(
transcribeElevenLabsAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
mime: "audio/mpeg",
apiKey: "eleven-key",
model: "scribe_v2",
timeoutMs: 1000,
fetchFn: fetchMock,
}),
).rejects.toThrow("ElevenLabs audio transcription failed: malformed JSON response");
});
it("rejects non-object successful speech-to-text JSON with a stable provider error", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify([])));
await expect(
transcribeElevenLabsAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
mime: "audio/mpeg",
apiKey: "eleven-key",
model: "scribe_v2",
timeoutMs: 1000,
fetchFn: fetchMock,
}),
).rejects.toThrow("ElevenLabs audio transcription failed: malformed JSON response");
});
});

View File

@@ -7,7 +7,6 @@ import {
assertOkOrThrowHttpError,
buildAudioTranscriptionFormData,
postTranscriptionRequest,
readProviderJsonObjectResponse,
resolveProviderHttpRequestConfig,
requireTranscriptionText,
} from "openclaw/plugin-sdk/provider-http";
@@ -62,12 +61,9 @@ export async function transcribeElevenLabsAudio(
try {
await assertOkOrThrowHttpError(response, "ElevenLabs audio transcription failed");
const payload = await readProviderJsonObjectResponse(
response,
"ElevenLabs audio transcription failed",
);
const payload = (await response.json()) as { text?: string };
const text = requireTranscriptionText(
typeof payload.text === "string" ? payload.text : undefined,
payload.text,
"ElevenLabs audio transcription response missing text",
);
return { text, model };

View File

@@ -112,25 +112,6 @@ describe("elevenlabs tts diagnostics", () => {
expect(getHeadersFromFirstFetchCall(fetchMock).get("accept")).toBe("audio/mpeg");
});
it("rejects JSON success bodies as malformed audio", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: "not audio" }), {
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow("ElevenLabs API error: malformed audio response");
});
it("rejects empty successful audio bodies as malformed audio", async () => {
const fetchMock = vi.fn(async () => new Response(new Uint8Array()));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow("ElevenLabs API error: malformed audio response");
});
it("omits the MPEG Accept header for PCM telephony output", async () => {
const fetchMock = vi.fn(async () => new Response(Buffer.from("pcm")));
globalThis.fetch = fetchMock as unknown as typeof fetch;
@@ -195,18 +176,4 @@ describe("elevenlabs tts diagnostics", () => {
expect(result.audioStream).toBeInstanceOf(ReadableStream);
await result.release();
});
it("rejects JSON success stream responses as malformed audio", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: "not audio" }), {
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(elevenLabsTTSStream(createDefaultTtsRequest())).rejects.toThrow(
"ElevenLabs API error: malformed audio response",
);
});
});

View File

@@ -1,8 +1,4 @@
import {
assertOkOrThrowProviderError,
assertProviderBinaryResponseContent,
readProviderBinaryResponse,
} from "openclaw/plugin-sdk/provider-http";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
normalizeApplyTextNormalization,
normalizeLanguageCode,
@@ -147,7 +143,7 @@ export async function elevenLabsTTS(params: ElevenLabsTtsRequestParams): Promise
try {
await assertOkOrThrowProviderError(response, "ElevenLabs API error");
return Buffer.from(await readProviderBinaryResponse(response, "ElevenLabs API error", "audio"));
return Buffer.from(await response.arrayBuffer());
} finally {
await release();
}
@@ -181,7 +177,6 @@ export async function elevenLabsTTSStream(params: ElevenLabsTtsRequestParams): P
let handedOff = false;
try {
await assertOkOrThrowProviderError(response, "ElevenLabs API error");
assertProviderBinaryResponseContent(response, "ElevenLabs API error", "audio");
if (!response.body) {
throw new Error("ElevenLabs API response missing audio stream");
}

View File

@@ -17,7 +17,6 @@ type StreamingSessionState = {
messageId: string;
sequence: number;
currentText: string;
sentText: string;
hasNote: boolean;
};
@@ -27,7 +26,7 @@ function setStreamingSessionInternals(
state: StreamingSessionState;
lastUpdateTime?: number;
},
): void {
) {
const internals = session as unknown as {
state: StreamingSessionState;
lastUpdateTime: number;
@@ -53,18 +52,10 @@ describe("FeishuStreamingSession", () => {
vi.useRealTimers();
});
function mockFetches(
updateBodies: string[],
failedContentUpdateIndexes: ReadonlySet<number> = new Set<number>(),
replaceBodies: string[] = [],
failedContentUpdateStatuses: ReadonlyMap<number, number> = new Map<number, number>(),
failedReplaceStatuses: ReadonlyMap<number, number> = new Map<number, number>(),
): void {
function mockFetches(updateBodies: string[]) {
fetchWithSsrFGuardMock.mockImplementation(
async ({ url, init }: { url: string; init?: { body?: string } }) => {
const release = vi.fn(async () => {});
let ok = true;
let status = 200;
if (url.includes("/auth/")) {
return {
response: {
@@ -80,29 +71,11 @@ describe("FeishuStreamingSession", () => {
};
}
if (url.includes("/elements/content/content")) {
const updateIndex = updateBodies.length;
updateBodies.push(init?.body ?? "");
if (failedContentUpdateIndexes.has(updateIndex)) {
throw new Error(`content update ${updateIndex} failed`);
}
const failedStatus = failedContentUpdateStatuses.get(updateIndex);
if (failedStatus !== undefined) {
ok = false;
status = failedStatus;
}
} else if (url.includes("/elements/content")) {
const replaceIndex = replaceBodies.length;
replaceBodies.push(init?.body ?? "");
const failedStatus = failedReplaceStatuses.get(replaceIndex);
if (failedStatus !== undefined) {
ok = false;
status = failedStatus;
}
}
return {
response: {
ok,
status,
ok: true,
json: async () => ({ code: 0, msg: "ok" }),
},
release,
@@ -127,7 +100,6 @@ describe("FeishuStreamingSession", () => {
messageId: "om_1",
sequence: 1,
currentText: "hello",
sentText: "hello",
hasNote: false,
},
lastUpdateTime: 1_000,
@@ -140,7 +112,7 @@ describe("FeishuStreamingSession", () => {
expect(updateBodies).toHaveLength(1);
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
content: " small",
content: "hello small",
sequence: 2,
uuid: "s_card_1_2",
});
@@ -162,7 +134,6 @@ describe("FeishuStreamingSession", () => {
messageId: "om_2",
sequence: 1,
currentText: "hello",
sentText: "hello",
hasNote: false,
},
lastUpdateTime: 2_000,
@@ -172,176 +143,11 @@ describe("FeishuStreamingSession", () => {
expect(updateBodies).toHaveLength(1);
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
content: "!",
content: "hello!",
sequence: 2,
uuid: "s_card_2_2",
});
});
it("retries unsent suffix content after a failed delta update", async () => {
vi.useFakeTimers();
vi.setSystemTime(3_000);
const updateBodies: string[] = [];
mockFetches(updateBodies, new Set([0]));
const session = new FeishuStreamingSession({} as never, {
appId: "app_failed_delta_retry",
appSecret: "secret",
});
setStreamingSessionInternals(session, {
state: {
cardId: "card_3",
messageId: "om_3",
sequence: 1,
currentText: "hello",
sentText: "hello",
hasNote: false,
},
lastUpdateTime: 2_000,
});
await session.update("hello world");
await session.update("hello world!");
expect(updateBodies).toHaveLength(2);
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
content: " world",
sequence: 2,
uuid: "s_card_3_2",
});
expect(JSON.parse(updateBodies[1] ?? "{}")).toEqual({
content: " world!",
sequence: 3,
uuid: "s_card_3_3",
});
});
it("retries unsent suffix content after a non-OK delta update", async () => {
vi.useFakeTimers();
vi.setSystemTime(3_500);
const updateBodies: string[] = [];
mockFetches(updateBodies, new Set<number>(), [], new Map([[0, 429]]));
const session = new FeishuStreamingSession({} as never, {
appId: "app_non_ok_delta_retry",
appSecret: "secret",
});
setStreamingSessionInternals(session, {
state: {
cardId: "card_5",
messageId: "om_5",
sequence: 1,
currentText: "hello",
sentText: "hello",
hasNote: false,
},
lastUpdateTime: 2_000,
});
await session.update("hello world");
await session.update("hello world!");
expect(updateBodies).toHaveLength(2);
expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
content: " world",
sequence: 2,
uuid: "s_card_5_2",
});
expect(JSON.parse(updateBodies[1] ?? "{}")).toEqual({
content: " world!",
sequence: 3,
uuid: "s_card_5_3",
});
});
it("replaces content when final text removes transient streamed status", async () => {
vi.useFakeTimers();
vi.setSystemTime(4_000);
const updateBodies: string[] = [];
const replaceBodies: string[] = [];
mockFetches(updateBodies, new Set<number>(), replaceBodies);
const session = new FeishuStreamingSession({} as never, {
appId: "app_final_rewrite",
appSecret: "secret",
});
setStreamingSessionInternals(session, {
state: {
cardId: "card_4",
messageId: "om_4",
sequence: 1,
currentText: "🔎 Web Search\n\nfinal answer",
sentText: "🔎 Web Search\n\nfinal answer",
hasNote: false,
},
lastUpdateTime: 3_000,
});
await session.close("final answer");
expect(updateBodies).toHaveLength(0);
expect(replaceBodies).toHaveLength(1);
const replacePayload = JSON.parse(replaceBodies[0] ?? "{}") as {
element?: string;
sequence?: number;
uuid?: string;
};
expect({
...replacePayload,
element: JSON.parse(replacePayload.element ?? "{}"),
}).toEqual({
element: {
tag: "markdown",
content: "final answer",
element_id: "content",
},
sequence: 2,
uuid: "r_card_4_2",
});
});
it("logs a final replacement failure when CardKit returns non-OK", async () => {
vi.useFakeTimers();
vi.setSystemTime(4_500);
const updateBodies: string[] = [];
const replaceBodies: string[] = [];
mockFetches(
updateBodies,
new Set<number>(),
replaceBodies,
new Map<number, number>(),
new Map([[0, 500]]),
);
const log = vi.fn();
const session = new FeishuStreamingSession(
{} as never,
{
appId: "app_final_rewrite_non_ok",
appSecret: "secret",
},
log,
);
setStreamingSessionInternals(session, {
state: {
cardId: "card_6",
messageId: "om_6",
sequence: 1,
currentText: "working\n\nfinal answer",
sentText: "working\n\nfinal answer",
hasNote: false,
},
lastUpdateTime: 3_000,
});
await session.close("final answer");
expect(updateBodies).toHaveLength(0);
expect(replaceBodies).toHaveLength(1);
expect(log).toHaveBeenCalledWith(
"Final replace failed: Error: Replace card content failed with HTTP 500",
);
});
});
describe("mergeStreamingText", () => {

View File

@@ -14,7 +14,6 @@ type CardState = {
messageId: string;
sequence: number;
currentText: string;
sentText: string;
hasNote: boolean;
};
@@ -130,16 +129,6 @@ function shouldPushStreamingUpdate(previousText: string, nextText: string): bool
return nextText.length - previousText.length >= STREAMING_SIGNIFICANT_DELTA_CHARS;
}
function resolveStreamingCardAppendContent(previousText: string, nextText: string): string {
if (!nextText || nextText === previousText) {
return "";
}
if (!previousText) {
return nextText;
}
return nextText.startsWith(previousText) ? nextText.slice(previousText.length) : nextText;
}
export function mergeStreamingText(
previousText: string | undefined,
nextText: string | undefined,
@@ -216,7 +205,7 @@ export class FeishuStreamingSession {
const apiBase = resolveApiBase(this.creds.domain);
const elements: Record<string, unknown>[] = [
{ tag: "markdown", content: "", element_id: "content" },
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
];
if (options?.note) {
elements.push({ tag: "hr" });
@@ -317,90 +306,39 @@ export class FeishuStreamingSession {
messageId: sendRes.data.message_id,
sequence: 1,
currentText: "",
sentText: "",
hasNote: !!options?.note,
};
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
}
private async updateCardContent(
text: string,
onError?: (error: unknown) => void,
): Promise<boolean> {
private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise<void> {
if (!this.state) {
return false;
return;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
init: {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
"User-Agent": getFeishuUserAgent(),
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
await fetchWithSsrFGuard({
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
init: {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
"User-Agent": getFeishuUserAgent(),
},
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.update",
});
await release();
if (!response.ok) {
onError?.(new Error(`Update card content failed with HTTP ${response.status}`));
return false;
}
return true;
} catch (error) {
onError?.(error);
return false;
}
}
private async replaceCardContent(
text: string,
onError?: (error: unknown) => void,
): Promise<boolean> {
if (!this.state) {
return false;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content`,
init: {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
"User-Agent": getFeishuUserAgent(),
},
body: JSON.stringify({
element: JSON.stringify({ tag: "markdown", content: text, element_id: "content" }),
sequence: this.state.sequence,
uuid: `r_${this.state.cardId}_${this.state.sequence}`,
}),
},
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.replace",
});
await release();
if (!response.ok) {
onError?.(new Error(`Replace card content failed with HTTP ${response.status}`));
return false;
}
return true;
} catch (error) {
onError?.(error);
return false;
}
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
},
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.update",
})
.then(async ({ release }) => {
await release();
})
.catch((error) => onError?.(error));
}
private clearFlushTimer(): void {
@@ -453,18 +391,9 @@ export class FeishuStreamingSession {
if (!mergedText || mergedText === this.state.currentText) {
return;
}
const appendContent = resolveStreamingCardAppendContent(this.state.sentText, mergedText);
if (!appendContent) {
return;
}
this.pendingText = null;
this.state.currentText = mergedText;
const sent = await this.updateCardContent(appendContent, (e) =>
this.log?.(`Update failed: ${String(e)}`),
);
if (sent && this.state) {
this.state.sentText = mergedText;
}
await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`));
});
await this.queue;
}
@@ -508,23 +437,13 @@ export class FeishuStreamingSession {
await this.queue;
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
const text = finalText ?? pendingMerged;
const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
const apiBase = resolveApiBase(this.creds.domain);
// Only send final update if content differs from what's already displayed
if (text && text !== this.state.sentText) {
const sent = text.startsWith(this.state.sentText)
? await this.updateCardContent(
resolveStreamingCardAppendContent(this.state.sentText, text),
(e) => this.log?.(`Final update failed: ${String(e)}`),
)
: await this.replaceCardContent(text, (e) =>
this.log?.(`Final replace failed: ${String(e)}`),
);
if (text && text !== this.state.currentText) {
await this.updateCardContent(text);
this.state.currentText = text;
if (sent) {
this.state.sentText = text;
}
}
// Update note with final model/provider info

View File

@@ -646,24 +646,6 @@ describe("fetchCopilotModelCatalog", () => {
).rejects.toThrow(/HTTP 401/);
});
it("throws provider-owned errors for malformed successful /models payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
await expect(
fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
baseUrl: "https://api.githubcopilot.com",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow("Copilot /models: malformed JSON response");
}
});
it("rejects empty token / baseUrl synchronously before fetching", async () => {
const fetchImpl = vi.fn();

View File

@@ -3,7 +3,6 @@ import type {
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -192,13 +191,6 @@ function mapCopilotApiModelToDefinition(
return definition;
}
function asCopilotApiModelEntry(value: unknown): CopilotApiModelEntry {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Copilot /models: malformed JSON response");
}
return value as CopilotApiModelEntry;
}
export type FetchCopilotModelCatalogParams = {
/** Short-lived Copilot API token (from `resolveCopilotApiToken`). */
copilotApiToken: string;
@@ -250,11 +242,11 @@ export async function fetchCopilotModelCatalog(
if (!res.ok) {
throw new Error(`Copilot /models fetch failed: HTTP ${res.status}`);
}
const data = await readProviderJsonArrayFieldResponse(res, "Copilot /models", "data");
const json = (await res.json()) as { data?: CopilotApiModelEntry[] };
const data = Array.isArray(json?.data) ? json.data : [];
const seen = new Set<string>();
const out: ModelDefinitionConfig[] = [];
for (const rawEntry of data) {
const entry = asCopilotApiModelEntry(rawEntry);
for (const entry of data) {
const def = mapCopilotApiModelToDefinition(entry);
if (!def) {
continue;

View File

@@ -137,10 +137,10 @@ describe("Gemini embedding provider", () => {
return url.endsWith(":batchEmbedContents")
? {
embeddings: Array.from({ length: 2 }, () => ({
values: [0, 0, 5],
values: [0, Number.POSITIVE_INFINITY, 5],
})),
}
: { embedding: { values: [3, 4, 0] } };
: { embedding: { values: [3, 4, Number.NaN] } };
});
const { provider } = await createGeminiEmbeddingProvider({
@@ -213,52 +213,4 @@ describe("Gemini embedding provider", () => {
],
});
});
it("rejects non-object successful embedding responses", async () => {
installFetchMock(() => []);
const { provider } = await createGeminiEmbeddingProvider({
config: {} as never,
provider: "gemini",
remote: { apiKey: "test-key" },
model: "gemini-embedding-001",
fallback: "none",
});
await expect(provider.embedQuery("test query")).rejects.toThrow(
"gemini embeddings failed: malformed JSON response",
);
});
it("rejects wrong single embedding vector shapes", async () => {
installFetchMock(() => ({ embedding: { values: [1, "bad"] } }));
const { provider } = await createGeminiEmbeddingProvider({
config: {} as never,
provider: "gemini",
remote: { apiKey: "test-key" },
model: "gemini-embedding-001",
fallback: "none",
});
await expect(provider.embedQuery("test query")).rejects.toThrow(
"gemini embeddings failed: malformed JSON response",
);
});
it("rejects batch embedding count mismatches", async () => {
installFetchMock(() => ({ embeddings: [{ values: [1, 2] }] }));
const { provider } = await createGeminiEmbeddingProvider({
config: {} as never,
provider: "gemini",
remote: { apiKey: "test-key" },
model: "gemini-embedding-001",
fallback: "none",
});
await expect(provider.embedBatch(["one", "two"])).rejects.toThrow(
"gemini embeddings failed: malformed JSON response",
);
});
});

View File

@@ -17,7 +17,6 @@ import {
import {
createProviderHttpError,
providerOperationRetryConfig,
readProviderJsonObjectResponse,
} from "openclaw/plugin-sdk/provider-http";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -91,52 +90,6 @@ type GeminiEmbeddingRequest = {
};
export type GeminiTextEmbeddingRequest = GeminiEmbeddingRequest;
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function malformedGeminiEmbeddingResponse(): Error {
return new Error("gemini embeddings failed: malformed JSON response");
}
function readGeminiEmbeddingValues(value: unknown): number[] {
if (!Array.isArray(value)) {
throw malformedGeminiEmbeddingResponse();
}
for (const entry of value) {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw malformedGeminiEmbeddingResponse();
}
}
return value;
}
function readGeminiSingleEmbedding(payload: Record<string, unknown>): number[] {
const embedding = asRecord(payload.embedding);
if (!embedding) {
throw malformedGeminiEmbeddingResponse();
}
return readGeminiEmbeddingValues(embedding.values);
}
function readGeminiBatchEmbeddings(
payload: Record<string, unknown>,
expectedCount: number,
): number[][] {
if (!Array.isArray(payload.embeddings) || payload.embeddings.length !== expectedCount) {
throw malformedGeminiEmbeddingResponse();
}
return payload.embeddings.map((entry) => {
const embedding = asRecord(entry);
if (!embedding) {
throw malformedGeminiEmbeddingResponse();
}
return readGeminiEmbeddingValues(embedding.values);
});
}
/** Builds the text-only Gemini embedding request shape used across direct and batch APIs. */
export function buildGeminiTextEmbeddingRequest(params: {
text: string;
@@ -242,7 +195,10 @@ async function fetchGeminiEmbeddingPayload(params: {
client: GeminiEmbeddingClient;
endpoint: string;
body: unknown;
}): Promise<Record<string, unknown>> {
}): Promise<{
embedding?: { values?: number[] };
embeddings?: Array<{ values?: number[] }>;
}> {
return await executeWithApiKeyRotation({
provider: "google",
apiKeys: params.client.apiKeys,
@@ -265,7 +221,10 @@ async function fetchGeminiEmbeddingPayload(params: {
if (!res.ok) {
throw await createProviderHttpError(res, "gemini embeddings failed");
}
return await readProviderJsonObjectResponse(res, "gemini embeddings failed");
return (await res.json()) as {
embedding?: { values?: number[] };
embeddings?: Array<{ values?: number[] }>;
};
},
});
},
@@ -329,7 +288,7 @@ export async function createGeminiEmbeddingProvider(
outputDimensionality: isV2 ? outputDimensionality : undefined,
}),
});
return sanitizeAndNormalizeEmbedding(readGeminiSingleEmbedding(payload));
return sanitizeAndNormalizeEmbedding(payload.embedding?.values ?? []);
};
const embedBatchInputs = async (inputs: EmbeddingInput[]): Promise<number[][]> => {
@@ -350,8 +309,8 @@ export async function createGeminiEmbeddingProvider(
),
},
});
const embeddings = readGeminiBatchEmbeddings(payload, inputs.length);
return embeddings.map((values) => sanitizeAndNormalizeEmbedding(values));
const embeddings = Array.isArray(payload.embeddings) ? payload.embeddings : [];
return inputs.map((_, index) => sanitizeAndNormalizeEmbedding(embeddings[index]?.values ?? []));
};
const embedBatch = async (texts: string[]): Promise<number[][]> => {

View File

@@ -830,23 +830,6 @@ describe("google transport stream", () => {
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
});
it("does not send thinkingConfig when the resolved Google model disables reasoning", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({
id: "gemma-4-26b-a4b-it",
reasoning: false,
}),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
reasoning: "medium",
},
);
expect(params.generationConfig ?? {}).not.toHaveProperty("thinkingConfig");
});
it("omits disabled thinkingBudget=0 for Gemini 2.5 Pro direct payloads", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),

View File

@@ -226,19 +226,6 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
});
it("falls back to static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
});
}
});
it("ensures kilo/auto is present even when API doesn't return it", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,

View File

@@ -1,4 +1,3 @@
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
@@ -71,6 +70,10 @@ interface GatewayModelEntry {
supported_parameters?: string[];
}
interface GatewayModelsResponse {
data: GatewayModelEntry[];
}
function toPricePerMillion(perToken: string | undefined): number {
if (!perToken) {
return 0;
@@ -130,30 +133,6 @@ function buildStaticCatalog(): ModelDefinitionConfig[] {
}));
}
function asGatewayModelEntry(value: unknown): GatewayModelEntry {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Kilocode model list: malformed JSON response");
}
const entry = value as Partial<GatewayModelEntry>;
if (
typeof entry.id !== "string" ||
typeof entry.pricing !== "object" ||
entry.pricing === null ||
Array.isArray(entry.pricing)
) {
throw new Error("Kilocode model list: malformed JSON response");
}
return value as GatewayModelEntry;
}
function readGatewayModelId(value: unknown): string {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return "";
}
const id = (value as Partial<GatewayModelEntry>).id;
return typeof id === "string" ? id.trim() : "";
}
export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]> {
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
return buildStaticCatalog();
@@ -175,12 +154,8 @@ export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]>
return buildStaticCatalog();
}
const data = await readProviderJsonArrayFieldResponse(
response,
"Kilocode model list",
"data",
);
if (data.length === 0) {
const data = (await response.json()) as GatewayModelsResponse;
if (!Array.isArray(data.data) || data.data.length === 0) {
log.warn("No models found from gateway API, using static catalog");
return buildStaticCatalog();
}
@@ -188,13 +163,15 @@ export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]>
const models: ModelDefinitionConfig[] = [];
const discoveredIds = new Set<string>();
for (const rawEntry of data) {
const id = readGatewayModelId(rawEntry);
for (const entry of data.data) {
if (!entry || typeof entry !== "object") {
continue;
}
const id = typeof entry.id === "string" ? entry.id.trim() : "";
if (!id || discoveredIds.has(id)) {
continue;
}
try {
const entry = asGatewayModelEntry(rawEntry);
if (!id || discoveredIds.has(id)) {
continue;
}
models.push(toModelDefinition(entry));
discoveredIds.add(id);
} catch (e) {

View File

@@ -1,5 +1,4 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -26,6 +25,10 @@ type FetchLmstudioModelsResult = {
error?: unknown;
};
type LmstudioModelsResponseWire = {
models?: LmstudioModelWire[];
};
type DiscoverLmstudioModelsParams = {
baseUrl: string;
apiKey: string;
@@ -63,13 +66,6 @@ async function fetchLmstudioEndpoint(params: {
};
}
function asLmstudioModelWire(value: unknown): LmstudioModelWire {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("LM Studio model list: malformed JSON response");
}
return value as LmstudioModelWire;
}
/** Fetches /api/v1/models and reports transport reachability separately from HTTP status. */
export async function fetchLmstudioModels(params: {
baseUrl?: string;
@@ -104,15 +100,17 @@ export async function fetchLmstudioModels(params: {
models: [],
};
}
const models = await readProviderJsonArrayFieldResponse(
response,
"LM Studio model list",
"models",
);
let payload: LmstudioModelsResponseWire;
try {
// External service payload is untrusted JSON; parse with a permissive wire type.
payload = (await response.json()) as LmstudioModelsResponseWire;
} catch (cause) {
throw new Error("LM Studio model list returned malformed JSON", { cause });
}
return {
reachable: true,
status: response.status,
models: models.map(asLmstudioModelWire),
models: Array.isArray(payload.models) ? payload.models : [],
};
} finally {
await release();

View File

@@ -313,25 +313,7 @@ describe("lmstudio-models", () => {
});
expect(result.reachable).toBe(false);
expect((result.error as Error).message).toBe("LM Studio model list: malformed JSON response");
});
it("reports wrong-shaped model list payloads with owned errors", async () => {
for (const payload of [[], { models: {} }, { models: [null] }]) {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => payload,
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
fetchImpl: asFetch(fetchMock),
});
expect(result.reachable).toBe(false);
expect((result.error as Error).message).toBe("LM Studio model list: malformed JSON response");
}
expect((result.error as Error).message).toBe("LM Studio model list returned malformed JSON");
});
it("skips model load when already loaded", async () => {

View File

@@ -12,7 +12,6 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type OutputRuntimeEnv,
formatUnifiedDiff,
pathEmitCommand,
pathFindCommand,
pathResolveCommand,
@@ -153,92 +152,6 @@ describe("openclaw path CLI", () => {
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S05 --dry-run --diff prints a unified diff", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
const before = '{\n "version": "1.0",\n "enabled": true\n}\n';
writeFileSync(filePath, before, "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://gateway.jsonc/version",
"2.0",
{ cwd: workspaceDir, human: true, dryRun: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(0);
const out = stdoutText(rt);
expect(out).toContain("--- ");
expect(out).toContain("+++ ");
expect(out).toContain('- "version": "1.0",');
expect(out).toContain('+ "version": "2.0",');
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S05b --dry-run --diff shows final newline-only byte changes", () => {
const out = formatUnifiedDiff(
"## Boundaries\n\n- timeout: 5\n",
"## Boundaries\n\n- timeout: 5",
"AGENTS.md",
);
expect(out).toContain("--- AGENTS.md");
expect(out).toContain("@@ -1,4 +1,3 @@");
expect(out).toContain("\n-\n");
});
it("CLI-S05c --dry-run --diff shows line-ending-only byte changes", async () => {
const filePath = join(workspaceDir, "AGENTS.md");
const before = "---\r\nname: x\r\n---\r\n";
writeFileSync(filePath, before, "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://AGENTS.md/[frontmatter]/name",
"x",
{ cwd: workspaceDir, json: true, dryRun: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(0);
const out = JSON.parse(stdoutText(rt));
expect(out.diff).toContain("-name: x\r");
expect(out.diff).toContain("+name: x");
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S06 --dry-run --diff includes diff in JSON output", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
writeFileSync(filePath, '{ "version": "1.0" }', "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://gateway.jsonc/version",
"2.0",
{ cwd: workspaceDir, json: true, dryRun: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(0);
const out = JSON.parse(stdoutText(rt));
expect(out.dryRun).toBe(true);
expect(out.bytes).toContain('"2.0"');
expect(out.diff).toContain('-{ "version": "1.0" }');
expect(out.diff).toContain('+{ "version": "2.0" }');
});
it("CLI-S07 rejects --diff without --dry-run", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
const before = '{ "version": "1.0" }';
writeFileSync(filePath, before, "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://gateway.jsonc/version",
"2.0",
{ cwd: workspaceDir, json: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(1);
expect(JSON.parse(stdoutText(rt))).toMatchObject({
ok: false,
reason: "--diff requires --dry-run",
});
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S03 sentinel-bearing value is refused at emit", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
writeFileSync(filePath, '{ "token": "x" }', "utf-8");

View File

@@ -42,7 +42,6 @@ export interface PathCommandOptions {
readonly cwd?: string;
readonly file?: string;
readonly dryRun?: boolean;
readonly diff?: boolean;
}
type OutputMode = "human" | "json";
@@ -64,19 +63,13 @@ const defaultRuntime: OutputRuntimeEnv = {
// Defense-in-depth: replace the redaction sentinel with `[REDACTED]`
// before writing, even if upstream emits it.
export function scrubSentinel(s: string): string {
if (!s.includes(REDACTED_SENTINEL)) {
return s;
}
if (!s.includes(REDACTED_SENTINEL)) {return s;}
return s.split(REDACTED_SENTINEL).join(SCRUB_PLACEHOLDER);
}
function detectMode(options: PathCommandOptions): OutputMode {
if (options.json === true) {
return "json";
}
if (options.human === true) {
return "human";
}
if (options.json === true) {return "json";}
if (options.human === true) {return "human";}
return process.stdout.isTTY ? "human" : "json";
}
@@ -123,7 +116,11 @@ function requireArg<T>(
}
/** Parse an oc-path string; emit structured error and return null on failure. */
function tryParse(pathStr: string, runtime: OutputRuntimeEnv, mode: OutputMode): OcPath | null {
function tryParse(
pathStr: string,
runtime: OutputRuntimeEnv,
mode: OutputMode,
): OcPath | null {
try {
return parseOcPath(pathStr);
} catch (err) {
@@ -160,12 +157,8 @@ function catchSentinel<T>(
async function loadAst(absPath: string, fileName: string): Promise<OcAst> {
const raw = await fs.readFile(absPath, "utf-8");
const kind = inferKind(fileName);
if (kind === "jsonc") {
return parseJsonc(raw).ast;
}
if (kind === "jsonl") {
return parseJsonl(raw).ast;
}
if (kind === "jsonc") {return parseJsonc(raw).ast;}
if (kind === "jsonl") {return parseJsonl(raw).ast;}
return parseMd(raw).ast;
}
@@ -184,9 +177,7 @@ function emitForKind(ast: OcAst, fileName?: string): string {
}
function resolveFsPath(path: OcPath, options: PathCommandOptions): string {
if (options.file !== undefined) {
return resolvePath(options.file);
}
if (options.file !== undefined) {return resolvePath(options.file);}
return resolvePath(options.cwd ?? process.cwd(), path.file);
}
@@ -194,72 +185,13 @@ function formatMatchHuman(match: OcMatch): string {
if (match.kind === "leaf") {
return `leaf @ L${match.line}: ${JSON.stringify(match.valueText)} (${match.leafType})`;
}
if (match.kind === "node") {
return `node @ L${match.line} [${match.descriptor}]`;
}
if (match.kind === "node") {return `node @ L${match.line} [${match.descriptor}]`;}
if (match.kind === "insertion-point") {
return `insertion-point @ L${match.line} [${match.container}]`;
}
return `root @ L${match.line}`;
}
function splitDiffLines(s: string): readonly string[] {
return s === "" ? [] : s.split("\n");
}
export function formatUnifiedDiff(oldBytes: string, newBytes: string, fsPath: string): string {
if (oldBytes === newBytes) {
return "";
}
const oldLines = splitDiffLines(oldBytes);
const newLines = splitDiffLines(newBytes);
let prefix = 0;
while (
prefix < oldLines.length &&
prefix < newLines.length &&
oldLines[prefix] === newLines[prefix]
) {
prefix++;
}
let oldSuffix = oldLines.length - 1;
let newSuffix = newLines.length - 1;
while (
oldSuffix >= prefix &&
newSuffix >= prefix &&
oldLines[oldSuffix] === newLines[newSuffix]
) {
oldSuffix--;
newSuffix--;
}
const context = 3;
const hunkStart = Math.max(0, prefix - context);
const hunkOldEnd = Math.min(oldLines.length - 1, oldSuffix + context);
const hunkNewEnd = Math.min(newLines.length - 1, newSuffix + context);
const oldCount = Math.max(0, hunkOldEnd - hunkStart + 1);
const newCount = Math.max(0, hunkNewEnd - hunkStart + 1);
const lines = [
`--- ${fsPath}`,
`+++ ${fsPath}`,
`@@ -${hunkStart + 1},${oldCount} +${hunkStart + 1},${newCount} @@`,
];
for (let i = hunkStart; i < prefix; i++) {
lines.push(` ${oldLines[i] ?? ""}`);
}
for (let i = prefix; i <= oldSuffix; i++) {
lines.push(`-${oldLines[i] ?? ""}`);
}
for (let i = prefix; i <= newSuffix; i++) {
lines.push(`+${newLines[i] ?? ""}`);
}
for (let i = Math.max(oldSuffix + 1, prefix); i <= hunkOldEnd; i++) {
lines.push(` ${oldLines[i] ?? ""}`);
}
return `${lines.join("\n")}\n`;
}
// ---------- Commands -----------------------------------------------------
export async function pathResolveCommand(
@@ -268,13 +200,9 @@ export async function pathResolveCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(pathStr, "resolve: missing <oc-path> argument", runtime, mode)) {
return;
}
if (!requireArg(pathStr, "resolve: missing <oc-path> argument", runtime, mode)) {return;}
const ocPath = tryParse(pathStr, runtime, mode);
if (ocPath === null) {
return;
}
if (ocPath === null) {return;}
const ast = await loadAst(resolveFsPath(ocPath, options), ocPath.file);
let match: OcMatch | null;
try {
@@ -303,34 +231,15 @@ export async function pathSetCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(pathStr, "set: requires <oc-path> <value>", runtime, mode)) {
return;
}
if (!requireArg(value, "set: requires <oc-path> <value>", runtime, mode)) {
return;
}
if (options.diff === true && options.dryRun !== true) {
emit(
runtime,
mode,
{ ok: false, reason: "--diff requires --dry-run" },
() => "set failed: --diff requires --dry-run",
);
runtime.exit(1);
return;
}
if (!requireArg(pathStr, "set: requires <oc-path> <value>", runtime, mode)) {return;}
if (!requireArg(value, "set: requires <oc-path> <value>", runtime, mode)) {return;}
const ocPath = tryParse(pathStr, runtime, mode);
if (ocPath === null) {
return;
}
if (ocPath === null) {return;}
const fsPath = resolveFsPath(ocPath, options);
const oldBytes = await fs.readFile(fsPath, "utf-8");
const ast = await loadAst(fsPath, ocPath.file);
const result = catchSentinel("set", runtime, mode, () => setOcPath(ast, ocPath, value));
if (result === null) {
return;
}
if (result === null) {return;}
if (!result.ok) {
const detail = "detail" in result ? result.detail : undefined;
emit(
@@ -343,21 +252,17 @@ export async function pathSetCommand(
return;
}
// Per-kind emit can still refuse the sentinel even after set succeeds.
const newBytes = catchSentinel("emit", runtime, mode, () => emitForKind(result.ast, ocPath.file));
if (newBytes === null) {
return;
}
const newBytes = catchSentinel("emit", runtime, mode, () =>
emitForKind(result.ast, ocPath.file),
);
if (newBytes === null) {return;}
if (options.dryRun === true) {
const diff = options.diff === true ? formatUnifiedDiff(oldBytes, newBytes, fsPath) : undefined;
emit(
runtime,
mode,
{ ok: true, dryRun: true, bytes: newBytes, ...(diff !== undefined ? { diff } : {}) },
() =>
diff !== undefined
? diff || `--dry-run: no byte changes for ${fsPath}`
: `--dry-run: would write ${newBytes.length} bytes to ${fsPath}\n${newBytes}`,
{ ok: true, dryRun: true, bytes: newBytes },
() => `--dry-run: would write ${newBytes.length} bytes to ${fsPath}\n${newBytes}`,
);
return;
}
@@ -376,13 +281,9 @@ export async function pathFindCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(patternStr, "find: missing <pattern> argument", runtime, mode)) {
return;
}
if (!requireArg(patternStr, "find: missing <pattern> argument", runtime, mode)) {return;}
const pattern = tryParse(patternStr, runtime, mode);
if (pattern === null) {
return;
}
if (pattern === null) {return;}
// File-slot wildcards would silently ENOENT during readFile; reject.
if (/[*?]/.test(pattern.file)) {
emitError(
@@ -406,9 +307,7 @@ export async function pathFindCommand(
matches: matches.map((m) => ({ path: formatOcPath(m.path), match: m.match })),
},
() => {
if (matches.length === 0) {
return `0 matches for ${patternStr}`;
}
if (matches.length === 0) {return `0 matches for ${patternStr}`;}
const plural = matches.length === 1 ? "" : "es";
const lines = [`${matches.length} match${plural} for ${patternStr}:`];
for (const m of matches) {
@@ -417,9 +316,7 @@ export async function pathFindCommand(
return lines.join("\n");
},
);
if (matches.length === 0) {
runtime.exit(1);
}
if (matches.length === 0) {runtime.exit(1);}
}
export function pathValidateCommand(
@@ -428,9 +325,7 @@ export function pathValidateCommand(
runtime: OutputRuntimeEnv,
): void {
const mode = detectMode(options);
if (!requireArg(pathStr, "validate: missing <oc-path> argument", runtime, mode)) {
return;
}
if (!requireArg(pathStr, "validate: missing <oc-path> argument", runtime, mode)) {return;}
try {
const ocPath = parseOcPath(pathStr);
emit(
@@ -450,18 +345,10 @@ export function pathValidateCommand(
},
() => {
const lines = [`valid: ${pathStr}`, ` file: ${ocPath.file}`];
if (ocPath.section !== undefined) {
lines.push(` section: ${ocPath.section}`);
}
if (ocPath.item !== undefined) {
lines.push(` item: ${ocPath.item}`);
}
if (ocPath.field !== undefined) {
lines.push(` field: ${ocPath.field}`);
}
if (ocPath.session !== undefined) {
lines.push(` session: ${ocPath.session}`);
}
if (ocPath.section !== undefined) {lines.push(` section: ${ocPath.section}`);}
if (ocPath.item !== undefined) {lines.push(` item: ${ocPath.item}`);}
if (ocPath.field !== undefined) {lines.push(` field: ${ocPath.field}`);}
if (ocPath.session !== undefined) {lines.push(` session: ${ocPath.session}`);}
return lines.join("\n");
},
);
@@ -486,9 +373,7 @@ export async function pathEmitCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(fileArg, "emit: missing <file> argument", runtime, mode)) {
return;
}
if (!requireArg(fileArg, "emit: missing <file> argument", runtime, mode)) {return;}
const fsPath =
options.file !== undefined
? resolvePath(options.file)
@@ -496,9 +381,7 @@ export async function pathEmitCommand(
const fileName = fsPath.split(/[\\/]/).pop() ?? fileArg;
const ast = await loadAst(fsPath, fileName);
const bytes = catchSentinel("emit", runtime, mode, () => emitForKind(ast, fileName));
if (bytes === null) {
return;
}
if (bytes === null) {return;}
if (mode === "json") {
runtime.writeStdout(scrubSentinel(JSON.stringify({ ok: true, kind: ast.kind, bytes })));
return;
@@ -546,8 +429,7 @@ export function registerPathCli(program: Command): void {
.description("Write a leaf value at an oc:// path")
.argument("<oc-path>", "oc:// path to write")
.argument("<value>", "string value to write")
.option("--dry-run", "Print bytes without writing")
.option("--diff", "With --dry-run, print a unified diff instead of full bytes"),
.option("--dry-run", "Print bytes without writing"),
).action(async (pathStr: string, value: string, opts: PathCommandOptions) => {
await pathSetCommand(pathStr, value, opts, defaultRuntime);
});

View File

@@ -61,8 +61,7 @@
"install": {
"npmSpec": "@openclaw/slack",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.1",
"allowInvalidConfigRecovery": true
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.16"

View File

@@ -27,7 +27,6 @@ import {
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
@@ -2077,18 +2076,6 @@ export const registerTelegramHandlers = ({
logVerbose(
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${errStr}`,
);
if (isApprovalNotFoundError(resolveErr)) {
if (isPluginApproval || pluginApprovalAuthorizedSender) {
try {
await clearCallbackButtons();
} catch (editErr) {
logVerbose(
`telegram: failed to clear expired approval callback buttons: ${String(editErr)}`,
);
}
}
return;
}
throw new TelegramRetryableCallbackError(resolveErr);
}
try {

View File

@@ -866,7 +866,7 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target");
});
it("drops target-only approval not-found misses without clearing legacy fallback buttons", async () => {
it("keeps legacy plugin fallback approval failures retryable for target-only recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
@@ -898,21 +898,23 @@ describe("createTelegramBot", () => {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-legacy-plugin-fallback-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Legacy plugin approval required.",
await expect(
callbackHandler({
callbackQuery: {
id: "cbq-legacy-plugin-fallback-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Legacy plugin approval required.",
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
}),
).rejects.toThrow("unknown or expired approval id");
const approvalCall = execApprovalCall();
const execApprovals = execApprovalTargetConfig(approvalCall);
@@ -928,63 +930,6 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked");
});
it("drops expired approval callbacks for configured approvers after clearing buttons", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id"));
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-expired-approval",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 26,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
const approvalCall = execApprovalCall();
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.allowPluginFallback).toBe(true);
expect(approvalCall.senderId).toBe("9");
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-expired-approval");
});
it("keeps plugin approval callback buttons for target-only recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();

View File

@@ -10,10 +10,6 @@ import {
toPluginMessageSentEvent,
} from "openclaw/plugin-sdk/hook-runtime";
import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import {
buildOutboundMediaLoadOptions,
isGifMedia,
@@ -759,15 +755,10 @@ export async function deliverReplies(params: {
? [reply.mediaUrl]
: [];
const hasMedia = mediaList.length > 0;
const presentation = normalizeMessagePresentation(reply?.presentation);
const interactive =
reply?.interactive ??
(presentation ? presentationToInteractiveReply(presentation) : undefined);
const resolvedReplyText =
resolveTelegramInteractiveTextFallback({
text: reply?.text,
interactive,
presentation,
interactive: reply?.interactive,
}) ??
reply?.text ??
"";
@@ -829,7 +820,7 @@ export async function deliverReplies(params: {
const replyMarkup = buildInlineKeyboard(
resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive,
interactive: reply.interactive,
}),
);
let firstDeliveredMessageId: number | undefined;

View File

@@ -328,33 +328,6 @@ describe("deliverReplies", () => {
});
});
it("uses presentation button labels as fallback text for presentation-only replies", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 4, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
replies: [
{
presentation: {
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "cmd:retry" }] }],
},
},
],
runtime,
bot,
});
expect(runtime.error).not.toHaveBeenCalled();
expect(firstMockCallArg(sendMessage, 0)).toBe("123");
expect(firstMockCallArg(sendMessage, 1)).toContain("Retry");
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
reply_markup: {
inline_keyboard: [[{ text: "Retry", callback_data: "cmd:retry" }]],
},
});
});
it("reports message_sent success=false when hooks blank out a text-only reply", async () => {
messageHookRunner.hasHooks.mockImplementation(
(name: string) => name === "message_sending" || name === "message_sent",

View File

@@ -1,6 +1,5 @@
import {
interactiveReplyToPresentation,
normalizeMessagePresentation,
normalizeInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
@@ -9,7 +8,6 @@ import {
export function resolveTelegramInteractiveTextFallback(params: {
text?: string | null;
interactive?: unknown;
presentation?: unknown;
}): string | undefined {
const interactive = normalizeInteractiveReply(params.interactive);
const text = resolveInteractiveTextFallback({
@@ -19,23 +17,13 @@ export function resolveTelegramInteractiveTextFallback(params: {
if (text?.trim()) {
return text;
}
const presentation = normalizeMessagePresentation(params.presentation);
if (presentation) {
const fallback = renderMessagePresentationFallbackText({
text: params.text ?? undefined,
presentation,
});
if (fallback.trim()) {
return fallback;
}
}
if (!interactive) {
return text;
}
const interactivePresentation = interactiveReplyToPresentation(interactive);
if (!interactivePresentation) {
const presentation = interactiveReplyToPresentation(interactive);
if (!presentation) {
return text;
}
const fallback = renderMessagePresentationFallbackText({ presentation: interactivePresentation });
const fallback = renderMessagePresentationFallbackText({ presentation });
return fallback.trim() ? fallback : text;
}

View File

@@ -150,33 +150,6 @@ describe("telegramOutbound", () => {
expect(result).toEqual({ channel: "telegram", messageId: "tg-buttons", chatId: "12345" });
});
it("uses presentation button labels as fallback text for presentation-only payloads", async () => {
sendMessageTelegramMock.mockResolvedValueOnce({
messageId: "tg-presentation-buttons",
chatId: "12345",
});
const result = await telegramOutbound.sendPayload!({
cfg: {} as never,
to: "12345",
text: "",
payload: {
presentation: {
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "cmd:retry" }] }],
},
},
deps: { sendTelegram: sendMessageTelegramMock },
});
const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "- Retry");
expect(options.buttons).toEqual([[{ text: "Retry", callback_data: "cmd:retry" }]]);
expect(result).toEqual({
channel: "telegram",
messageId: "tg-presentation-buttons",
chatId: "12345",
});
});
it("renders presentation web app buttons for payload sends", async () => {
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-web-app", chatId: "12345" });
const presentation = {

View File

@@ -4,7 +4,6 @@ import {
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import {
normalizeMessagePresentation,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
@@ -117,20 +116,15 @@ export async function sendTelegramPayloadMessages(params: {
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const presentation = normalizeMessagePresentation(params.payload.presentation);
const interactive =
params.payload.interactive ??
(presentation ? presentationToInteractiveReply(presentation) : undefined);
const text =
resolveTelegramInteractiveTextFallback({
text: params.payload.text,
interactive,
presentation,
interactive: params.payload.interactive,
}) ?? "";
const mediaUrls = resolvePayloadMediaUrls(params.payload);
const buttons = resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive,
interactive: params.payload.interactive,
});
const payloadOpts = {
...params.baseOpts,

View File

@@ -1,4 +1,3 @@
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -34,6 +33,10 @@ type VercelGatewayModelShape = {
pricing?: VercelPricingShape;
};
type VercelGatewayModelsResponse = {
data?: VercelGatewayModelShape[];
};
type StaticVercelGatewayModel = Omit<ModelDefinitionConfig, "cost"> & {
cost?: Partial<ModelDefinitionConfig["cost"]>;
};
@@ -183,13 +186,6 @@ function buildDiscoveredModelDefinition(
};
}
function asVercelGatewayModelShape(value: unknown): VercelGatewayModelShape {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Vercel AI Gateway model list: malformed JSON response");
}
return value as VercelGatewayModelShape;
}
export async function discoverVercelAiGatewayModels(): Promise<ModelDefinitionConfig[]> {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return getStaticVercelAiGatewayModelCatalog();
@@ -206,13 +202,8 @@ export async function discoverVercelAiGatewayModels(): Promise<ModelDefinitionCo
log.warn(`Failed to discover Vercel AI Gateway models: HTTP ${response.status}`);
return getStaticVercelAiGatewayModelCatalog();
}
const data = await readProviderJsonArrayFieldResponse(
response,
"Vercel AI Gateway model list",
"data",
);
const discovered = data
.map(asVercelGatewayModelShape)
const data = (await response.json()) as VercelGatewayModelsResponse;
const discovered = (data.data ?? [])
.map(buildDiscoveredModelDefinition)
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog();

View File

@@ -1,18 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import {
discoverVercelAiGatewayModels,
getStaticVercelAiGatewayModelCatalog,
VERCEL_AI_GATEWAY_BASE_URL,
} from "./api.js";
import { describe, expect, it } from "vitest";
import { getStaticVercelAiGatewayModelCatalog, VERCEL_AI_GATEWAY_BASE_URL } from "./api.js";
import {
buildStaticVercelAiGatewayProvider,
buildVercelAiGatewayProvider,
@@ -25,31 +12,6 @@ const STATIC_MODEL_IDS = [
"moonshotai/kimi-k2.6",
];
function restoreEnvVar(name: "NODE_ENV" | "VITEST", value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
async function withLiveDiscovery<T>(run: () => Promise<T>): Promise<T> {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
try {
return await run();
} finally {
restoreEnvVar("NODE_ENV", oldNodeEnv);
restoreEnvVar("VITEST", oldVitest);
}
}
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
describe("vercel ai gateway provider catalog", () => {
it("builds the bundled Vercel AI Gateway defaults", async () => {
const provider = await buildVercelAiGatewayProvider();
@@ -74,23 +36,4 @@ describe("vercel ai gateway provider catalog", () => {
models: getStaticVercelAiGatewayModelCatalog(),
});
});
it("falls back to the static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
json: async () => payload,
},
release: async () => {},
});
await withLiveDiscovery(async () => {
expect(await discoverVercelAiGatewayModels()).toStrictEqual(
getStaticVercelAiGatewayModelCatalog(),
);
});
}
});
});

View File

@@ -132,10 +132,6 @@
"types": "./dist/plugin-sdk/runtime.d.ts",
"default": "./dist/plugin-sdk/runtime.js"
},
"./plugin-sdk/effect-runtime": {
"types": "./dist/plugin-sdk/effect-runtime.d.ts",
"default": "./dist/plugin-sdk/effect-runtime.js"
},
"./plugin-sdk/runtime-doctor": {
"types": "./dist/plugin-sdk/runtime-doctor.d.ts",
"default": "./dist/plugin-sdk/runtime-doctor.js"
@@ -1777,7 +1773,6 @@
"commander": "14.0.3",
"croner": "10.0.1",
"dotenv": "17.4.2",
"effect": "3.21.2",
"express": "5.2.1",
"file-type": "22.0.1",
"grammy": "1.42.0",
@@ -1798,8 +1793,8 @@
"tokenjuice": "0.7.0",
"tree-sitter-bash": "0.25.1",
"tslog": "4.10.2",
"typebox": "1.1.38",
"typescript": "6.0.3",
"typebox": "1.1.38",
"undici": "8.3.0",
"web-push": "3.6.7",
"web-tree-sitter": "0.26.8",

View File

@@ -32,7 +32,7 @@ describe("fetchRemoteEmbeddingVectors", () => {
it("maps remote embedding response data to vectors", async () => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({
data: [{ embedding: [0.1, 0.2] }, { embedding: [0.4] }, { embedding: [0.3] }],
data: [{ embedding: [0.1, 0.2] }, {}, { embedding: [0.3] }],
});
});
@@ -43,7 +43,7 @@ describe("fetchRemoteEmbeddingVectors", () => {
errorPrefix: "embedding fetch failed",
});
expect(vectors).toEqual([[0.1, 0.2], [0.4], [0.3]]);
expect(vectors).toEqual([[0.1, 0.2], [], [0.3]]);
const postJsonParams = requirePostJsonParams();
expect(postJsonParams.url).toBe("https://memory.example/v1/embeddings");
expect(postJsonParams.headers).toEqual({ Authorization: "Bearer test" });
@@ -63,60 +63,4 @@ describe("fetchRemoteEmbeddingVectors", () => {
}),
).rejects.toThrow("embedding fetch failed: 403 forbidden");
});
it("rejects non-object embedding responses", async () => {
postJsonMock.mockImplementationOnce(async (params) => await params.parse([]));
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["one"] },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
it("rejects missing embedding data arrays", async () => {
postJsonMock.mockImplementationOnce(async (params) => await params.parse({}));
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["one"] },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
it("rejects embedding counts that do not match the submitted input batch", async () => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({ data: [{ embedding: [0.1] }] });
});
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["one", "two"] },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
it("rejects wrong nested embedding vector types", async () => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({ data: [{ embedding: [0.1, "bad"] }] });
});
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["one"] },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
});

View File

@@ -1,33 +1,6 @@
import { postJson } from "./post-json.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function malformedEmbeddingResponse(errorPrefix: string): Error {
return new Error(`${errorPrefix}: malformed JSON response`);
}
function readEmbeddingVector(value: unknown, errorPrefix: string): number[] {
if (!Array.isArray(value)) {
throw malformedEmbeddingResponse(errorPrefix);
}
for (const entry of value) {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw malformedEmbeddingResponse(errorPrefix);
}
}
return value;
}
function resolveExpectedEmbeddingCount(body: unknown): number | undefined {
const input = asRecord(body)?.input;
return Array.isArray(input) ? input.length : undefined;
}
export async function fetchRemoteEmbeddingVectors(params: {
url: string;
headers: Record<string, string>;
@@ -44,21 +17,11 @@ export async function fetchRemoteEmbeddingVectors(params: {
body: params.body,
errorPrefix: params.errorPrefix,
parse: (payload) => {
const root = asRecord(payload);
if (!root || !Array.isArray(root.data)) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
const expectedCount = resolveExpectedEmbeddingCount(params.body);
if (expectedCount !== undefined && root.data.length !== expectedCount) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
return root.data.map((entry) => {
const record = asRecord(entry);
if (!record) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
return readEmbeddingVector(record.embedding, params.errorPrefix);
});
const typedPayload = payload as {
data?: Array<{ embedding?: number[] }>;
};
const data = typedPayload.data ?? [];
return data.map((entry) => entry.embedding ?? []);
},
});
}

View File

@@ -1 +0,0 @@
export * from "../../../src/plugin-sdk/effect-runtime.js";

24
pnpm-lock.yaml generated
View File

@@ -107,9 +107,6 @@ importers:
dotenv:
specifier: 17.4.2
version: 17.4.2
effect:
specifier: 3.21.2
version: 3.21.2
express:
specifier: 5.2.1
version: 5.2.1
@@ -5220,9 +5217,6 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
effect@3.21.2:
resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -5387,10 +5381,6 @@ packages:
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
engines: {node: '>=18'}
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -6819,9 +6809,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qified@0.10.1:
resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==}
engines: {node: '>=20'}
@@ -11602,11 +11589,6 @@ snapshots:
ee-first@1.1.1: {}
effect@3.21.2:
dependencies:
'@standard-schema/spec': 1.1.0
fast-check: 3.23.2
emoji-regex@8.0.0: {}
empathic@2.0.1: {}
@@ -11824,10 +11806,6 @@ snapshots:
fake-indexeddb@6.2.5: {}
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
@@ -13620,8 +13598,6 @@ snapshots:
punycode@2.3.1: {}
pure-rand@6.1.0: {}
qified@0.10.1:
dependencies:
hookified: 2.2.0

View File

@@ -412,8 +412,7 @@
"install": {
"npmSpec": "@openclaw/slack",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.1",
"allowInvalidConfigRecovery": true
"minHostVersion": ">=2026.5.12-beta.1"
}
}
},

View File

@@ -45,8 +45,7 @@
"install": {
"npmSpec": "@openclaw/brave-plugin",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10",
"allowInvalidConfigRecovery": true
"minHostVersion": ">=2026.4.10"
}
}
},

View File

@@ -31,68 +31,6 @@ function packageRelativePathExists(packageDir, relativePath) {
return fs.existsSync(path.join(packageDir, relativePath));
}
function normalizePackPath(value) {
return value.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
}
function escapeRegExp(value) {
return value.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
}
function packFilePatternMatchesPath(pattern, relativePath) {
const normalizedPattern = normalizePackPath(pattern).replace(/^!/u, "");
const normalizedPath = normalizePackPath(relativePath);
if (!normalizedPattern || !normalizedPath) {
return false;
}
if (normalizedPattern === normalizedPath) {
return true;
}
let source = "";
for (let index = 0; index < normalizedPattern.length; index += 1) {
const char = normalizedPattern[index];
const next = normalizedPattern[index + 1];
const afterNext = normalizedPattern[index + 2];
if (char === "*" && next === "*" && afterNext === "/") {
source += "(?:.*/)?";
index += 2;
continue;
}
if (char === "*" && next === "*") {
source += ".*";
index += 1;
continue;
}
if (char === "*") {
source += "[^/]*";
continue;
}
source += escapeRegExp(char ?? "");
}
return new RegExp(`^${source}$`, "u").test(normalizedPath);
}
function assertPackageFilesDoNotExcludeRequiredRuntimeArtifacts(plan) {
const fileRules = Array.isArray(plan.packageJson.files)
? plan.packageJson.files.filter((entry) => typeof entry === "string")
: [];
const exclusions = fileRules.filter((entry) => normalizePackPath(entry).startsWith("!"));
if (exclusions.length === 0) {
return;
}
for (const requiredPath of listPluginNpmRuntimeBuildOutputs(plan)) {
for (const exclusion of exclusions) {
if (packFilePatternMatchesPath(exclusion, requiredPath)) {
throw new Error(
`package file rule '${exclusion}' excludes required package-local runtime file '${requiredPath}' for ${plan.pluginDir}. Remove the negation or publish would advertise a missing runtime entry.`,
);
}
}
}
}
function assertPluginNpmRuntimeBuildExists(plan) {
const missing = listPluginNpmRuntimeBuildOutputs(plan).filter(
(runtimePath) => !packageRelativePathExists(plan.packageDir, runtimePath.replace(/^\.\//u, "")),
@@ -105,7 +43,6 @@ function assertPluginNpmRuntimeBuildExists(plan) {
].join("\n"),
);
}
assertPackageFilesDoNotExcludeRequiredRuntimeArtifacts(plan);
}
export function resolveAugmentedPluginNpmPackageJson(params) {

View File

@@ -8,7 +8,6 @@
"self-hosted-provider-setup",
"routing",
"runtime",
"effect-runtime",
"runtime-doctor",
"runtime-env",
"runtime-logger",

View File

@@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -7,8 +6,16 @@ import { parsePackageRootArg } from "./lib/package-root-args.mjs";
const STATUS_MESSAGE_RUNTIME_RE = /^status-message\.runtime(?:-[A-Za-z0-9_-]+)?\.js$/u;
export function findBuiltStatusMessageRuntimePath(distDir) {
const candidates = listBuiltStatusMessageRuntimeFiles(distDir)
const { packageRoot } = parsePackageRootArg(
process.argv.slice(2),
"OPENCLAW_STATUS_MESSAGE_RUNTIME_ROOT",
);
function findBuiltStatusMessageRuntimePath(distDir) {
const candidates = fs
.readdirSync(distDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && STATUS_MESSAGE_RUNTIME_RE.test(entry.name))
.map((entry) => entry.name)
.toSorted((left, right) => {
const leftHasHash = left !== "status-message.runtime.js";
const rightHasHash = right !== "status-message.runtime.js";
@@ -23,60 +30,18 @@ export function findBuiltStatusMessageRuntimePath(distDir) {
return path.join(distDir, candidates[0]);
}
function listBuiltStatusMessageRuntimeFiles(distDir) {
const externalFiles = listFindBuiltStatusMessageRuntimeFiles(distDir);
if (externalFiles) {
return externalFiles;
}
return fs
.readdirSync(distDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && STATUS_MESSAGE_RUNTIME_RE.test(entry.name))
.map((entry) => entry.name);
}
const runtimePath = findBuiltStatusMessageRuntimePath(path.join(packageRoot, "dist"));
const runtimeModule = await import(pathToFileURL(runtimePath).href);
function listFindBuiltStatusMessageRuntimeFiles(distDir) {
const result = spawnSync(
"find",
[distDir, "-maxdepth", "1", "-type", "f", "-name", "status-message.runtime*.js"],
{
encoding: "utf8",
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"],
},
);
if (result.status !== 0) {
return null;
}
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((file) => path.basename(file))
.filter((file) => STATUS_MESSAGE_RUNTIME_RE.test(file));
}
assert.equal(
typeof runtimeModule.loadStatusMessageRuntimeModule,
"function",
`built status-message runtime did not export loadStatusMessageRuntimeModule: ${runtimePath}`,
);
async function main() {
const { packageRoot } = parsePackageRootArg(
process.argv.slice(2),
"OPENCLAW_STATUS_MESSAGE_RUNTIME_ROOT",
);
const runtimePath = findBuiltStatusMessageRuntimePath(path.join(packageRoot, "dist"));
const runtimeModule = await import(pathToFileURL(runtimePath).href);
assert.equal(
typeof runtimeModule.loadStatusMessageRuntimeModule,
"function",
`built status-message runtime did not export loadStatusMessageRuntimeModule: ${runtimePath}`,
);
const statusModule = await runtimeModule.loadStatusMessageRuntimeModule();
assert.equal(
typeof statusModule.buildStatusMessage,
"function",
"status-message runtime did not load buildStatusMessage",
);
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
await main();
}
const statusModule = await runtimeModule.loadStatusMessageRuntimeModule();
assert.equal(
typeof statusModule.buildStatusMessage,
"function",
"status-message runtime did not load buildStatusMessage",
);

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -65,10 +64,6 @@ function walkFiles(rootDir) {
}
export function collectAllLiveTestFiles(repoRoot = process.cwd()) {
const externalFiles = listExternalLiveTestFiles(repoRoot);
if (externalFiles) {
return externalFiles;
}
return ["src", "test", "extensions"]
.flatMap((dir) => walkFiles(path.join(repoRoot, dir)))
.map((file) => path.relative(repoRoot, file).split(path.sep).join("/"))
@@ -76,72 +71,6 @@ export function collectAllLiveTestFiles(repoRoot = process.cwd()) {
.toSorted((a, b) => a.localeCompare(b));
}
function listExternalLiveTestFiles(repoRoot) {
return listGitLiveTestFiles(repoRoot) ?? listFindLiveTestFiles(repoRoot);
}
function listGitLiveTestFiles(repoRoot) {
const result = spawnSync("git", ["ls-files", "--", "src", "test", "extensions"], {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 4,
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0) {
return null;
}
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter((file) => file.endsWith(LIVE_TEST_SUFFIX))
.toSorted((a, b) => a.localeCompare(b));
}
function listFindLiveTestFiles(repoRoot) {
const roots = ["src", "test", "extensions"].map((dir) => path.join(repoRoot, dir));
const result = spawnSync(
"find",
[
...roots,
"(",
"-name",
"node_modules",
"-o",
"-name",
"dist",
"-o",
"-name",
"vendor",
"-o",
"-name",
"fixtures",
")",
"-prune",
"-o",
"-type",
"f",
"-name",
`*${LIVE_TEST_SUFFIX}`,
"-print",
],
{
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 4,
stdio: ["ignore", "pipe", "ignore"],
},
);
if (result.status !== 0) {
return null;
}
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter((file) => file.length > 0)
.map((file) => path.relative(repoRoot, file).split(path.sep).join("/"))
.toSorted((a, b) => a.localeCompare(b));
}
function extensionKey(file) {
const relative = file.slice("extensions/".length);
return relative.split("/", 1)[0]?.toLowerCase() ?? "";

View File

@@ -778,15 +778,6 @@ describe("resolveAgentConfig", () => {
expect(workspace).toBe(path.join(path.resolve(home), ".openclaw", "workspace"));
});
it("uses OPENCLAW_WORKSPACE_DIR for default agent workspace", () => {
const workspaceDir = path.join(path.sep, "srv", "openclaw-workspace");
vi.stubEnv("OPENCLAW_WORKSPACE_DIR", workspaceDir);
vi.stubEnv("OPENCLAW_HOME", path.join(path.sep, "srv", "openclaw-home"));
const workspace = resolveAgentWorkspaceDir({} as OpenClawConfig, "main");
expect(workspace).toBe(path.resolve(workspaceDir));
});
it("uses OPENCLAW_HOME for default agentDir", () => {
const home = path.join(path.sep, "srv", "openclaw-home");
vi.stubEnv("OPENCLAW_HOME", home);
@@ -808,7 +799,7 @@ describe("resolveAgentConfig", () => {
const agentDir = resolveDefaultAgentDir(cfg);
expect(agentDir).toBe(path.resolve(stateDir, "agents", "ops", "agent"));
expect(agentDir).toBe(path.join(stateDir, "agents", "ops", "agent"));
});
it("non-default agent uses agents.defaults.workspace as base (#59789)", () => {
@@ -842,7 +833,7 @@ describe("resolveAgentConfig", () => {
},
};
const workspace = resolveAgentWorkspaceDir(cfg, "main");
expect(workspace).toBe(path.resolve(stateDir, "workspace-main"));
expect(workspace).toBe(path.join(stateDir, "workspace-main"));
});
});

View File

@@ -259,26 +259,6 @@ describe("executeWithApiKeyRotation", () => {
expect(sleep).not.toHaveBeenCalled();
});
it("throws the original rate-limit error after exhausting rotated keys", async () => {
const firstError = new Error("HTTP 429 too many requests on key 1");
const secondError = new Error("HTTP 429 too many requests on key 2");
const execute = vi
.fn<(apiKey: string) => Promise<string>>()
.mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(secondError);
await expect(
executeWithApiKeyRotation({
provider: "openai",
apiKeys: ["key-1", "key-2"],
transientRetry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
execute,
}),
).rejects.toBe(secondError);
expect(execute).toHaveBeenCalledTimes(2);
});
it("does not rotate keys for transient 500 after same-key retry exhaustion", async () => {
const sleep = vi.fn(async () => undefined);
const execute = vi.fn(async () => {

View File

@@ -1,4 +1,3 @@
import { runRetryingPromise } from "../effect-runtime/retry.js";
import { sleepWithAbort } from "../infra/backoff.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
@@ -10,16 +9,6 @@ import {
} from "../provider-runtime/operation-retry.js";
import { collectProviderApiKeys, isApiKeyRateLimitError } from "./live-auth-keys.js";
class RotateApiKeyError extends Error {
constructor(
readonly error: unknown,
readonly messageForRetry: string,
) {
super(messageForRetry);
this.name = "RotateApiKeyError";
}
}
type ApiKeyRetryParams = {
apiKey: string;
error: unknown;
@@ -67,66 +56,46 @@ export async function executeWithApiKeyRotation<T>(
let lastError: unknown;
const transientRetry = resolveTransientProviderRetryOptions(params.transientRetry);
for (let apiKeyIndex = 0; apiKeyIndex < keys.length; apiKeyIndex += 1) {
keyLoop: for (let apiKeyIndex = 0; apiKeyIndex < keys.length; apiKeyIndex += 1) {
const apiKey = keys[apiKeyIndex];
const maxOperationAttempts = resolveTransientProviderAttempts(transientRetry);
try {
return await runRetryingPromise({
operation: async () => {
try {
return await params.execute(apiKey);
} catch (error) {
lastError = error;
const message = formatErrorMessage(error);
const rotateKey = params.shouldRetry
? params.shouldRetry({ apiKey, error, attempt: apiKeyIndex, message })
: isApiKeyRateLimitError(message);
for (let attemptNumber = 1; attemptNumber <= maxOperationAttempts; attemptNumber += 1) {
try {
return await params.execute(apiKey);
} catch (error) {
lastError = error;
const message = formatErrorMessage(error);
const rotateKey = params.shouldRetry
? params.shouldRetry({ apiKey, error, attempt: apiKeyIndex, message })
: isApiKeyRateLimitError(message);
if (rotateKey) {
throw new RotateApiKeyError(error, message);
}
if (rotateKey) {
if (apiKeyIndex + 1 >= keys.length) {
break;
}
params.onRetry?.({ apiKey, error, attempt: apiKeyIndex, message });
break;
}
throw error;
}
},
maxAttempts: maxOperationAttempts,
shouldRetry: (error, attemptNumber) => {
if (!transientRetry || error instanceof RotateApiKeyError) {
return false;
}
return shouldRetrySameKeyProviderOperation({
if (
!transientRetry ||
!shouldRetrySameKeyProviderOperation({
options: transientRetry,
error,
message: formatErrorMessage(error),
message,
provider: params.provider,
apiKeyIndex,
attemptNumber,
maxAttempts: maxOperationAttempts,
});
},
resolveDelayMs: (attemptNumber) =>
transientRetry ? resolveTransientProviderDelayMs(transientRetry, attemptNumber) : 0,
sleep: async (delayMs) => {
const sleep = transientRetry?.sleep ?? sleepWithAbort;
await sleep(delayMs, transientRetry?.signal);
},
});
} catch (error) {
if (error instanceof RotateApiKeyError) {
lastError = error.error;
if (apiKeyIndex + 1 >= keys.length) {
break;
})
) {
break keyLoop;
}
params.onRetry?.({
apiKey,
error: error.error,
attempt: apiKeyIndex,
message: error.messageForRetry,
});
continue;
const delayMs = resolveTransientProviderDelayMs(transientRetry, attemptNumber);
const sleep = transientRetry.sleep ?? sleepWithAbort;
await sleep(delayMs, transientRetry.signal);
}
lastError = error;
break;
}
}

View File

@@ -1,105 +0,0 @@
import { describe, expect, it } from "vitest";
import { AUTH_STORE_VERSION } from "./constants.js";
import { coercePersistedAuthProfileStore } from "./persisted.js";
describe("persisted auth profile boundary", () => {
it("normalizes malformed persisted credentials and state before runtime use", () => {
const store = coercePersistedAuthProfileStore({
version: "not-a-version",
profiles: {
"openai:default": {
type: "api_key",
provider: " OpenAI ",
key: 42,
keyRef: { source: "env", id: "OPENAI_API_KEY" },
metadata: { account: "acct_123", bad: 123 },
copyToAgents: "yes",
email: ["wrong"],
displayName: "Work",
},
"minimax:default": {
type: "token",
provider: "minimax",
token: ["wrong"],
tokenRef: { source: "env", provider: "default", id: "MINIMAX_TOKEN" },
expires: "tomorrow",
},
"codex:default": {
type: "oauth",
provider: "openai-codex",
access: ["wrong"],
refresh: "refresh-token",
expires: "later",
oauthRef: {
source: "openclaw-credentials",
provider: "openai-codex",
id: "not-a-secret-id",
},
},
"broken:array": [],
},
order: {
OpenAI: [" openai:default ", 5, ""],
minimax: "wrong",
},
lastGood: {
OpenAI: " openai:default ",
minimax: 5,
},
usageStats: {
"openai:default": {
cooldownUntil: "later",
disabledUntil: 123,
disabledReason: "billing",
failureCounts: {
billing: 2,
nope: 4,
},
},
"minimax:default": "wrong",
},
});
expect(store).toMatchObject({
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
metadata: { account: "acct_123" },
displayName: "Work",
},
"minimax:default": {
type: "token",
provider: "minimax",
tokenRef: { source: "env", provider: "default", id: "MINIMAX_TOKEN" },
expires: 0,
},
"codex:default": {
type: "oauth",
provider: "openai-codex",
refresh: "refresh-token",
expires: 0,
},
},
order: {
openai: ["openai:default"],
},
lastGood: {
openai: "openai:default",
},
usageStats: {
"openai:default": {
disabledUntil: 123,
disabledReason: "billing",
failureCounts: { billing: 2 },
},
},
});
expect(store?.profiles["broken:array"]).toBeUndefined();
expect(store?.profiles["openai:default"]).not.toHaveProperty("key");
expect(store?.profiles["openai:default"]).not.toHaveProperty("copyToAgents");
expect(store?.profiles["codex:default"]).not.toHaveProperty("oauthRef");
});
});

View File

@@ -77,38 +77,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeOptionalCredentialString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? value : undefined;
}
function normalizeOptionalCredentialBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function normalizeExpiryField(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
}
function normalizeCredentialMetadata(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const metadata: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string") {
metadata[key] = entry;
}
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function normalizeSecretBackedField(params: {
entry: Record<string, unknown>;
valueField: "key" | "token";
@@ -125,25 +93,6 @@ function normalizeSecretBackedField(params: {
delete params.entry[params.valueField];
}
function normalizeCommonCredentialFields(entry: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {
provider: typeof entry.provider === "string" ? normalizeProviderId(entry.provider) : "",
};
const copyToAgents = normalizeOptionalCredentialBoolean(entry.copyToAgents);
if (copyToAgents !== undefined) {
normalized.copyToAgents = copyToAgents;
}
const email = normalizeOptionalCredentialString(entry.email);
if (email !== undefined) {
normalized.email = email;
}
const displayName = normalizeOptionalCredentialString(entry.displayName);
if (displayName !== undefined) {
normalized.displayName = displayName;
}
return normalized;
}
function normalizeRawCredentialEntry(raw: Record<string, unknown>): Partial<AuthProfileCredential> {
const entry = { ...raw } as Record<string, unknown>;
if (!("type" in entry) && typeof entry["mode"] === "string") {
@@ -154,73 +103,6 @@ function normalizeRawCredentialEntry(raw: Record<string, unknown>): Partial<Auth
}
normalizeSecretBackedField({ entry, valueField: "key", refField: "keyRef" });
normalizeSecretBackedField({ entry, valueField: "token", refField: "tokenRef" });
if (entry.type === "api_key") {
const normalized: Record<string, unknown> = {
type: "api_key",
...normalizeCommonCredentialFields(entry),
};
const key = normalizeOptionalCredentialString(entry.key);
const keyRef = coerceSecretRef(entry.keyRef);
const metadata = normalizeCredentialMetadata(entry.metadata);
if (key !== undefined) {
normalized.key = key;
}
if (keyRef) {
normalized.keyRef = keyRef;
}
if (metadata) {
normalized.metadata = metadata;
}
return normalized as Partial<AuthProfileCredential>;
}
if (entry.type === "token") {
const normalized: Record<string, unknown> = {
type: "token",
...normalizeCommonCredentialFields(entry),
};
const token = normalizeOptionalCredentialString(entry.token);
const tokenRef = coerceSecretRef(entry.tokenRef);
const expires = normalizeExpiryField(entry.expires);
if (token !== undefined) {
normalized.token = token;
}
if (tokenRef) {
normalized.tokenRef = tokenRef;
}
if (expires !== undefined) {
normalized.expires = expires;
}
return normalized as Partial<AuthProfileCredential>;
}
if (entry.type === "oauth") {
const normalized: Record<string, unknown> = {
type: "oauth",
...normalizeCommonCredentialFields(entry),
};
for (const field of [
"access",
"refresh",
"idToken",
"clientId",
"enterpriseUrl",
"projectId",
"accountId",
"chatgptPlanType",
] as const) {
const value = normalizeOptionalCredentialString(entry[field]);
if (value !== undefined) {
normalized[field] = value;
}
}
const expires = normalizeExpiryField(entry.expires);
if (expires !== undefined) {
normalized.expires = expires;
}
if (isOAuthProfileSecretRef(entry.oauthRef)) {
normalized.oauthRef = entry.oauthRef;
}
return normalized;
}
return entry as Partial<AuthProfileCredential>;
}
@@ -646,23 +528,22 @@ function parseCredentialEntry(
raw: unknown,
fallbackProvider?: string,
): { ok: true; credential: AuthProfileCredential } | { ok: false; reason: CredentialRejectReason } {
if (!isRecord(raw)) {
if (!raw || typeof raw !== "object") {
return { ok: false, reason: "non_object" };
}
const typed = normalizeRawCredentialEntry(raw);
const typed = normalizeRawCredentialEntry(raw as Record<string, unknown>);
if (!AUTH_PROFILE_TYPES.has(typed.type as AuthProfileCredential["type"])) {
return { ok: false, reason: "invalid_type" };
}
const provider = typed.provider ?? fallbackProvider;
const normalizedProvider = typeof provider === "string" ? normalizeProviderId(provider) : "";
if (!normalizedProvider) {
if (typeof provider !== "string" || provider.trim().length === 0) {
return { ok: false, reason: "missing_provider" };
}
return {
ok: true,
credential: {
...typed,
provider: normalizedProvider,
provider,
} as AuthProfileCredential,
};
}
@@ -728,9 +609,8 @@ export function coercePersistedAuthProfileStore(raw: unknown): AuthProfileStore
normalized[key] = parsed.credential;
}
warnRejectedCredentialEntries("auth-profiles.json", rejected);
const version = Number(record.version ?? AUTH_STORE_VERSION);
return {
version: Number.isFinite(version) && version > 0 ? version : AUTH_STORE_VERSION,
version: Number(record.version ?? AUTH_STORE_VERSION),
profiles: normalized,
...coerceAuthProfileState(record),
};

View File

@@ -1,159 +1,44 @@
import fs from "node:fs";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { normalizeProviderId } from "../provider-id.js";
import { AUTH_STORE_VERSION } from "./constants.js";
import { resolveAuthStatePath } from "./paths.js";
import type {
AuthProfileBlockedReason,
AuthProfileBlockedSource,
AuthProfileFailureReason,
AuthProfileState,
AuthProfileStateStore,
ProfileUsageStats,
} from "./types.js";
const AUTH_FAILURE_REASONS = new Set<AuthProfileFailureReason>([
"auth",
"auth_permanent",
"format",
"overloaded",
"rate_limit",
"billing",
"timeout",
"model_not_found",
"session_expired",
"empty_response",
"no_error_details",
"unclassified",
"unknown",
]);
const AUTH_BLOCKED_REASONS = new Set<AuthProfileBlockedReason>(["subscription_limit"]);
const AUTH_BLOCKED_SOURCES = new Set<AuthProfileBlockedSource>(["codex_rate_limits", "wham"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeEnumValue<T extends string>(value: unknown, allowed: Set<T>): T | undefined {
if (typeof value !== "string") {
return undefined;
}
return allowed.has(value as T) ? (value as T) : undefined;
}
function normalizeFailureCounts(raw: unknown): ProfileUsageStats["failureCounts"] {
if (!isRecord(raw)) {
return undefined;
}
const normalized: NonNullable<ProfileUsageStats["failureCounts"]> = {};
for (const [reason, count] of Object.entries(raw)) {
if (!AUTH_FAILURE_REASONS.has(reason as AuthProfileFailureReason)) {
continue;
}
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
continue;
}
normalized[reason as AuthProfileFailureReason] = Math.trunc(count);
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
import type { AuthProfileState, AuthProfileStateStore, ProfileUsageStats } from "./types.js";
function normalizeAuthProfileOrder(raw: unknown): AuthProfileState["order"] {
if (!isRecord(raw)) {
if (!raw || typeof raw !== "object") {
return undefined;
}
const normalized = Object.entries(raw).reduce<Record<string, string[]>>(
(acc, [provider, value]) => {
if (!Array.isArray(value)) {
return acc;
}
const providerKey = normalizeProviderId(provider);
if (!providerKey) {
return acc;
}
const list = value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
if (list.length > 0) {
acc[providerKey] = list;
}
const normalized = Object.entries(raw as Record<string, unknown>).reduce<
Record<string, string[]>
>((acc, [provider, value]) => {
if (!Array.isArray(value)) {
return acc;
},
{},
);
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeLastGood(raw: unknown): AuthProfileState["lastGood"] {
if (!isRecord(raw)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [provider, profileId] of Object.entries(raw)) {
const providerKey = normalizeProviderId(provider);
const normalizedProfileId = normalizeOptionalString(profileId);
if (!providerKey || !normalizedProfileId) {
continue;
}
normalized[providerKey] = normalizedProfileId;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeUsageStatsEntry(raw: unknown): ProfileUsageStats | undefined {
if (!isRecord(raw)) {
return undefined;
}
const stats: ProfileUsageStats = {
lastUsed: normalizeFiniteNumber(raw.lastUsed),
blockedUntil: normalizeFiniteNumber(raw.blockedUntil),
blockedReason: normalizeEnumValue(raw.blockedReason, AUTH_BLOCKED_REASONS),
blockedSource: normalizeEnumValue(raw.blockedSource, AUTH_BLOCKED_SOURCES),
blockedModel: normalizeOptionalString(raw.blockedModel),
cooldownUntil: normalizeFiniteNumber(raw.cooldownUntil),
cooldownReason: normalizeEnumValue(raw.cooldownReason, AUTH_FAILURE_REASONS),
cooldownModel: normalizeOptionalString(raw.cooldownModel),
disabledUntil: normalizeFiniteNumber(raw.disabledUntil),
disabledReason: normalizeEnumValue(raw.disabledReason, AUTH_FAILURE_REASONS),
errorCount: normalizeFiniteNumber(raw.errorCount),
failureCounts: normalizeFailureCounts(raw.failureCounts),
lastFailureAt: normalizeFiniteNumber(raw.lastFailureAt),
};
for (const key of Object.keys(stats) as Array<keyof ProfileUsageStats>) {
if (stats[key] === undefined) {
delete stats[key];
const list = value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
if (list.length > 0) {
acc[provider] = list;
}
}
return Object.keys(stats).length > 0 ? stats : undefined;
}
function normalizeUsageStats(raw: unknown): AuthProfileState["usageStats"] {
if (!isRecord(raw)) {
return undefined;
}
const normalized: Record<string, ProfileUsageStats> = {};
for (const [profileId, value] of Object.entries(raw)) {
const normalizedProfileId = normalizeOptionalString(profileId);
const stats = normalizeUsageStatsEntry(value);
if (!normalizedProfileId || !stats) {
continue;
}
normalized[normalizedProfileId] = stats;
}
return acc;
}, {});
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
export function coerceAuthProfileState(raw: unknown): AuthProfileState {
if (!isRecord(raw)) {
if (!raw || typeof raw !== "object") {
return {};
}
const record = raw as Record<string, unknown>;
return {
order: normalizeAuthProfileOrder(raw.order),
lastGood: normalizeLastGood(raw.lastGood),
usageStats: normalizeUsageStats(raw.usageStats),
order: normalizeAuthProfileOrder(record.order),
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
: undefined,
usageStats:
record.usageStats && typeof record.usageStats === "object"
? (record.usageStats as Record<string, ProfileUsageStats>)
: undefined,
};
}

View File

@@ -81,21 +81,6 @@ function registerDuplicateBootstrapFileHook() {
});
}
function registerBootstrapFileHook(relativePath = "BOOTSTRAP.md") {
registerInternalHook("agent:bootstrap", (event) => {
const context = event.context as AgentBootstrapHookContext;
context.bootstrapFiles = [
...context.bootstrapFiles,
{
name: "BOOTSTRAP.md",
path: path.join(context.workspaceDir, relativePath),
content: "stale ritual",
missing: false,
},
];
});
}
async function createHeartbeatAgentsWorkspace() {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
@@ -164,124 +149,6 @@ describe("resolveBootstrapFilesForRun", () => {
expect(agentsContextFiles).toHaveLength(1);
expect(agentsContextFiles[0]?.content).toBe("workspace rules");
});
it("ignores stale workspace BOOTSTRAP.md once setup is completed", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.map((file) => file.name)).toContain("AGENTS.md");
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
it("keeps BOOTSTRAP.md when setup state cannot be read", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw", "workspace-state.json"), {
recursive: true,
});
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8");
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.map((file) => file.name)).toContain("BOOTSTRAP.md");
});
it("does not let hooks re-add stale root BOOTSTRAP.md after setup is completed", async () => {
registerBootstrapFileHook();
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
it("ignores stale root BOOTSTRAP.md for home-relative workspace paths", async () => {
registerBootstrapFileHook();
const parentDir = await makeTempWorkspace("openclaw-bootstrap-home-");
const workspaceDir = path.join(parentDir, "workspace");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
const previousOpenClawHome = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = parentDir;
try {
const files = await resolveBootstrapFilesForRun({ workspaceDir: "~/workspace" });
expect(files.map((file) => file.name)).toContain("AGENTS.md");
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
} finally {
if (previousOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previousOpenClawHome;
}
}
});
it("keeps hook-added nested BOOTSTRAP.md after setup is completed", async () => {
registerBootstrapFileHook(path.join("packages", "core", "BOOTSTRAP.md"));
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.mkdir(path.join(workspaceDir, "packages", "core"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
await fs.writeFile(
path.join(workspaceDir, "packages", "core", "BOOTSTRAP.md"),
"package ritual",
"utf8",
);
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.map((file) => path.relative(workspaceDir, file.path))).toContain(
path.join("packages", "core", "BOOTSTRAP.md"),
);
expect(files.map((file) => file.path)).not.toContain(path.join(workspaceDir, "BOOTSTRAP.md"));
});
});
describe("resolveBootstrapContextForRun", () => {

View File

@@ -3,7 +3,6 @@ import path from "node:path";
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
@@ -16,9 +15,7 @@ import {
} from "./pi-embedded-helpers.js";
import {
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
filterBootstrapFilesForSession,
isWorkspaceSetupCompleted,
isWorkspaceBootstrapPending,
loadWorkspaceBootstrapFiles,
type WorkspaceBootstrapFile,
@@ -161,7 +158,7 @@ function sanitizeBootstrapFiles(
workspaceDir: string,
warn?: (message: string) => void,
): WorkspaceBootstrapFile[] {
const workspaceRoot = resolveUserPath(workspaceDir);
const workspaceRoot = path.resolve(workspaceDir);
const seenPaths = new Set<string>();
const sanitized: WorkspaceBootstrapFile[] = [];
for (const file of files) {
@@ -174,9 +171,7 @@ function sanitizeBootstrapFiles(
}
const resolvedPath = path.isAbsolute(pathValue)
? path.resolve(pathValue)
: pathValue.startsWith("~")
? resolveUserPath(pathValue)
: path.resolve(workspaceRoot, pathValue);
: path.resolve(workspaceRoot, pathValue);
const dedupeKey = path.normalize(path.relative(workspaceRoot, resolvedPath));
if (seenPaths.has(dedupeKey)) {
continue;
@@ -239,41 +234,6 @@ function filterHeartbeatBootstrapFile(
return files.filter((file) => file.name !== DEFAULT_HEARTBEAT_FILENAME);
}
function filterCompletedWorkspaceBootstrapFile(
files: WorkspaceBootstrapFile[],
setupCompleted: boolean,
workspaceDir: string,
): WorkspaceBootstrapFile[] {
if (!setupCompleted) {
return files;
}
const workspaceRoot = resolveUserPath(workspaceDir);
const rootBootstrapPath = path.join(workspaceRoot, DEFAULT_BOOTSTRAP_FILENAME);
return files.filter((file) => {
if (file.name !== DEFAULT_BOOTSTRAP_FILENAME) {
return true;
}
const pathValue = normalizeOptionalString(file.path);
if (!pathValue) {
return true;
}
const resolvedPath = path.isAbsolute(pathValue)
? path.resolve(pathValue)
: pathValue.startsWith("~")
? resolveUserPath(pathValue)
: path.resolve(workspaceRoot, pathValue);
return resolvedPath !== rootBootstrapPath;
});
}
async function isWorkspaceSetupCompletedForContext(workspaceDir: string): Promise<boolean> {
try {
return await isWorkspaceSetupCompleted(workspaceDir);
} catch {
return false;
}
}
export async function resolveBootstrapFilesForRun(params: {
workspaceDir: string;
config?: OpenClawConfig;
@@ -286,7 +246,6 @@ export async function resolveBootstrapFilesForRun(params: {
}): Promise<WorkspaceBootstrapFile[]> {
const excludeHeartbeatBootstrapFile = shouldExcludeHeartbeatBootstrapFile(params);
const sessionKey = params.sessionKey ?? params.sessionId;
const workspaceSetupCompleted = await isWorkspaceSetupCompletedForContext(params.workspaceDir);
const rawFiles = params.sessionKey
? await getOrLoadBootstrapFiles({
workspaceDir: params.workspaceDir,
@@ -294,11 +253,7 @@ export async function resolveBootstrapFilesForRun(params: {
})
: await loadWorkspaceBootstrapFiles(params.workspaceDir);
const bootstrapFiles = applyContextModeFilter({
files: filterCompletedWorkspaceBootstrapFile(
filterBootstrapFilesForSession(rawFiles, sessionKey),
workspaceSetupCompleted,
params.workspaceDir,
),
files: filterBootstrapFilesForSession(rawFiles, sessionKey),
contextMode: params.contextMode,
runKind: params.runKind,
});
@@ -311,13 +266,8 @@ export async function resolveBootstrapFilesForRun(params: {
sessionId: params.sessionId,
agentId: params.agentId,
});
const filteredUpdated = filterCompletedWorkspaceBootstrapFile(
updated,
workspaceSetupCompleted,
params.workspaceDir,
);
return sanitizeBootstrapFiles(
filterHeartbeatBootstrapFile(filteredUpdated, excludeHeartbeatBootstrapFile),
filterHeartbeatBootstrapFile(updated, excludeHeartbeatBootstrapFile),
params.workspaceDir,
params.warn,
);

View File

@@ -2128,38 +2128,6 @@ describe("model-selection", () => {
}),
).toBe("medium");
});
it("honors configured provider models that disable reasoning", () => {
const cfg = {
models: {
providers: {
google: {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [
{
id: "gemma-4-26b-a4b-it",
name: "Gemma 4 26B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_000,
maxTokens: 8_192,
},
],
},
},
},
} as OpenClawConfig;
expect(
resolveThinkingDefault({
cfg,
provider: "google",
model: "gemma-4-26b-a4b-it",
}),
).toBe("off");
});
});
});

View File

@@ -19,12 +19,11 @@ export function resolveThinkingDefault(params: {
}): ThinkLevel {
const normalizedProvider = normalizeProviderId(params.provider);
const normalizedModel = normalizeLowercaseStringOrEmpty(params.model).replace(/\./g, "-");
const catalog = Array.isArray(params.catalog)
? params.catalog
: buildConfiguredModelCatalog({ cfg: params.cfg });
const catalogCandidate = catalog.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
const catalogCandidate = Array.isArray(params.catalog)
? params.catalog.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
)
: undefined;
const configuredModels = params.cfg.agents?.defaults?.models;
const canonicalKey = modelKey(params.provider, params.model);
const legacyKey = legacyModelKey(params.provider, params.model);
@@ -76,7 +75,7 @@ export function resolveThinkingDefault(params: {
return resolveThinkingDefaultForModel({
provider: params.provider,
model: params.model,
catalog,
catalog: params.catalog,
});
}

View File

@@ -1,10 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import {
clearInternalHooks,
registerInternalHook,
type InternalHookEvent,
} from "../hooks/internal-hooks.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
import type { TaskRecord } from "../tasks/task-registry.types.js";
import { buildTaskStatusSnapshot } from "../tasks/task-status.js";
@@ -437,7 +432,6 @@ function getSessionStatusTool(
describe("session_status tool", () => {
beforeEach(() => {
buildStatusMessageMock.mockClear();
clearInternalHooks();
});
it("returns a status card for the current session", async () => {
@@ -903,41 +897,6 @@ describe("session_status tool", () => {
expect(saved.sessionId).toMatch(UUID_RE);
});
it("fires session:patch when session_status changes the persisted session model", async () => {
const events: InternalHookEvent[] = [];
registerInternalHook("session:patch", async (event) => {
events.push(event);
});
resetSessionStore({
main: {
sessionId: "s1",
updatedAt: 10,
},
});
const tool = getSessionStatusTool();
await tool.execute("call-session-status-model-hook", {
model: "anthropic/claude-sonnet-4-6",
});
await vi.waitFor(() => expect(events).toHaveLength(1));
const event = events[0];
expect(event.type).toBe("session");
expect(event.action).toBe("patch");
expect(event.sessionKey).toBe("main");
const context = event.context;
expect(context.patch).toMatchObject({
key: "main",
model: "anthropic/claude-sonnet-4-6",
});
expect(context.sessionEntry).toMatchObject({
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6",
liveModelSwitchPending: true,
});
});
it("materializes a valid persisted session entry when the default implicit current fallback mutates model state", async () => {
resetSessionStore({});

View File

@@ -80,6 +80,22 @@ export function resolveContextEngineCapabilities(
},
}).complete(request);
},
completeStructured: async (request) => {
const { createRuntimeLlm } = await import("../../plugins/runtime/runtime-llm.runtime.js");
return await createRuntimeLlm({
getConfig: () => params.config,
authority: {
caller: { kind: "context-engine", id: params.purpose },
requiresBoundAgent: true,
...(sessionKey ? { sessionKey } : {}),
...(agentId ? { agentId } : {}),
...(contextEnginePluginId ? { pluginIdForPolicy: contextEnginePluginId } : {}),
allowAgentIdOverride: false,
allowModelOverride: false,
allowComplete: true,
},
}).completeStructured(request);
},
},
};
}

View File

@@ -83,10 +83,6 @@ type HookOutcome =
| { blocked: false; params: unknown };
type PluginApprovalRequest = NonNullable<PluginHookBeforeToolCallResult["requireApproval"]>;
export function hasBeforeToolCallPolicy(): boolean {
return getGlobalHookRunner()?.hasHooks("before_tool_call") === true || hasTrustedToolPolicies();
}
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON =

View File

@@ -171,64 +171,3 @@ export async function readProviderJsonResponse<T>(response: Response, label: str
throw new Error(`${label}: malformed JSON response`, { cause });
}
}
export async function readProviderJsonObjectResponse(
response: Response,
label: string,
): Promise<Record<string, unknown>> {
const payload = await readProviderJsonResponse<unknown>(response, label);
const object = asObject(payload);
if (!object) {
throw new Error(`${label}: malformed JSON response`);
}
return object;
}
export async function readProviderJsonArrayFieldResponse(
response: Response,
label: string,
field: string,
): Promise<unknown[]> {
const payload = await readProviderJsonObjectResponse(response, label);
const value = payload[field];
if (!Array.isArray(value)) {
throw new Error(`${label}: malformed JSON response`);
}
return value;
}
function normalizeContentType(response: Response): string | undefined {
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
return contentType || undefined;
}
export function assertProviderBinaryResponseContent(
response: Response,
label: string,
kind = "binary",
): void {
const contentType = normalizeContentType(response);
if (!contentType) {
return;
}
if (
contentType === "application/json" ||
contentType.endsWith("+json") ||
contentType.startsWith("text/")
) {
throw new Error(`${label}: malformed ${kind} response`);
}
}
export async function readProviderBinaryResponse(
response: Response,
label: string,
kind = "binary",
): Promise<Uint8Array> {
assertProviderBinaryResponseContent(response, label, kind);
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength === 0) {
throw new Error(`${label}: malformed ${kind} response`);
}
return bytes;
}

View File

@@ -14,7 +14,6 @@ import {
updateSessionStore,
} from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js";
import {
buildAgentMainSessionKey,
@@ -631,15 +630,6 @@ export function createSessionStatusTool(opts?: {
nextStore[resolved.key] = persistedEntry;
});
resolved.entry = persistedEntry;
triggerSessionPatchHook({
cfg,
sessionEntry: persistedEntry,
sessionKey: resolved.key,
patch: {
key: resolved.key,
model: selection.kind === "reset" ? null : `${selection.provider}/${selection.model}`,
},
});
changedModel = true;
}
}

View File

@@ -7,10 +7,6 @@ export function resolveDefaultAgentWorkspaceDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const workspaceDir = env.OPENCLAW_WORKSPACE_DIR?.trim();
if (workspaceDir) {
return path.resolve(workspaceDir);
}
const home = resolveRequiredHomeDir(env, homedir);
const profile = env.OPENCLAW_PROFILE?.trim();
if (profile && normalizeOptionalLowercaseString(profile) !== "default") {

View File

@@ -60,18 +60,16 @@ describe("resolveRunWorkspaceDir", () => {
});
it("falls back to built-in main workspace when config is unavailable", () => {
const workspaceDir = path.join(path.sep, "srv", "openclaw-workspace");
const result = resolveRunWorkspaceDir({
workspaceDir: null,
sessionKey: "agent:main:subagent:test",
config: undefined,
env: { ...process.env, OPENCLAW_WORKSPACE_DIR: workspaceDir },
});
expect(result.usedFallback).toBe(true);
expect(result.fallbackReason).toBe("missing");
expect(result.agentId).toBe("main");
expect(result.workspaceDir).toBe(path.resolve(workspaceDir));
expect(result.workspaceDir).toBe(path.resolve(resolveDefaultAgentWorkspaceDir(process.env)));
});
it("throws for malformed agent session keys", () => {

View File

@@ -16,12 +16,4 @@ describe("DEFAULT_AGENT_WORKSPACE_DIR", () => {
path.join(path.resolve(home), ".openclaw", "workspace"),
);
});
it("uses OPENCLAW_WORKSPACE_DIR before OPENCLAW_HOME", () => {
const workspaceDir = path.join(path.sep, "srv", "openclaw-workspace");
vi.stubEnv("OPENCLAW_WORKSPACE_DIR", workspaceDir);
vi.stubEnv("OPENCLAW_HOME", path.join(path.sep, "srv", "openclaw-home"));
expect(resolveDefaultAgentWorkspaceDir()).toBe(path.resolve(workspaceDir));
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
import {
DEFAULT_AGENTS_FILENAME,
@@ -31,16 +31,6 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
expect(dir).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace"));
});
it("prefers OPENCLAW_WORKSPACE_DIR for default workspace resolution", () => {
const dir = resolveDefaultAgentWorkspaceDir({
OPENCLAW_WORKSPACE_DIR: "/srv/openclaw-workspace",
OPENCLAW_HOME: "/srv/openclaw-home",
HOME: "/home/other",
} as NodeJS.ProcessEnv);
expect(dir).toBe(path.resolve("/srv/openclaw-workspace"));
});
});
const WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
@@ -266,35 +256,6 @@ describe("ensureAgentWorkspace", () => {
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
});
it("records stale bootstrap completion when BOOTSTRAP.md cleanup fails", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_IDENTITY_FILENAME,
content: "# IDENTITY.md\n\n- **Name:** Example\n",
});
const bootstrapPath = path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME);
const rmSpy = vi
.spyOn(fs, "rm")
.mockRejectedValueOnce(Object.assign(new Error("not a directory"), { code: "ENOTDIR" }));
try {
const result = await reconcileWorkspaceBootstrapCompletion(tempDir);
expect(result.repaired).toBe(true);
expect(result.bootstrapExists).toBe(true);
expect(rmSpy).toHaveBeenCalledWith(bootstrapPath, { force: true });
await expect(fs.access(bootstrapPath)).resolves.toBeUndefined();
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
} finally {
rmSpy.mockRestore();
}
});
it("uses SOUL.md customization as stale bootstrap completion evidence", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });

View File

@@ -299,14 +299,9 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
bootstrapSeededAt: params.state.bootstrapSeededAt ?? now,
setupCompletedAt: now,
};
await fs.rm(params.bootstrapPath, { force: true });
await writeWorkspaceSetupState(params.statePath, repairedState);
try {
await fs.rm(params.bootstrapPath, { force: true });
return { repaired: true, bootstrapExists: false, state: repairedState };
} catch {
// Completion state is authoritative; stale BOOTSTRAP cleanup is best-effort.
return { repaired: true, bootstrapExists: true, state: repairedState };
}
return { repaired: true, bootstrapExists: false, state: repairedState };
}
function resolveWorkspaceStatePath(dir: string): string {

View File

@@ -14,7 +14,7 @@ import {
expectBareNewOrResetAcknowledged,
withTempHome,
} from "../../test/helpers/auto-reply/trigger-handling-test-harness.js";
import { loadSessionStore } from "../config/sessions.js";
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js";
import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js";
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
@@ -537,7 +537,6 @@ describe("trigger handling", () => {
const storePath = join(home, "compact-main.sessions.json");
const cfg = makeCfg(home);
cfg.session = { ...cfg.session, store: storePath };
await seedTargetSession(storePath, MAIN_SESSION_KEY);
mockSuccessfulCompaction();
const request = {
@@ -558,7 +557,7 @@ describe("trigger handling", () => {
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce();
const store = loadSessionStore(storePath);
const sessionKey = MAIN_SESSION_KEY;
const sessionKey = resolveSessionKey("per-sender", request);
expect(store[sessionKey]?.compactionCount).toBe(1);
});
});

View File

@@ -4,7 +4,6 @@ import { resolveExecDefaults } from "../../agents/exec-defaults.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { updateSessionStore } from "../../config/sessions.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
@@ -476,16 +475,6 @@ export async function handleDirectiveOnly(
});
}
if (modelSelection && modelSelectionUpdated && sessionKey) {
triggerSessionPatchHook({
cfg: params.cfg,
sessionEntry,
sessionKey,
patch: {
key: sessionKey,
model:
directives.rawModelDirective ?? `${modelSelection.provider}/${modelSelection.model}`,
},
});
// `/model` should retarget queued/future work without interrupting the
// active run. Refresh queued followups so they pick up the persisted
// selection once the current turn finishes.

View File

@@ -113,11 +113,6 @@ import {
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ModelDefinitionConfig, OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
clearInternalHooks,
registerInternalHook,
type InternalHookEvent,
} from "../../hooks/internal-hooks.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
@@ -248,13 +243,11 @@ beforeEach(() => {
vi.mocked(enqueueSystemEvent).mockClear();
liveModelSwitchMocks.requestLiveSessionModelSwitch.mockReset().mockReturnValue(false);
queueMocks.refreshQueuedFollowupSession.mockReset();
clearInternalHooks();
});
afterEach(() => {
setDirectiveTestProviders([]);
clearRuntimeAuthProfileStoreSnapshots();
clearInternalHooks();
});
function setAuthProfiles(profiles: Record<string, AuthProfileForTest>) {
@@ -1274,34 +1267,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
});
});
it("fires session:patch when /model changes the persisted session model", async () => {
const events: InternalHookEvent[] = [];
registerInternalHook("session:patch", async (event) => {
events.push(event);
});
const sessionEntry = createSessionEntry();
await handleDirectiveOnly(
createHandleParams({
directives: parseInlineDirectives("/model openai/gpt-4o"),
sessionEntry,
}),
);
await vi.waitFor(() => expect(events).toHaveLength(1));
const event = events[0];
expect(event.type).toBe("session");
expect(event.action).toBe("patch");
expect(event.sessionKey).toBe(sessionKey);
const context = event.context;
expect(context.patch).toMatchObject({ key: sessionKey, model: "openai/gpt-4o" });
expect(context.sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o",
liveModelSwitchPending: true,
});
});
it("keeps xhigh when switching to OpenCode Claude Opus 4.7", async () => {
const sessionEntry = createSessionEntry({ thinkingLevel: "xhigh" });
const sessionStore = { [sessionKey]: sessionEntry };

View File

@@ -11,7 +11,6 @@ import { resolveContextConfigProviderForRuntime } from "../../agents/openai-code
import { updateSessionStore } from "../../config/sessions/store.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
@@ -236,7 +235,6 @@ export async function persistInlineDirectives(params: {
directives.hasModelDirective && params.effectiveModelDirective
? params.effectiveModelDirective
: undefined;
let modelUpdated = false;
if (modelDirective) {
const modelResolution = resolveModelSelectionFromDirective({
directives: {
@@ -254,7 +252,7 @@ export async function persistInlineDirectives(params: {
provider,
});
if (modelResolution.modelSelection) {
const appliedModelOverride = applyModelOverrideToSessionEntry({
const { updated: modelUpdated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelResolution.modelSelection,
profileOverride: modelResolution.profileOverride,
@@ -294,7 +292,6 @@ export async function persistInlineDirectives(params: {
},
);
}
modelUpdated = appliedModelOverride.updated;
provider = modelResolution.modelSelection.provider;
model = modelResolution.modelSelection.model;
const currentThinkingLevel = sessionEntry.thinkingLevel as ThinkLevel | undefined;
@@ -354,14 +351,6 @@ export async function persistInlineDirectives(params: {
store[sessionKey] = sessionEntry;
});
}
if (modelDirective && modelUpdated) {
triggerSessionPatchHook({
cfg,
sessionEntry,
sessionKey,
patch: { key: sessionKey, model: modelDirective },
});
}
enqueueModeSwitchEvents({
enqueueSystemEvent,
sessionEntry,

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