mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 16:03:47 +08:00
Compare commits
7 Commits
feat/effec
...
codex/pr-8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0635aeca7 | ||
|
|
2171e714f5 | ||
|
|
9dc496986a | ||
|
|
80c54f8288 | ||
|
|
074621dfd9 | ||
|
|
9588d72156 | ||
|
|
9c0e21f239 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))],
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & Effect",
|
||||
url: "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.openclaw.ai%2F",
|
||||
snippet: "Typed effects & 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.");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[][]> => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../../src/plugin-sdk/effect-runtime.js";
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -412,8 +412,7 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/slack",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.1",
|
||||
"allowInvalidConfigRecovery": true
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -45,8 +45,7 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/brave-plugin",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10",
|
||||
"allowInvalidConfigRecovery": true
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"self-hosted-provider-setup",
|
||||
"routing",
|
||||
"runtime",
|
||||
"effect-runtime",
|
||||
"runtime-doctor",
|
||||
"runtime-env",
|
||||
"runtime-logger",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user