Merge branch 'main' into feature/before-dispatch-hook

This commit is contained in:
M1a0 2026-03-12 12:58:27 +08:00 committed by GitHub
commit a8abdf8980
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
183 changed files with 6572 additions and 1455 deletions

View File

@ -4,21 +4,27 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Security
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
### Changes
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
- Memory/Gemini: add `gemini-embedding-2-preview` memory-search support with configurable output dimensions and automatic reindexing when the configured dimensions change. (#42501) Thanks @BillChirico and @gumadeiras.
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
- Onboarding/Ollama: add first-class Ollama setup with Local or Cloud + Local modes, browser-based cloud sign-in, curated model suggestions, and cloud-model handling that skips unnecessary local pulls. (#41529) Thanks @BruceMacD.
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
- Memory: add opt-in multimodal image and audio indexing for `memorySearch.extraPaths` with Gemini `gemini-embedding-2-preview`, strict fallback gating, and scope-based reindexing. (#43460) Thanks @gumadeiras.
- Memory/Gemini: add `gemini-embedding-2-preview` memory-search support with configurable output dimensions and automatic reindexing when the configured dimensions change. (#42501) Thanks @BillChirico and @gumadeiras.
- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
### Breaking
@ -27,87 +33,97 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<...>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
- Gateway/macOS launchd restarts: keep the LaunchAgent registered during explicit restarts, hand off self-restarts through a detached launchd helper, and recover config/hot reload restart paths without unloading the service. Fixes #43311, #43406, #43035, and #43049.
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- Agents/fallback: recognize Venice `402 Insufficient USD or Diem balance` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#43205) Thanks @Squabble9.
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
- Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam.
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#43045) Thanks @MoerAI.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
- CLI/skills JSON: strip ANSI and C1 control bytes from `skills list --json`, `skills info --json`, and `skills check --json` so machine-readable output stays valid for terminals and skill metadata with embedded control characters. Fixes #27530. Related #27557. Thanks @Jimmy-xuzimo and @vincentkoc.
- CLI/tables: default shared tables to ASCII borders on legacy Windows consoles while keeping Unicode borders on modern Windows terminals, so commands like `openclaw skills` stop rendering mojibake under GBK/936 consoles. Fixes #40853. Related #41015. Thanks @ApacheBin and @vincentkoc.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
- Signal/config schema: accept `channels.signal.accountUuid` in strict config validation so loop-protection configs no longer fail with an unrecognized-key error. (#35578) Thanks @ingyukoh.
- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
- Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh.
- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692)
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006.
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
- Agents/context-engine compaction: guard thrown engine-owned overflow compaction attempts and fire compaction hooks for `ownsCompaction` engines so overflow recovery no longer crashes and plugin subscribers still observe compact runs. (#41361) thanks @davidrudduck.
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#43045) Thanks @MoerAI.
- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
- Signal/config schema: accept `channels.signal.accountUuid` in strict config validation so loop-protection configs no longer fail with an unrecognized-key error. (#35578) Thanks @ingyukoh.
- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
- Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam.
- Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh.
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
- Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches.
- Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang.
## 2026.3.8
@ -4023,6 +4039,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
### Maintenance

View File

@ -63,8 +63,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 202603090
versionName = "2026.3.9"
versionCode = 202603110
versionName = "2026.3.11-beta.1"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@ -64,9 +64,9 @@ Release behavior:
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.9-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.9`
- `CFBundleVersion = next TestFlight build number for 2026.3.9`
- A root version like `2026.3.11-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.11`
- `CFBundleVersion = next TestFlight build number for 2026.3.11`
Archive without upload:

View File

@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.")
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.11 or 2026.3.11-beta.1.")
end
version

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>2026.3.11-beta.1</string>
<key>CFBundleVersion</key>
<string>202603080</string>
<string>202603110</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@ -284,9 +284,46 @@ Notes:
- Paths can be absolute or workspace-relative.
- Directories are scanned recursively for `.md` files.
- Only Markdown files are indexed.
- By default, only Markdown files are indexed.
- If `memorySearch.multimodal.enabled = true`, OpenClaw also indexes supported image/audio files under `extraPaths` only. Default memory roots (`MEMORY.md`, `memory.md`, `memory/**/*.md`) stay Markdown-only.
- Symlinks are ignored (files or directories).
### Multimodal memory files (Gemini image + audio)
OpenClaw can index image and audio files from `memorySearch.extraPaths` when using Gemini embedding 2:
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-2-preview",
extraPaths: ["assets/reference", "voice-notes"],
multimodal: {
enabled: true,
modalities: ["image", "audio"], // or ["all"]
maxFileBytes: 10000000
},
remote: {
apiKey: "YOUR_GEMINI_API_KEY"
}
}
}
}
```
Notes:
- Multimodal memory is currently supported only for `gemini-embedding-2-preview`.
- Multimodal indexing applies only to files discovered through `memorySearch.extraPaths`.
- Supported modalities in this phase: image and audio.
- `memorySearch.fallback` must stay `"none"` while multimodal memory is enabled.
- Matching image/audio file bytes are uploaded to the configured Gemini embedding endpoint during indexing.
- Supported image extensions: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif`.
- Supported audio extensions: `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac`.
- Search queries remain text, but Gemini can compare those text queries against indexed image/audio embeddings.
- `memory_get` still reads Markdown only; binary files are searchable but not returned as raw file contents.
### Gemini embeddings (native)
Set the provider to `gemini` to use the Gemini embeddings API directly:

View File

@ -357,7 +357,7 @@ Ollama is a local LLM runtime that provides an OpenAI-compatible API:
- Provider: `ollama`
- Auth: None required (local server)
- Example model: `ollama/llama3.3`
- Installation: [https://ollama.ai](https://ollama.ai)
- Installation: [https://ollama.com/download](https://ollama.com/download)
```bash
# Install Ollama, then pull a model:
@ -372,7 +372,7 @@ ollama pull llama3.3
}
```
Ollama is automatically detected when running locally at `http://127.0.0.1:11434/v1`. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with `OLLAMA_API_KEY`, and `openclaw onboard` can configure it directly as a first-class provider. See [/providers/ollama](/providers/ollama) for onboarding, cloud/local mode, and custom configuration.
### vLLM

View File

@ -11,6 +11,8 @@ title: "Local Models"
Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers.
## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size)
Best current local stack. Load MiniMax M2.5 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.

View File

@ -2084,8 +2084,21 @@ More context: [Models](/concepts/models).
### Can I use selfhosted models llamacpp vLLM Ollama
Yes. If your local server exposes an OpenAI-compatible API, you can point a
custom provider at it. Ollama is supported directly and is the easiest path.
Yes. Ollama is the easiest path for local models.
Quickest setup:
1. Install Ollama from `https://ollama.com/download`
2. Pull a local model such as `ollama pull glm-4.7-flash`
3. If you want Ollama Cloud too, run `ollama signin`
4. Run `openclaw onboard` and choose `Ollama`
5. Pick `Local` or `Cloud + Local`
Notes:
- `Cloud + Local` gives you Ollama Cloud models plus your local Ollama models
- cloud models such as `kimi-k2.5:cloud` do not need a local pull
- for manual switching, use `openclaw models list` and `openclaw models set ollama/<model>`
Security note: smaller or heavily quantized models are more vulnerable to prompt
injection. We strongly recommend **large models** for any bot that can use tools.

View File

@ -39,7 +39,7 @@ Notes:
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.9 \
APP_VERSION=2026.3.11 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
@ -47,10 +47,10 @@ scripts/package-mac-dist.sh
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.9.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.11.zip
# Optional: build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.9.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.11.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@ -58,13 +58,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.9.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.9 \
APP_VERSION=2026.3.11 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.9.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.11.dSYM.zip
```
## Appcast entry
@ -72,7 +72,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.9.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.11.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@ -80,7 +80,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.3.9.zip` (and `OpenClaw-2026.3.9.dSYM.zip`) to the GitHub release for tag `v2026.3.9`.
- Upload `OpenClaw-2026.3.11.zip` (and `OpenClaw-2026.3.11.dSYM.zip`) to the GitHub release for tag `v2026.3.11`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@ -153,30 +153,33 @@ sudo systemctl status openclaw
journalctl -u openclaw -f
```
## 9) Access the Dashboard
## 9) Access the OpenClaw Dashboard
Since the Pi is headless, use an SSH tunnel:
Replace `user@gateway-host` with your Pi username and hostname or IP address.
On your computer, ask the Pi to print a fresh dashboard URL:
```bash
# From your laptop/desktop
ssh -L 18789:localhost:18789 user@gateway-host
# Then open in browser
open http://localhost:18789
ssh user@gateway-host 'openclaw dashboard --no-open'
```
Or use Tailscale for always-on access:
The command prints `Dashboard URL:`. Depending on how `gateway.auth.token`
is configured, the URL may be a plain `http://127.0.0.1:18789/` link or one
that includes `#token=...`.
In another terminal on your computer, create the SSH tunnel:
```bash
# On the Pi
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Update config
openclaw config set gateway.bind tailnet
sudo systemctl restart openclaw
ssh -N -L 18789:127.0.0.1:18789 user@gateway-host
```
Then open the printed Dashboard URL in your local browser.
If the UI asks for auth, paste the token from `gateway.auth.token`
(or `OPENCLAW_GATEWAY_TOKEN`) into Control UI settings.
For always-on remote access, see [Tailscale](/gateway/tailscale).
---
## Performance Optimizations

View File

@ -8,7 +8,7 @@ title: "Ollama"
# Ollama
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supporting streaming and tool calling, and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supports streaming and tool calling, and can auto-discover local Ollama models when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
<Warning>
**Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`).
@ -16,21 +16,40 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo
## Quick start
1. Install Ollama: [https://ollama.ai](https://ollama.ai)
1. Install Ollama: [https://ollama.com/download](https://ollama.com/download)
2. Pull a model:
2. Pull a local model if you want local inference:
```bash
ollama pull glm-4.7-flash
# or
ollama pull gpt-oss:20b
# or
ollama pull llama3.3
# or
ollama pull qwen2.5-coder:32b
# or
ollama pull deepseek-r1:32b
```
3. Enable Ollama for OpenClaw (any value works; Ollama doesn't require a real key):
3. If you want Ollama Cloud models too, sign in:
```bash
ollama signin
```
4. Run onboarding and choose `Ollama`:
```bash
openclaw onboard
```
- `Local`: local models only
- `Cloud + Local`: local models plus Ollama Cloud models
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
OpenClaw currently suggests:
- local default: `glm-4.7-flash`
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`
5. If you prefer manual setup, enable Ollama for OpenClaw directly (any value works; Ollama doesn't require a real key):
```bash
# Set environment variable
@ -40,13 +59,20 @@ export OLLAMA_API_KEY="ollama-local"
openclaw config set models.providers.ollama.apiKey "ollama-local"
```
4. Use Ollama models:
6. Inspect or switch models:
```bash
openclaw models list
openclaw models set ollama/glm-4.7-flash
```
7. Or set the default in config:
```json5
{
agents: {
defaults: {
model: { primary: "ollama/gpt-oss:20b" },
model: { primary: "ollama/glm-4.7-flash" },
},
},
}
@ -56,14 +82,13 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama`, OpenClaw discovers models from the local Ollama instance at `http://127.0.0.1:11434`:
- Queries `/api/tags` and `/api/show`
- Keeps only models that report `tools` capability
- Marks `reasoning` when the model reports `thinking`
- Reads `contextWindow` from `model_info["<arch>.context_length"]` when available
- Sets `maxTokens` to 10× the context window
- Queries `/api/tags`
- Uses best-effort `/api/show` lookups to read `contextWindow` when available
- Marks `reasoning` with a model-name heuristic (`r1`, `reasoning`, `think`)
- Sets `maxTokens` to the default Ollama max-token cap used by OpenClaw
- Sets all costs to `0`
This avoids manual model entries while keeping the catalog aligned with Ollama's capabilities.
This avoids manual model entries while keeping the catalog aligned with the local Ollama instance.
To see what models are available:
@ -98,7 +123,7 @@ Use explicit config when:
- Ollama runs on another host/port.
- You want to force specific context windows or model lists.
- You want to include models that do not report tool support.
- You want fully manual model definitions.
```json5
{
@ -170,7 +195,7 @@ Once configured, all your Ollama models are available:
### Reasoning models
OpenClaw marks models as reasoning-capable when Ollama reports `thinking` in `/api/show`:
OpenClaw treats models with names such as `deepseek-r1`, `reasoning`, or `think` as reasoning-capable by default:
```bash
ollama pull deepseek-r1:32b
@ -230,7 +255,7 @@ When `api: "openai-completions"` is used with Ollama, OpenClaw injects `options.
### Context windows
For auto-discovered models, OpenClaw uses the context window reported by Ollama when available, otherwise it defaults to `8192`. You can override `contextWindow` and `maxTokens` in explicit provider config.
For auto-discovered models, OpenClaw uses the context window reported by Ollama when available, otherwise it falls back to the default Ollama context window used by OpenClaw. You can override `contextWindow` and `maxTokens` in explicit provider config.
## Troubleshooting
@ -250,16 +275,17 @@ curl http://localhost:11434/api/tags
### No models available
OpenClaw only auto-discovers models that report tool support. If your model isn't listed, either:
If your model is not listed, either:
- Pull a tool-capable model, or
- Pull the model locally, or
- Define the model explicitly in `models.providers.ollama`.
To add models:
```bash
ollama list # See what's installed
ollama pull gpt-oss:20b # Pull a tool-capable model
ollama pull glm-4.7-flash
ollama pull gpt-oss:20b
ollama pull llama3.3 # Or another model
```

View File

@ -1,10 +1,10 @@
{
"name": "@openclaw/acpx",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "0.1.16"
"acpx": "0.2.0"
},
"openclaw": {
"extensions": [

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"openclaw": {

View File

@ -1,12 +1,12 @@
{
"name": "@openclaw/feishu",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.59.0",
"@sinclair/typebox": "0.34.48",
"https-proxy-agent": "^7.0.6",
"https-proxy-agent": "^8.0.0",
"zod": "^4.3.6"
},
"openclaw": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@ -1,15 +1,12 @@
{
"name": "@openclaw/googlechat",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",
"dependencies": {
"google-auth-library": "^10.6.1"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.7"
},

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,12 +1,9 @@
{
"name": "@openclaw/memory-core",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.7"
},

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/synology-chat",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "Synology Chat channel plugin for OpenClaw",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Twitch channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.3.9",
"version": "2026.3.11",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,17 @@
# Changelog
## 2026.3.11
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.10
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.9
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.3.9",
"version": "2026.3.11",
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.3.9",
"version": "2026.3.11-beta.1",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@ -338,11 +338,11 @@
"ui:install": "node scripts/ui.js install"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.15.0",
"@aws-sdk/client-bedrock": "^3.1004.0",
"@agentclientprotocol/sdk": "0.16.1",
"@aws-sdk/client-bedrock": "^3.1007.0",
"@buape/carbon": "0.0.0-beta-20260216184201",
"@clack/prompts": "^1.1.0",
"@discordjs/voice": "^0.19.0",
"@discordjs/voice": "^0.19.1",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.5",
@ -364,13 +364,13 @@
"cli-highlight": "^2.1.11",
"commander": "^14.0.3",
"croner": "^10.0.1",
"discord-api-types": "^0.38.41",
"discord-api-types": "^0.38.42",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "^21.3.1",
"grammy": "^1.41.1",
"hono": "4.12.7",
"https-proxy-agent": "^7.0.6",
"https-proxy-agent": "^8.0.0",
"ipaddr.js": "^2.3.0",
"jiti": "^2.6.1",
"json5": "^2.2.3",
@ -399,18 +399,18 @@
"@lit/context": "^1.1.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.3.5",
"@types/node": "^25.4.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260308.1",
"@typescript/native-preview": "7.0.0-dev.20260311.1",
"@vitest/coverage-v8": "^4.0.18",
"jscpd": "4.0.8",
"lit": "^3.3.2",
"oxfmt": "0.36.0",
"oxlint": "^1.51.0",
"oxfmt": "0.38.0",
"oxlint": "^1.53.0",
"oxlint-tsgolint": "^0.16.0",
"signal-utils": "0.21.1",
"tsdown": "0.21.0",
"tsdown": "0.21.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"

1374
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -73,7 +73,7 @@ fi
if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
MARKETING_VERSION="${BASH_REMATCH[1]}"
else
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.9 or 2026.3.9-beta.1." >&2
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.11 or 2026.3.11-beta.1." >&2
exit 1
fi

View File

@ -44,11 +44,11 @@ import {
type TurnLatencyStats,
} from "./manager.types.js";
import {
canonicalizeAcpSessionKey,
createUnsupportedControlError,
hasLegacyAcpIdentityProjection,
normalizeAcpErrorCode,
normalizeActorKey,
normalizeSessionKey,
requireReadySessionMeta,
resolveAcpAgentFromSessionKey,
resolveAcpSessionResolutionError,
@ -87,7 +87,7 @@ export class AcpSessionManager {
constructor(private readonly deps: AcpSessionManagerDeps = DEFAULT_DEPS) {}
resolveSession(params: { cfg: OpenClawConfig; sessionKey: string }): AcpSessionResolution {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
return {
kind: "none",
@ -213,7 +213,10 @@ export class AcpSessionManager {
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
}> {
const sessionKey = normalizeSessionKey(input.sessionKey);
const sessionKey = canonicalizeAcpSessionKey({
cfg: input.cfg,
sessionKey: input.sessionKey,
});
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -321,7 +324,7 @@ export class AcpSessionManager {
sessionKey: string;
signal?: AbortSignal;
}): Promise<AcpSessionStatus> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -397,7 +400,7 @@ export class AcpSessionManager {
sessionKey: string;
runtimeMode: string;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -452,7 +455,7 @@ export class AcpSessionManager {
key: string;
value: string;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -525,7 +528,7 @@ export class AcpSessionManager {
sessionKey: string;
patch: Partial<AcpSessionRuntimeOptions>;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
const validatedPatch = validateRuntimeOptionPatch(params.patch);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
@ -555,7 +558,7 @@ export class AcpSessionManager {
cfg: OpenClawConfig;
sessionKey: string;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -591,7 +594,10 @@ export class AcpSessionManager {
}
async runTurn(input: AcpRunTurnInput): Promise<void> {
const sessionKey = normalizeSessionKey(input.sessionKey);
const sessionKey = canonicalizeAcpSessionKey({
cfg: input.cfg,
sessionKey: input.sessionKey,
});
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -738,7 +744,7 @@ export class AcpSessionManager {
sessionKey: string;
reason?: string;
}): Promise<void> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@ -806,7 +812,10 @@ export class AcpSessionManager {
}
async closeSession(input: AcpCloseSessionInput): Promise<AcpCloseSessionResult> {
const sessionKey = normalizeSessionKey(input.sessionKey);
const sessionKey = canonicalizeAcpSessionKey({
cfg: input.cfg,
sessionKey: input.sessionKey,
});
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}

View File

@ -170,6 +170,57 @@ describe("AcpSessionManager", () => {
expect(resolved.error.message).toContain("ACP metadata is missing");
});
it("canonicalizes the main alias before ACP rehydrate after restart", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey;
if (sessionKey !== "agent:main:main") {
return null;
}
return {
sessionKey,
storeSessionKey: sessionKey,
acp: {
...readySessionMeta(),
agent: "main",
runtimeSessionName: sessionKey,
},
};
});
const manager = new AcpSessionManager();
const cfg = {
...baseCfg,
session: { mainKey: "main" },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
await manager.runTurn({
cfg,
sessionKey: "main",
text: "after restart",
mode: "prompt",
requestId: "r-main",
});
expect(hoisted.readAcpSessionEntryMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
sessionKey: "agent:main:main",
}),
);
expect(runtimeState.ensureSession).toHaveBeenCalledWith(
expect.objectContaining({
agent: "main",
sessionKey: "agent:main:main",
}),
);
});
it("serializes concurrent turns for the same ACP session", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({

View File

@ -1,6 +1,14 @@
import type { OpenClawConfig } from "../../config/config.js";
import {
canonicalizeMainSessionAlias,
resolveMainSessionKey,
} from "../../config/sessions/main-session.js";
import type { SessionAcpMeta } from "../../config/sessions/types.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import {
normalizeAgentId,
normalizeMainKey,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
import type { AcpSessionResolution } from "./manager.types.js";
@ -42,6 +50,33 @@ export function normalizeSessionKey(sessionKey: string): string {
return sessionKey.trim();
}
export function canonicalizeAcpSessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): string {
const normalized = normalizeSessionKey(params.sessionKey);
if (!normalized) {
return "";
}
const lowered = normalized.toLowerCase();
if (lowered === "global" || lowered === "unknown") {
return lowered;
}
const parsed = parseAgentSessionKey(lowered);
if (parsed) {
return canonicalizeMainSessionAlias({
cfg: params.cfg,
agentId: parsed.agentId,
sessionKey: lowered,
});
}
const mainKey = normalizeMainKey(params.cfg.session?.mainKey);
if (lowered === "main" || lowered === mainKey) {
return resolveMainSessionKey(params.cfg);
}
return lowered;
}
export function normalizeActorKey(sessionKey: string): string {
return sessionKey.trim().toLowerCase();
}

View File

@ -52,7 +52,7 @@ function createSetSessionModeRequest(sessionId: string, modeId: string): SetSess
function createSetSessionConfigOptionRequest(
sessionId: string,
configId: string,
value: string,
value: string | boolean,
): SetSessionConfigOptionRequest {
return {
sessionId,
@ -644,6 +644,55 @@ describe("acp setSessionConfigOption bridge behavior", () => {
sessionStore.clearAllSessionsForTest();
});
it("rejects non-string ACP config option values", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const request = vi.fn(async (method: string) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "bool-config-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("bool-config-session"));
await expect(
agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("bool-config-session", "thought_level", false),
),
).rejects.toThrow(
'ACP bridge does not support non-string session config option values for "thought_level".',
);
expect(request).not.toHaveBeenCalledWith(
"sessions.patch",
expect.objectContaining({ key: "bool-config-session" }),
);
sessionStore.clearAllSessionsForTest();
});
});
describe("acp tool streaming bridge behavior", () => {

View File

@ -937,11 +937,16 @@ export class AcpGatewayAgent implements Agent {
private resolveSessionConfigPatch(
configId: string,
value: string,
value: string | boolean,
): {
overrides: Partial<GatewaySessionPresentationRow>;
patch: Record<string, string>;
} {
if (typeof value !== "string") {
throw new Error(
`ACP bridge does not support non-string session config option values for "${configId}".`,
);
}
switch (configId) {
case ACP_THOUGHT_LEVEL_CONFIG_ID:
return {

View File

@ -131,6 +131,113 @@ describe("memory search config", () => {
expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]);
});
it("normalizes multimodal settings", () => {
const cfg = asConfig({
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-2-preview",
multimodal: {
enabled: true,
modalities: ["all"],
maxFileBytes: 8192,
},
},
},
},
});
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.multimodal).toEqual({
enabled: true,
modalities: ["image", "audio"],
maxFileBytes: 8192,
});
});
it("keeps an explicit empty multimodal modalities list empty", () => {
const cfg = asConfig({
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-2-preview",
multimodal: {
enabled: true,
modalities: [],
},
},
},
},
});
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.multimodal).toEqual({
enabled: true,
modalities: [],
maxFileBytes: 10 * 1024 * 1024,
});
expect(resolved?.provider).toBe("gemini");
});
it("does not enforce multimodal provider validation when no modalities are active", () => {
const cfg = asConfig({
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
fallback: "openai",
multimodal: {
enabled: true,
modalities: [],
},
},
},
},
});
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.multimodal).toEqual({
enabled: true,
modalities: [],
maxFileBytes: 10 * 1024 * 1024,
});
});
it("rejects multimodal memory on unsupported providers", () => {
const cfg = asConfig({
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
multimodal: { enabled: true, modalities: ["image"] },
},
},
},
});
expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow(
/memorySearch\.multimodal requires memorySearch\.provider = "gemini"/,
);
});
it("rejects multimodal memory when fallback is configured", () => {
const cfg = asConfig({
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-2-preview",
fallback: "openai",
multimodal: { enabled: true, modalities: ["image"] },
},
},
},
});
expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow(
/memorySearch\.multimodal does not support memorySearch\.fallback/,
);
});
it("includes batch defaults for openai without remote overrides", () => {
const cfg = configWithDefaultProvider("openai");
const resolved = resolveMemorySearchConfig(cfg, "main");

View File

@ -3,6 +3,12 @@ import path from "node:path";
import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { SecretInput } from "../config/types.secrets.js";
import {
isMemoryMultimodalEnabled,
normalizeMemoryMultimodalSettings,
supportsMemoryMultimodalEmbeddings,
type MemoryMultimodalSettings,
} from "../memory/multimodal.js";
import { clampInt, clampNumber, resolveUserPath } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope.js";
@ -10,6 +16,7 @@ export type ResolvedMemorySearchConfig = {
enabled: boolean;
sources: Array<"memory" | "sessions">;
extraPaths: string[];
multimodal: MemoryMultimodalSettings;
provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto";
remote?: {
baseUrl?: string;
@ -204,6 +211,11 @@ function mergeConfig(
.map((value) => value.trim())
.filter(Boolean);
const extraPaths = Array.from(new Set(rawPaths));
const multimodal = normalizeMemoryMultimodalSettings({
enabled: overrides?.multimodal?.enabled ?? defaults?.multimodal?.enabled,
modalities: overrides?.multimodal?.modalities ?? defaults?.multimodal?.modalities,
maxFileBytes: overrides?.multimodal?.maxFileBytes ?? defaults?.multimodal?.maxFileBytes,
});
const vector = {
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
extensionPath:
@ -307,6 +319,7 @@ function mergeConfig(
enabled,
sources,
extraPaths,
multimodal,
provider,
remote,
experimental: {
@ -365,5 +378,22 @@ export function resolveMemorySearchConfig(
if (!resolved.enabled) {
return null;
}
const multimodalActive = isMemoryMultimodalEnabled(resolved.multimodal);
if (
multimodalActive &&
!supportsMemoryMultimodalEmbeddings({
provider: resolved.provider,
model: resolved.model,
})
) {
throw new Error(
'agents.*.memorySearch.multimodal requires memorySearch.provider = "gemini" and model = "gemini-embedding-2-preview".',
);
}
if (multimodalActive && resolved.fallback !== "none") {
throw new Error(
'agents.*.memorySearch.multimodal does not support memorySearch.fallback. Set fallback to "none".',
);
}
return resolved;
}

View File

@ -10,6 +10,7 @@ import {
} from "./huggingface-models.js";
import { discoverKilocodeModels } from "./kilocode-models.js";
import {
enrichOllamaModelsWithContext,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
OLLAMA_DEFAULT_COST,
OLLAMA_DEFAULT_MAX_TOKENS,
@ -46,38 +47,6 @@ type VllmModelsResponse = {
}>;
};
async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
): Promise<number | undefined> {
try {
const response = await fetch(`${apiBase}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
return undefined;
}
const data = (await response.json()) as { model_info?: Record<string, unknown> };
if (!data.model_info) {
return undefined;
}
for (const [key, value] of Object.entries(data.model_info)) {
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) {
const contextWindow = Math.floor(value);
if (contextWindow > 0) {
return contextWindow;
}
}
}
return undefined;
} catch {
return undefined;
}
}
async function discoverOllamaModels(
baseUrl?: string,
opts?: { quiet?: boolean },
@ -107,27 +76,18 @@ async function discoverOllamaModels(
`Capping Ollama /api/show inspection to ${OLLAMA_SHOW_MAX_MODELS} models (received ${data.models.length})`,
);
}
const discovered: ModelDefinitionConfig[] = [];
for (let index = 0; index < modelsToInspect.length; index += OLLAMA_SHOW_CONCURRENCY) {
const batch = modelsToInspect.slice(index, index + OLLAMA_SHOW_CONCURRENCY);
const batchDiscovered = await Promise.all(
batch.map(async (model) => {
const modelId = model.name;
const contextWindow = await queryOllamaContextWindow(apiBase, modelId);
return {
id: modelId,
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
}),
);
discovered.push(...batchDiscovered);
}
return discovered;
const discovered = await enrichOllamaModelsWithContext(apiBase, modelsToInspect, {
concurrency: OLLAMA_SHOW_CONCURRENCY,
});
return discovered.map((model) => ({
id: model.name,
name: model.name,
reasoning: isReasoningModelHeuristic(model.name),
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow: model.contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
}));
} catch (error) {
if (!opts?.quiet) {
log.warn(`Failed to discover Ollama models: ${String(error)}`);

View File

@ -429,6 +429,24 @@ export function buildOpenrouterProvider(): ProviderConfig {
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
},
{
id: "openrouter/hunter-alpha",
name: "Hunter Alpha",
reasoning: true,
input: ["text"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "openrouter/healer-alpha",
name: "Healer Alpha",
reasoning: true,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: 262144,
maxTokens: 65536,
},
],
};
}

View File

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
enrichOllamaModelsWithContext,
resolveOllamaApiBase,
type OllamaTagModel,
} from "./ollama-models.js";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function requestUrl(input: string | URL | Request): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
function requestBody(body: BodyInit | null | undefined): string {
return typeof body === "string" ? body : "{}";
}
describe("ollama-models", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("strips /v1 when resolving the Ollama API base", () => {
expect(resolveOllamaApiBase("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
});
it("enriches discovered models with context windows from /api/show", async () => {
const models: OllamaTagModel[] = [{ name: "llama3:8b" }, { name: "deepseek-r1:14b" }];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBody(init?.body)) as { name?: string };
if (body.name === "llama3:8b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched).toEqual([
{ name: "llama3:8b", contextWindow: 65536 },
{ name: "deepseek-r1:14b", contextWindow: undefined },
]);
});
});

View File

@ -27,6 +27,12 @@ export type OllamaTagsResponse = {
models?: OllamaTagModel[];
};
export type OllamaModelWithContext = OllamaTagModel & {
contextWindow?: number;
};
const OLLAMA_SHOW_CONCURRENCY = 8;
/**
* Derive the Ollama native API base URL from a configured base URL.
*
@ -43,6 +49,58 @@ export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
return trimmed.replace(/\/v1$/i, "");
}
export async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
): Promise<number | undefined> {
try {
const response = await fetch(`${apiBase}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
return undefined;
}
const data = (await response.json()) as { model_info?: Record<string, unknown> };
if (!data.model_info) {
return undefined;
}
for (const [key, value] of Object.entries(data.model_info)) {
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) {
const contextWindow = Math.floor(value);
if (contextWindow > 0) {
return contextWindow;
}
}
}
return undefined;
} catch {
return undefined;
}
}
export async function enrichOllamaModelsWithContext(
apiBase: string,
models: OllamaTagModel[],
opts?: { concurrency?: number },
): Promise<OllamaModelWithContext[]> {
const concurrency = Math.max(1, Math.floor(opts?.concurrency ?? OLLAMA_SHOW_CONCURRENCY));
const enriched: OllamaModelWithContext[] = [];
for (let index = 0; index < models.length; index += concurrency) {
const batch = models.slice(index, index + concurrency);
const batchResults = await Promise.all(
batch.map(async (model) => ({
...model,
contextWindow: await queryOllamaContextWindow(apiBase, model.name),
})),
);
enriched.push(...batchResults);
}
return enriched;
}
/** Heuristic: treat models with "r1", "reasoning", or "think" in the name as reasoning models. */
export function isReasoningModelHeuristic(modelId: string): boolean {
return /r1|reasoning|think|reason/i.test(modelId);

View File

@ -30,6 +30,13 @@ function extractInputTypes(input: unknown[]) {
.filter((t): t is string => typeof t === "string");
}
function extractInputMessages(input: unknown[]) {
return input.filter(
(item): item is Record<string, unknown> =>
!!item && typeof item === "object" && (item as Record<string, unknown>).type === "message",
);
}
const ZERO_USAGE = {
input: 0,
output: 0,
@ -184,4 +191,36 @@ describe("openai-responses reasoning replay", () => {
expect(types).toContain("reasoning");
expect(types).toContain("message");
});
it.each(["commentary", "final_answer"] as const)(
"replays assistant message phase metadata for %s",
async (phase) => {
const assistantWithText = buildAssistantMessage({
stopReason: "stop",
content: [
buildReasoningPart(),
{
type: "text",
text: "hello",
textSignature: JSON.stringify({ v: 1, id: `msg_${phase}`, phase }),
},
],
});
const { input, types } = await runAbortedOpenAIResponsesStream({
messages: [
{ role: "user", content: "Hi", timestamp: Date.now() },
assistantWithText,
{ role: "user", content: "Ok", timestamp: Date.now() },
],
});
expect(types).toContain("message");
const replayedMessage = extractInputMessages(input).find(
(item) => item.id === `msg_${phase}`,
);
expect(replayedMessage?.phase).toBe(phase);
},
);
});

View File

@ -595,14 +595,12 @@ describe("OpenAIWebSocketManager", () => {
manager.warmUp({
model: "gpt-5.2",
tools: [{ type: "function", function: { name: "exec", description: "Run a command" } }],
tools: [{ type: "function", name: "exec", description: "Run a command" }],
});
const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as Record<string, unknown>;
expect(sent["tools"]).toHaveLength(1);
expect((sent["tools"] as Array<{ function?: { name?: string } }>)[0]?.function?.name).toBe(
"exec",
);
expect((sent["tools"] as Array<{ name?: string }>)[0]?.name).toBe("exec");
});
});

View File

@ -37,12 +37,15 @@ export interface UsageInfo {
total_tokens: number;
}
export type OpenAIResponsesAssistantPhase = "commentary" | "final_answer";
export type OutputItem =
| {
type: "message";
id: string;
role: "assistant";
content: Array<{ type: "output_text"; text: string }>;
phase?: OpenAIResponsesAssistantPhase;
status?: "in_progress" | "completed";
}
| {
@ -190,6 +193,7 @@ export type InputItem =
type: "message";
role: "system" | "developer" | "user" | "assistant";
content: string | ContentPart[];
phase?: OpenAIResponsesAssistantPhase;
}
| { type: "function_call"; id?: string; call_id?: string; name: string; arguments: string }
| { type: "function_call_output"; call_id: string; output: string }
@ -204,11 +208,10 @@ export type ToolChoice =
export interface FunctionToolDefinition {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
name: string;
description?: string;
parameters?: Record<string, unknown>;
strict?: boolean;
}
/** Standard response.create event payload (full turn) */

View File

@ -14,6 +14,7 @@
* Skipped in CI no API key available and we avoid billable external calls.
*/
import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
import { describe, it, expect, afterEach } from "vitest";
import {
createOpenAIWebSocketStreamFn,
@ -28,14 +29,13 @@ const testFn = LIVE ? it : it.skip;
const model = {
api: "openai-responses" as const,
provider: "openai",
id: "gpt-4o-mini",
name: "gpt-4o-mini",
baseUrl: "",
reasoning: false,
input: { maxTokens: 128_000 },
output: { maxTokens: 16_384 },
cache: false,
compat: {},
id: "gpt-5.2",
name: "gpt-5.2",
contextWindow: 128_000,
maxTokens: 4_096,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
} as unknown as Parameters<ReturnType<typeof createOpenAIWebSocketStreamFn>>[0];
type StreamFnParams = Parameters<ReturnType<typeof createOpenAIWebSocketStreamFn>>;
@ -47,6 +47,61 @@ function makeContext(userMessage: string): StreamFnParams[1] {
} as unknown as StreamFnParams[1];
}
function makeToolContext(userMessage: string): StreamFnParams[1] {
return {
systemPrompt: "You are a precise assistant. Follow tool instructions exactly.",
messages: [{ role: "user" as const, content: userMessage }],
tools: [
{
name: "noop",
description: "Return the supplied tool result to the user.",
parameters: {
type: "object",
additionalProperties: false,
properties: {},
},
},
],
} as unknown as Context;
}
function makeToolResultMessage(
callId: string,
output: string,
): StreamFnParams[1]["messages"][number] {
return {
role: "toolResult" as const,
toolCallId: callId,
toolName: "noop",
content: [{ type: "text" as const, text: output }],
isError: false,
timestamp: Date.now(),
} as unknown as StreamFnParams[1]["messages"][number];
}
async function collectEvents(
stream: ReturnType<ReturnType<typeof createOpenAIWebSocketStreamFn>>,
): Promise<Array<{ type: string; message?: AssistantMessage }>> {
const events: Array<{ type: string; message?: AssistantMessage }> = [];
for await (const event of stream as AsyncIterable<{ type: string; message?: AssistantMessage }>) {
events.push(event);
}
return events;
}
function expectDone(events: Array<{ type: string; message?: AssistantMessage }>): AssistantMessage {
const done = events.find((event) => event.type === "done")?.message;
expect(done).toBeDefined();
return done!;
}
function assistantText(message: AssistantMessage): string {
return message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
/** Each test gets a unique session ID to avoid cross-test interference. */
const sessions: string[] = [];
function freshSession(name: string): string {
@ -68,26 +123,14 @@ describe("OpenAI WebSocket e2e", () => {
async () => {
const sid = freshSession("single");
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
const stream = streamFn(model, makeContext("What is 2+2?"), {});
const stream = streamFn(model, makeContext("What is 2+2?"), { transport: "websocket" });
const done = expectDone(await collectEvents(stream));
const events: Array<{ type: string }> = [];
for await (const event of stream as AsyncIterable<{ type: string }>) {
events.push(event);
}
const done = events.find((e) => e.type === "done") as
| { type: "done"; message: { content: Array<{ type: string; text?: string }> } }
| undefined;
expect(done).toBeDefined();
expect(done!.message.content.length).toBeGreaterThan(0);
const text = done!.message.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
expect(done.content.length).toBeGreaterThan(0);
const text = assistantText(done);
expect(text).toMatch(/4/);
},
30_000,
45_000,
);
testFn(
@ -96,19 +139,80 @@ describe("OpenAI WebSocket e2e", () => {
const sid = freshSession("temp");
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
const stream = streamFn(model, makeContext("Pick a random number between 1 and 1000."), {
transport: "websocket",
temperature: 0.8,
});
const events: Array<{ type: string }> = [];
for await (const event of stream as AsyncIterable<{ type: string }>) {
events.push(event);
}
const events = await collectEvents(stream);
// Stream must complete (done or error with fallback) — must NOT hang.
const hasTerminal = events.some((e) => e.type === "done" || e.type === "error");
expect(hasTerminal).toBe(true);
},
30_000,
45_000,
);
testFn(
"reuses the websocket session for tool-call follow-up turns",
async () => {
const sid = freshSession("tool-roundtrip");
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
const firstContext = makeToolContext(
"Call the tool `noop` with {}. After the tool result arrives, reply with exactly the tool output and nothing else.",
);
const firstEvents = await collectEvents(
streamFn(model, firstContext, {
transport: "websocket",
toolChoice: "required",
maxTokens: 128,
} as unknown as StreamFnParams[2]),
);
const firstDone = expectDone(firstEvents);
const toolCall = firstDone.content.find((block) => block.type === "toolCall") as
| { type: "toolCall"; id: string; name: string }
| undefined;
expect(toolCall?.name).toBe("noop");
expect(toolCall?.id).toBeTruthy();
const secondContext = {
...firstContext,
messages: [
...firstContext.messages,
firstDone,
makeToolResultMessage(toolCall!.id, "TOOL_OK"),
],
} as unknown as StreamFnParams[1];
const secondDone = expectDone(
await collectEvents(
streamFn(model, secondContext, {
transport: "websocket",
maxTokens: 128,
}),
),
);
expect(assistantText(secondDone)).toMatch(/TOOL_OK/);
},
60_000,
);
testFn(
"supports websocket warm-up before the first request",
async () => {
const sid = freshSession("warmup");
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
const done = expectDone(
await collectEvents(
streamFn(model, makeContext("Reply with the word warmed."), {
transport: "websocket",
openaiWsWarmup: true,
maxTokens: 32,
} as unknown as StreamFnParams[2]),
),
);
expect(assistantText(done).toLowerCase()).toContain("warmed");
},
45_000,
);
testFn(
@ -119,16 +223,13 @@ describe("OpenAI WebSocket e2e", () => {
expect(hasWsSession(sid)).toBe(false);
const stream = streamFn(model, makeContext("Say hello."), {});
for await (const _ of stream as AsyncIterable<unknown>) {
/* consume */
}
await collectEvents(streamFn(model, makeContext("Say hello."), { transport: "websocket" }));
expect(hasWsSession(sid)).toBe(true);
releaseWsSession(sid);
expect(hasWsSession(sid)).toBe(false);
},
30_000,
45_000,
);
testFn(
@ -137,15 +238,11 @@ describe("OpenAI WebSocket e2e", () => {
const sid = freshSession("fallback");
const streamFn = createOpenAIWebSocketStreamFn("sk-invalid-key", sid);
const stream = streamFn(model, makeContext("Hello"), {});
const events: Array<{ type: string }> = [];
for await (const event of stream as AsyncIterable<{ type: string }>) {
events.push(event);
}
const events = await collectEvents(stream);
const hasTerminal = events.some((e) => e.type === "done" || e.type === "error");
expect(hasTerminal).toBe(true);
},
30_000,
45_000,
);
});

View File

@ -224,6 +224,7 @@ type FakeMessage =
| {
role: "assistant";
content: unknown[];
phase?: "commentary" | "final_answer";
stopReason: string;
api: string;
provider: string;
@ -247,6 +248,7 @@ function userMsg(text: string): FakeMessage {
function assistantMsg(
textBlocks: string[],
toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }> = [],
phase?: "commentary" | "final_answer",
): FakeMessage {
const content: unknown[] = [];
for (const t of textBlocks) {
@ -258,6 +260,7 @@ function assistantMsg(
return {
role: "assistant",
content,
phase,
stopReason: toolCalls.length > 0 ? "toolUse" : "stop",
api: "openai-responses",
provider: "openai",
@ -302,6 +305,7 @@ function makeResponseObject(
id: string,
outputText?: string,
toolCallName?: string,
phase?: "commentary" | "final_answer",
): ResponseObject {
const output: ResponseObject["output"] = [];
if (outputText) {
@ -310,6 +314,7 @@ function makeResponseObject(
id: "item_1",
role: "assistant",
content: [{ type: "output_text", text: outputText }],
phase,
});
}
if (toolCallName) {
@ -357,18 +362,16 @@ describe("convertTools", () => {
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "function",
function: {
name: "exec",
description: "Run a command",
parameters: { type: "object", properties: { cmd: { type: "string" } } },
},
name: "exec",
description: "Run a command",
parameters: { type: "object", properties: { cmd: { type: "string" } } },
});
});
it("handles tools without description", () => {
const tools = [{ name: "ping", description: "", parameters: {} }];
const result = convertTools(tools as Parameters<typeof convertTools>[0]);
expect(result[0]?.function?.name).toBe("ping");
expect(result[0]?.name).toBe("ping");
});
});
@ -391,6 +394,19 @@ describe("convertMessagesToInputItems", () => {
expect(items[0]).toMatchObject({ type: "message", role: "assistant", content: "Hi there." });
});
it("preserves assistant phase on replayed assistant messages", () => {
const items = convertMessagesToInputItems([
assistantMsg(["Working on it."], [], "commentary"),
] as Parameters<typeof convertMessagesToInputItems>[0]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
type: "message",
role: "assistant",
content: "Working on it.",
phase: "commentary",
});
});
it("converts an assistant message with a tool call", () => {
const msg = assistantMsg(
["Let me run that."],
@ -408,10 +424,58 @@ describe("convertMessagesToInputItems", () => {
call_id: "call_1",
name: "exec",
});
expect(textItem).not.toHaveProperty("phase");
const fc = fcItem as { arguments: string };
expect(JSON.parse(fc.arguments)).toEqual({ cmd: "ls" });
});
it("preserves assistant phase on commentary text before tool calls", () => {
const msg = assistantMsg(
["Let me run that."],
[{ id: "call_1", name: "exec", args: { cmd: "ls" } }],
"commentary",
);
const items = convertMessagesToInputItems([msg] as Parameters<
typeof convertMessagesToInputItems
>[0]);
const textItem = items.find((i) => i.type === "message");
expect(textItem).toMatchObject({
type: "message",
role: "assistant",
content: "Let me run that.",
phase: "commentary",
});
});
it("preserves assistant phase from textSignature metadata without local phase field", () => {
const msg = {
role: "assistant" as const,
content: [
{
type: "text" as const,
text: "Working on it.",
textSignature: JSON.stringify({ v: 1, id: "msg_sig", phase: "commentary" }),
},
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
};
const items = convertMessagesToInputItems([msg] as Parameters<
typeof convertMessagesToInputItems
>[0]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
type: "message",
role: "assistant",
content: "Working on it.",
phase: "commentary",
});
});
it("converts a tool result message", () => {
const items = convertMessagesToInputItems([toolResultMsg("call_1", "file.txt")] as Parameters<
typeof convertMessagesToInputItems
@ -518,6 +582,34 @@ describe("convertMessagesToInputItems", () => {
expect((items[0] as { content?: unknown }).content).toBe("Here is my answer.");
});
it("replays reasoning blocks from thinking signatures", () => {
const msg = {
role: "assistant" as const,
content: [
{
type: "thinking" as const,
thinking: "internal reasoning...",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
},
{ type: "text" as const, text: "Here is my answer." },
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
};
const items = convertMessagesToInputItems([msg] as Parameters<
typeof convertMessagesToInputItems
>[0]);
expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]);
});
it("returns empty array for empty messages", () => {
expect(convertMessagesToInputItems([])).toEqual([]);
});
@ -594,6 +686,16 @@ describe("buildAssistantMessageFromResponse", () => {
expect(msg.content).toEqual([]);
expect(msg.stopReason).toBe("stop");
});
it("preserves phase from assistant message output items", () => {
const response = makeResponseObject("resp_8", "Final answer", undefined, "final_answer");
const msg = buildAssistantMessageFromResponse(response, modelInfo) as {
phase?: string;
content: Array<{ type: string; text?: string }>;
};
expect(msg.phase).toBe("final_answer");
expect(msg.content[0]?.text).toBe("Final answer");
});
});
// ─────────────────────────────────────────────────────────────────────────────
@ -633,6 +735,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
releaseWsSession("sess-fallback");
releaseWsSession("sess-incremental");
releaseWsSession("sess-full");
releaseWsSession("sess-phase");
releaseWsSession("sess-tools");
releaseWsSession("sess-store-default");
releaseWsSession("sess-store-compat");
@ -795,6 +898,40 @@ describe("createOpenAIWebSocketStreamFn", () => {
expect(doneEvent?.message.content[0]?.text).toBe("Hello back!");
});
it("keeps assistant phase on completed WebSocket responses", async () => {
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase");
const stream = streamFn(
modelStub as Parameters<typeof streamFn>[0],
contextStub as Parameters<typeof streamFn>[1],
);
const events: unknown[] = [];
const done = (async () => {
for await (const ev of await resolveStream(stream)) {
events.push(ev);
}
})();
await new Promise((r) => setImmediate(r));
const manager = MockManager.lastInstance!;
manager.simulateEvent({
type: "response.completed",
response: makeResponseObject("resp_phase", "Working...", "exec", "commentary"),
});
await done;
const doneEvent = events.find((e) => (e as { type?: string }).type === "done") as
| {
type: string;
reason: string;
message: { phase?: string; stopReason: string };
}
| undefined;
expect(doneEvent?.message.phase).toBe("commentary");
expect(doneEvent?.message.stopReason).toBe("toolUse");
});
it("falls back to HTTP when WebSocket connect fails (session pre-broken via flag)", async () => {
// Set the class-level flag BEFORE calling streamFn so the new instance
// fails on connect(). We patch the static default via MockManager directly.

View File

@ -37,6 +37,7 @@ import {
type ContentPart,
type FunctionToolDefinition,
type InputItem,
type OpenAIResponsesAssistantPhase,
type OpenAIWebSocketManagerOptions,
type ResponseObject,
} from "./openai-ws-connection.js";
@ -100,6 +101,8 @@ export function hasWsSession(sessionId: string): boolean {
// ─────────────────────────────────────────────────────────────────────────────
type AnyMessage = Message & { role: string; content: unknown };
type AssistantMessageWithPhase = AssistantMessage & { phase?: OpenAIResponsesAssistantPhase };
type ReplayModelInfo = { input?: ReadonlyArray<string> };
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") {
@ -109,6 +112,50 @@ function toNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function normalizeAssistantPhase(value: unknown): OpenAIResponsesAssistantPhase | undefined {
return value === "commentary" || value === "final_answer" ? value : undefined;
}
function encodeAssistantTextSignature(params: {
id: string;
phase?: OpenAIResponsesAssistantPhase;
}): string {
return JSON.stringify({
v: 1,
id: params.id,
...(params.phase ? { phase: params.phase } : {}),
});
}
function parseAssistantTextSignature(
value: unknown,
): { id: string; phase?: OpenAIResponsesAssistantPhase } | null {
if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
if (!value.startsWith("{")) {
return { id: value };
}
try {
const parsed = JSON.parse(value) as { v?: unknown; id?: unknown; phase?: unknown };
if (parsed.v !== 1 || typeof parsed.id !== "string") {
return null;
}
return {
id: parsed.id,
...(normalizeAssistantPhase(parsed.phase)
? { phase: normalizeAssistantPhase(parsed.phase) }
: {}),
};
} catch {
return null;
}
}
function supportsImageInput(modelOverride?: ReplayModelInfo): boolean {
return !Array.isArray(modelOverride?.input) || modelOverride.input.includes("image");
}
/** Convert pi-ai content (string | ContentPart[]) to plain text. */
function contentToText(content: unknown): string {
if (typeof content === "string") {
@ -117,30 +164,50 @@ function contentToText(content: unknown): string {
if (!Array.isArray(content)) {
return "";
}
return (content as Array<{ type?: string; text?: string }>)
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text as string)
return content
.filter(
(part): part is { type?: string; text?: string } => Boolean(part) && typeof part === "object",
)
.filter(
(part) =>
(part.type === "text" || part.type === "input_text" || part.type === "output_text") &&
typeof part.text === "string",
)
.map((part) => part.text as string)
.join("");
}
/** Convert pi-ai content to OpenAI ContentPart[]. */
function contentToOpenAIParts(content: unknown): ContentPart[] {
function contentToOpenAIParts(content: unknown, modelOverride?: ReplayModelInfo): ContentPart[] {
if (typeof content === "string") {
return content ? [{ type: "input_text", text: content }] : [];
}
if (!Array.isArray(content)) {
return [];
}
const includeImages = supportsImageInput(modelOverride);
const parts: ContentPart[] = [];
for (const part of content as Array<{
type?: string;
text?: string;
data?: string;
mimeType?: string;
source?: unknown;
}>) {
if (part.type === "text" && typeof part.text === "string") {
if (
(part.type === "text" || part.type === "input_text" || part.type === "output_text") &&
typeof part.text === "string"
) {
parts.push({ type: "input_text", text: part.text });
} else if (part.type === "image" && typeof part.data === "string") {
continue;
}
if (!includeImages) {
continue;
}
if (part.type === "image" && typeof part.data === "string") {
parts.push({
type: "input_image",
source: {
@ -149,11 +216,60 @@ function contentToOpenAIParts(content: unknown): ContentPart[] {
data: part.data,
},
});
continue;
}
if (
part.type === "input_image" &&
part.source &&
typeof part.source === "object" &&
typeof (part.source as { type?: unknown }).type === "string"
) {
parts.push({
type: "input_image",
source: part.source as
| { type: "url"; url: string }
| { type: "base64"; media_type: string; data: string },
});
}
}
return parts;
}
function parseReasoningItem(value: unknown): Extract<InputItem, { type: "reasoning" }> | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as {
type?: unknown;
content?: unknown;
encrypted_content?: unknown;
summary?: unknown;
};
if (record.type !== "reasoning") {
return null;
}
return {
type: "reasoning",
...(typeof record.content === "string" ? { content: record.content } : {}),
...(typeof record.encrypted_content === "string"
? { encrypted_content: record.encrypted_content }
: {}),
...(typeof record.summary === "string" ? { summary: record.summary } : {}),
};
}
function parseThinkingSignature(value: unknown): Extract<InputItem, { type: "reasoning" }> | null {
if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
try {
return parseReasoningItem(JSON.parse(value));
} catch {
return null;
}
}
/** Convert pi-ai tool array to OpenAI FunctionToolDefinition[]. */
export function convertTools(tools: Context["tools"]): FunctionToolDefinition[] {
if (!tools || tools.length === 0) {
@ -161,11 +277,9 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[]
}
return tools.map((tool) => ({
type: "function" as const,
function: {
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
},
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
}));
}
@ -173,14 +287,24 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[]
* Convert the full pi-ai message history to an OpenAI `input` array.
* Handles user messages, assistant text+tool-call messages, and tool results.
*/
export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
export function convertMessagesToInputItems(
messages: Message[],
modelOverride?: ReplayModelInfo,
): InputItem[] {
const items: InputItem[] = [];
for (const msg of messages) {
const m = msg as AnyMessage;
const m = msg as AnyMessage & {
phase?: unknown;
toolCallId?: unknown;
toolUseId?: unknown;
};
if (m.role === "user") {
const parts = contentToOpenAIParts(m.content);
const parts = contentToOpenAIParts(m.content, modelOverride);
if (parts.length === 0) {
continue;
}
items.push({
type: "message",
role: "user",
@ -194,87 +318,116 @@ export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
if (m.role === "assistant") {
const content = m.content;
let assistantPhase = normalizeAssistantPhase(m.phase);
if (Array.isArray(content)) {
// Collect text blocks and tool calls separately
const textParts: string[] = [];
for (const block of content as Array<{
type?: string;
text?: string;
id?: string;
name?: string;
arguments?: Record<string, unknown>;
thinking?: string;
}>) {
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
} else if (block.type === "thinking" && typeof block.thinking === "string") {
// Skip thinking blocks — not sent back to the model
} else if (block.type === "toolCall") {
// Push accumulated text first
if (textParts.length > 0) {
items.push({
type: "message",
role: "assistant",
content: textParts.join(""),
});
textParts.length = 0;
}
const callId = toNonEmptyString(block.id);
const toolName = toNonEmptyString(block.name);
if (!callId || !toolName) {
continue;
}
// Push function_call item
items.push({
type: "function_call",
call_id: callId,
name: toolName,
arguments:
typeof block.arguments === "string"
? block.arguments
: JSON.stringify(block.arguments ?? {}),
});
const pushAssistantText = () => {
if (textParts.length === 0) {
return;
}
}
if (textParts.length > 0) {
items.push({
type: "message",
role: "assistant",
content: textParts.join(""),
...(assistantPhase ? { phase: assistantPhase } : {}),
});
}
} else {
const text = contentToText(m.content);
if (text) {
textParts.length = 0;
};
for (const block of content as Array<{
type?: string;
text?: string;
textSignature?: unknown;
id?: unknown;
name?: unknown;
arguments?: unknown;
thinkingSignature?: unknown;
}>) {
if (block.type === "text" && typeof block.text === "string") {
const parsedSignature = parseAssistantTextSignature(block.textSignature);
if (!assistantPhase) {
assistantPhase = parsedSignature?.phase;
}
textParts.push(block.text);
continue;
}
if (block.type === "thinking") {
pushAssistantText();
const reasoningItem = parseThinkingSignature(block.thinkingSignature);
if (reasoningItem) {
items.push(reasoningItem);
}
continue;
}
if (block.type !== "toolCall") {
continue;
}
pushAssistantText();
const callIdRaw = toNonEmptyString(block.id);
const toolName = toNonEmptyString(block.name);
if (!callIdRaw || !toolName) {
continue;
}
const [callId, itemId] = callIdRaw.split("|", 2);
items.push({
type: "message",
role: "assistant",
content: text,
type: "function_call",
...(itemId ? { id: itemId } : {}),
call_id: callId,
name: toolName,
arguments:
typeof block.arguments === "string"
? block.arguments
: JSON.stringify(block.arguments ?? {}),
});
}
pushAssistantText();
continue;
}
const text = contentToText(content);
if (!text) {
continue;
}
items.push({
type: "message",
role: "assistant",
content: text,
...(assistantPhase ? { phase: assistantPhase } : {}),
});
continue;
}
if (m.role === "toolResult") {
const tr = m as unknown as {
toolCallId?: string;
toolUseId?: string;
content: unknown;
isError: boolean;
};
const callId = toNonEmptyString(tr.toolCallId) ?? toNonEmptyString(tr.toolUseId);
if (!callId) {
continue;
}
const outputText = contentToText(tr.content);
items.push({
type: "function_call_output",
call_id: callId,
output: outputText,
});
if (m.role !== "toolResult") {
continue;
}
const toolCallId = toNonEmptyString(m.toolCallId) ?? toNonEmptyString(m.toolUseId);
if (!toolCallId) {
continue;
}
const [callId] = toolCallId.split("|", 2);
const parts = Array.isArray(m.content) ? contentToOpenAIParts(m.content, modelOverride) : [];
const textOutput = contentToText(m.content);
const imageParts = parts.filter((part) => part.type === "input_image");
items.push({
type: "function_call_output",
call_id: callId,
output: textOutput || (imageParts.length > 0 ? "(see attached image)" : ""),
});
if (imageParts.length > 0) {
items.push({
type: "message",
role: "user",
content: [
{ type: "input_text", text: "Attached image(s) from tool result:" },
...imageParts,
],
});
}
}
return items;
@ -289,12 +442,24 @@ export function buildAssistantMessageFromResponse(
modelInfo: { api: string; provider: string; id: string },
): AssistantMessage {
const content: (TextContent | ToolCall)[] = [];
let assistantPhase: OpenAIResponsesAssistantPhase | undefined;
for (const item of response.output ?? []) {
if (item.type === "message") {
const itemPhase = normalizeAssistantPhase(item.phase);
if (itemPhase) {
assistantPhase = itemPhase;
}
for (const part of item.content ?? []) {
if (part.type === "output_text" && part.text) {
content.push({ type: "text", text: part.text });
content.push({
type: "text",
text: part.text,
textSignature: encodeAssistantTextSignature({
id: item.id,
...(itemPhase ? { phase: itemPhase } : {}),
}),
});
}
}
} else if (item.type === "function_call") {
@ -321,7 +486,7 @@ export function buildAssistantMessageFromResponse(
const hasToolCalls = content.some((c) => c.type === "toolCall");
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
return buildAssistantMessage({
const message = buildAssistantMessage({
model: modelInfo,
content,
stopReason,
@ -331,6 +496,10 @@ export function buildAssistantMessageFromResponse(
totalTokens: response.usage?.total_tokens ?? 0,
}),
});
return assistantPhase
? ({ ...message, phase: assistantPhase } as AssistantMessageWithPhase)
: message;
}
// ─────────────────────────────────────────────────────────────────────────────
@ -504,6 +673,7 @@ export function createOpenAIWebSocketStreamFn(
if (resolveWsWarmup(options) && !session.warmUpAttempted) {
session.warmUpAttempted = true;
let warmupFailed = false;
try {
await runWarmUp({
manager: session.manager,
@ -517,10 +687,33 @@ export function createOpenAIWebSocketStreamFn(
if (signal?.aborted) {
throw warmErr instanceof Error ? warmErr : new Error(String(warmErr));
}
warmupFailed = true;
log.warn(
`[ws-stream] warm-up failed for session=${sessionId}; continuing without warm-up. error=${String(warmErr)}`,
);
}
if (warmupFailed && !session.manager.isConnected()) {
try {
session.manager.close();
} catch {
/* ignore */
}
try {
await session.manager.connect(apiKey);
session.everConnected = true;
log.debug(`[ws-stream] reconnected after warm-up failure for session=${sessionId}`);
} catch (reconnectErr) {
session.broken = true;
wsRegistry.delete(sessionId);
if (transport === "websocket") {
throw reconnectErr instanceof Error ? reconnectErr : new Error(String(reconnectErr));
}
log.warn(
`[ws-stream] reconnect after warm-up failed for session=${sessionId}; falling back to HTTP. error=${String(reconnectErr)}`,
);
return fallbackToHttp(model, context, options, eventStream, opts.signal);
}
}
}
// ── 3. Compute incremental vs full input ─────────────────────────────
@ -537,16 +730,16 @@ export function createOpenAIWebSocketStreamFn(
log.debug(
`[ws-stream] session=${sessionId}: no new tool results found; sending full context`,
);
inputItems = buildFullInput(context);
inputItems = buildFullInput(context, model);
} else {
inputItems = convertMessagesToInputItems(toolResults);
inputItems = convertMessagesToInputItems(toolResults, model);
}
log.debug(
`[ws-stream] session=${sessionId}: incremental send (${inputItems.length} tool results) previous_response_id=${prevResponseId}`,
);
} else {
// First turn: send full context
inputItems = buildFullInput(context);
inputItems = buildFullInput(context, model);
log.debug(
`[ws-stream] session=${sessionId}: full context send (${inputItems.length} items)`,
);
@ -605,10 +798,9 @@ export function createOpenAIWebSocketStreamFn(
...extraParams,
};
const nextPayload = await options?.onPayload?.(payload, model);
const requestPayload =
nextPayload && typeof nextPayload === "object"
? (nextPayload as Parameters<OpenAIWebSocketManager["send"]>[0])
: (payload as Parameters<OpenAIWebSocketManager["send"]>[0]);
const requestPayload = (nextPayload ?? payload) as Parameters<
OpenAIWebSocketManager["send"]
>[0];
try {
session.manager.send(requestPayload);
@ -734,8 +926,8 @@ export function createOpenAIWebSocketStreamFn(
// ─────────────────────────────────────────────────────────────────────────────
/** Build full input items from context (system prompt is passed via `instructions` field). */
function buildFullInput(context: Context): InputItem[] {
return convertMessagesToInputItems(context.messages);
function buildFullInput(context: Context, model: ReplayModelInfo): InputItem[] {
return convertMessagesToInputItems(context.messages, model);
}
/**

View File

@ -106,6 +106,9 @@ describe("isBillingErrorMessage", () => {
"Payment Required",
"HTTP 402 Payment Required",
"plans & billing",
// Venice returns "Insufficient USD or Diem balance" which has extra words
// between "insufficient" and "balance"
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
];
for (const sample of samples) {
expect(isBillingErrorMessage(sample)).toBe(true);
@ -149,6 +152,11 @@ describe("isBillingErrorMessage", () => {
expect(longResponse.length).toBeGreaterThan(512);
expect(isBillingErrorMessage(longResponse)).toBe(false);
});
it("does not false-positive on short non-billing text that mentions insufficient and balance", () => {
const sample = "The evidence is insufficient to reconcile the final balance after compaction.";
expect(isBillingErrorMessage(sample)).toBe(false);
expect(classifyFailoverReason(sample)).toBeNull();
});
it("still matches explicit 402 markers in long payloads", () => {
const longStructuredError =
'{"error":{"code":402,"message":"payment required","details":"' + "x".repeat(700) + '"}}';
@ -650,6 +658,12 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason(TOGETHER_ENGINE_OVERLOADED_MESSAGE)).toBe("overloaded");
expect(classifyFailoverReason(GROQ_TOO_MANY_REQUESTS_MESSAGE)).toBe("rate_limit");
expect(classifyFailoverReason(GROQ_SERVICE_UNAVAILABLE_MESSAGE)).toBe("overloaded");
// Venice 402 billing error with extra words between "insufficient" and "balance"
expect(
classifyFailoverReason(
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
),
).toBe("billing");
});
it("classifies internal and compatibility error messages", () => {

View File

@ -52,6 +52,7 @@ const ERROR_PATTERNS = {
"credit balance",
"plans & billing",
"insufficient balance",
"insufficient usd or diem balance",
],
authPermanent: [
/api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,

View File

@ -276,7 +276,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { model: "deepseek/deepseek-r1" };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -308,7 +308,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -332,7 +332,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { reasoning_effort: "high" };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -357,7 +357,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { reasoning: { max_tokens: 256 } };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -381,7 +381,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { reasoning_effort: "medium" };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -588,7 +588,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { thinking: "off" };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -619,7 +619,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { thinking: "off" };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -650,7 +650,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -674,7 +674,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { tool_choice: "required" };
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -699,7 +699,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -749,7 +749,7 @@ describe("applyExtraParamsToAgent", () => {
],
tool_choice: { type: "tool", name: "read" },
};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -793,7 +793,7 @@ describe("applyExtraParamsToAgent", () => {
},
],
};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -832,7 +832,7 @@ describe("applyExtraParamsToAgent", () => {
},
],
};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -896,7 +896,7 @@ describe("applyExtraParamsToAgent", () => {
},
},
};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -943,7 +943,7 @@ describe("applyExtraParamsToAgent", () => {
},
},
};
options?.onPayload?.(payload, _model);
options?.onPayload?.(payload, model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
@ -1081,7 +1081,7 @@ describe("applyExtraParamsToAgent", () => {
expect(calls).toHaveLength(1);
expect(calls[0]?.transport).toBe("auto");
expect(calls[0]?.openaiWsWarmup).toBe(true);
expect(calls[0]?.openaiWsWarmup).toBe(false);
});
it("lets runtime options override OpenAI default transport", () => {

View File

@ -7,6 +7,7 @@ const {
sessionCompactImpl,
triggerInternalHook,
sanitizeSessionHistoryMock,
contextEngineCompactMock,
} = vi.hoisted(() => ({
hookRunner: {
hasHooks: vi.fn(),
@ -28,6 +29,14 @@ const {
})),
triggerInternalHook: vi.fn(),
sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages),
contextEngineCompactMock: vi.fn(async () => ({
ok: true as boolean,
compacted: true as boolean,
reason: undefined as string | undefined,
result: { summary: "engine-summary", tokensAfter: 50 } as
| { summary: string; tokensAfter: number }
| undefined,
})),
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
@ -123,6 +132,27 @@ vi.mock("../session-write-lock.js", () => ({
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
}));
vi.mock("../../context-engine/index.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(async () => ({
info: { ownsCompaction: true },
compact: contextEngineCompactMock,
})),
}));
vi.mock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()),
}));
vi.mock("./lanes.js", () => ({
resolveSessionLane: vi.fn(() => "test-session-lane"),
resolveGlobalLane: vi.fn(() => "test-global-lane"),
}));
vi.mock("../context-window-guard.js", () => ({
resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })),
}));
vi.mock("../bootstrap-files.js", () => ({
makeBootstrapWarn: vi.fn(() => () => {}),
resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })),
@ -160,7 +190,7 @@ vi.mock("../transcript-policy.js", () => ({
}));
vi.mock("./extensions.js", () => ({
buildEmbeddedExtensionFactories: vi.fn(() => []),
buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })),
}));
vi.mock("./history.js", () => ({
@ -251,7 +281,7 @@ vi.mock("./utils.js", () => ({
import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai";
import { getCustomApiRegistrySourceId } from "../custom-api-registry.js";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js";
const sessionHook = (action: string) =>
triggerInternalHook.mock.calls.find(
@ -436,3 +466,103 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
expect(result.ok).toBe(true);
});
});
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
beforeEach(() => {
hookRunner.hasHooks.mockReset();
hookRunner.runBeforeCompaction.mockReset();
hookRunner.runAfterCompaction.mockReset();
contextEngineCompactMock.mockReset();
contextEngineCompactMock.mockResolvedValue({
ok: true,
compacted: true,
reason: undefined,
result: { summary: "engine-summary", tokensAfter: 50 },
});
resolveModelMock.mockReset();
resolveModelMock.mockReturnValue({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
});
});
it("fires before_compaction with sentinel -1 and after_compaction on success", async () => {
hookRunner.hasHooks.mockReturnValue(true);
const result = await compactEmbeddedPiSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
messageChannel: "telegram",
customInstructions: "focus on decisions",
enqueue: (task) => task(),
});
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
{ messageCount: -1, sessionFile: "/tmp/session.jsonl" },
expect.objectContaining({
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
}),
);
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
{
messageCount: -1,
compactedCount: -1,
tokenCount: 50,
sessionFile: "/tmp/session.jsonl",
},
expect.objectContaining({
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
}),
);
});
it("does not fire after_compaction when compaction fails", async () => {
hookRunner.hasHooks.mockReturnValue(true);
contextEngineCompactMock.mockResolvedValue({
ok: false,
compacted: false,
reason: "nothing to compact",
result: undefined,
});
const result = await compactEmbeddedPiSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
customInstructions: "focus on decisions",
enqueue: (task) => task(),
});
expect(result.ok).toBe(false);
expect(hookRunner.runBeforeCompaction).toHaveBeenCalled();
expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled();
});
it("catches and logs hook exceptions without aborting compaction", async () => {
hookRunner.hasHooks.mockReturnValue(true);
hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom"));
const result = await compactEmbeddedPiSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
customInstructions: "focus on decisions",
enqueue: (task) => task(),
});
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(contextEngineCompactMock).toHaveBeenCalled();
});
});

View File

@ -936,6 +936,43 @@ export async function compactEmbeddedPiSession(
modelContextWindow: ceModel?.contextWindow,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
// When the context engine owns compaction, its compact() implementation
// bypasses compactEmbeddedPiSessionDirect (which fires the hooks internally).
// Fire before_compaction / after_compaction hooks here so plugin subscribers
// are notified regardless of which engine is active.
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
const hookRunner = engineOwnsCompaction ? getGlobalHookRunner() : null;
const hookSessionKey = params.sessionKey?.trim() || params.sessionId;
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const resolvedMessageProvider = params.messageChannel ?? params.messageProvider;
const hookCtx = {
sessionId: params.sessionId,
agentId: sessionAgentId,
sessionKey: hookSessionKey,
workspaceDir: resolveUserPath(params.workspaceDir),
messageProvider: resolvedMessageProvider,
};
// Engine-owned compaction doesn't load the transcript at this level, so
// message counts are unavailable. We pass sessionFile so hook subscribers
// can read the transcript themselves if they need exact counts.
if (hookRunner?.hasHooks("before_compaction")) {
try {
await hookRunner.runBeforeCompaction(
{
messageCount: -1,
sessionFile: params.sessionFile,
},
hookCtx,
);
} catch (err) {
log.warn("before_compaction hook failed", {
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
const result = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
@ -944,6 +981,23 @@ export async function compactEmbeddedPiSession(
force: params.trigger === "manual",
runtimeContext: params as Record<string, unknown>,
});
if (result.ok && result.compacted && hookRunner?.hasHooks("after_compaction")) {
try {
await hookRunner.runAfterCompaction(
{
messageCount: -1,
compactedCount: -1,
tokenCount: result.result?.tokensAfter,
sessionFile: params.sessionFile,
},
hookCtx,
);
} catch (err) {
log.warn("after_compaction hook failed", {
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
return {
ok: result.ok,
compacted: result.compacted,

View File

@ -382,6 +382,40 @@ describe("resolveModel", () => {
expect(result.model?.reasoning).toBe(true);
});
it("matches prefixed OpenRouter native ids in configured fallback models", () => {
const cfg = {
models: {
providers: {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
api: "openai-completions",
models: [
{
...makeModel("openrouter/healer-alpha"),
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 65536,
},
],
},
},
},
} as OpenClawConfig;
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openrouter",
id: "openrouter/healer-alpha",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 65536,
});
});
it("prefers configured provider api metadata over discovered registry model", () => {
mockDiscoveredModel({
provider: "onehub",

View File

@ -250,7 +250,7 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und
const mergedOptions = {
...options,
transport: options?.transport ?? "auto",
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true,
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? false,
} as SimpleStreamOptions;
return underlying(model, context, mergedOptions);
};

View File

@ -9,16 +9,18 @@ export function makeOverflowError(message: string = DEFAULT_OVERFLOW_ERROR_MESSA
export function makeCompactionSuccess(params: {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
}) {
return {
ok: true as const,
compacted: true as const,
result: {
summary: params.summary,
firstKeptEntryId: params.firstKeptEntryId,
tokensBefore: params.tokensBefore,
...(params.firstKeptEntryId ? { firstKeptEntryId: params.firstKeptEntryId } : {}),
...(params.tokensBefore !== undefined ? { tokensBefore: params.tokensBefore } : {}),
...(params.tokensAfter !== undefined ? { tokensAfter: params.tokensAfter } : {}),
},
};
}
@ -55,8 +57,9 @@ type MockCompactDirect = {
compacted: true;
result: {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
};
}) => unknown;
};

View File

@ -2,9 +2,13 @@ import "./run.overflow-compaction.mocks.shared.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js";
vi.mock("../../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));
vi.mock(import("../../utils.js"), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
resolveUserPath: vi.fn((p: string) => p),
};
});
import { log } from "./logger.js";
import { runEmbeddedPiAgent } from "./run.js";
@ -16,6 +20,7 @@ import {
queueOverflowAttemptWithOversizedToolOutput,
} from "./run.overflow-compaction.fixture.js";
import {
mockedContextEngine,
mockedCompactDirect,
mockedRunEmbeddedAttempt,
mockedSessionLikelyHasOversizedToolResults,
@ -30,6 +35,11 @@ const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowErro
describe("overflow compaction in run loop", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedRunEmbeddedAttempt.mockReset();
mockedCompactDirect.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedContextEngine.info.ownsCompaction = false;
mockedIsCompactionFailureError.mockImplementation((msg?: string) => {
if (!msg) {
return false;
@ -72,7 +82,9 @@ describe("overflow compaction in run loop", () => {
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedCompactDirect).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "test-profile" }),
expect.objectContaining({
runtimeContext: expect.objectContaining({ authProfileId: "test-profile" }),
}),
);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(log.warn).toHaveBeenCalledWith(

View File

@ -6,6 +6,25 @@ import type {
PluginHookBeforePromptBuildResult,
} from "../../plugins/types.js";
type MockCompactionResult =
| {
ok: true;
compacted: true;
result: {
summary: string;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
};
reason?: string;
}
| {
ok: false;
compacted: false;
reason: string;
result?: undefined;
};
export const mockedGlobalHookRunner = {
hasHooks: vi.fn((_hookName: string) => false),
runBeforeAgentStart: vi.fn(
@ -26,12 +45,35 @@ export const mockedGlobalHookRunner = {
_ctx: PluginHookAgentContext,
): Promise<PluginHookBeforeModelResolveResult | undefined> => undefined,
),
runBeforeCompaction: vi.fn(async () => undefined),
runAfterCompaction: vi.fn(async () => undefined),
};
export const mockedContextEngine = {
info: { ownsCompaction: false as boolean },
compact: vi.fn<(params: unknown) => Promise<MockCompactionResult>>(async () => ({
ok: false as const,
compacted: false as const,
reason: "nothing to compact",
})),
};
export const mockedContextEngineCompact = vi.mocked(mockedContextEngine.compact);
export const mockedEnsureRuntimePluginsLoaded: (...args: unknown[]) => void = vi.fn();
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner),
}));
vi.mock("../../context-engine/index.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(async () => mockedContextEngine),
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded,
}));
vi.mock("../auth-profiles.js", () => ({
isProfileInCooldown: vi.fn(() => false),
markAuthProfileFailure: vi.fn(async () => {}),
@ -141,9 +183,13 @@ vi.mock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
}));
vi.mock("../../utils/message-channel.js", () => ({
isMarkdownCapableMessageChannel: vi.fn(() => true),
}));
vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
isMarkdownCapableMessageChannel: vi.fn(() => true),
};
});
vi.mock("../agent-paths.js", () => ({
resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"),

View File

@ -1,5 +1,8 @@
import { vi } from "vitest";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import {
mockedContextEngine,
mockedContextEngineCompact,
} from "./run.overflow-compaction.mocks.shared.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
import {
sessionLikelyHasOversizedToolResults,
@ -7,13 +10,14 @@ import {
} from "./tool-result-truncation.js";
export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
export const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
export const mockedCompactDirect = mockedContextEngineCompact;
export const mockedSessionLikelyHasOversizedToolResults = vi.mocked(
sessionLikelyHasOversizedToolResults,
);
export const mockedTruncateOversizedToolResultsInSession = vi.mocked(
truncateOversizedToolResultsInSession,
);
export { mockedContextEngine };
export const overflowBaseRunParams = {
sessionId: "test-session",

View File

@ -11,6 +11,7 @@ import {
} from "./run.overflow-compaction.fixture.js";
import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js";
import {
mockedContextEngine,
mockedCompactDirect,
mockedRunEmbeddedAttempt,
mockedSessionLikelyHasOversizedToolResults,
@ -22,6 +23,25 @@ const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel);
describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedRunEmbeddedAttempt.mockReset();
mockedCompactDirect.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedGlobalHookRunner.runBeforeAgentStart.mockReset();
mockedGlobalHookRunner.runBeforeCompaction.mockReset();
mockedGlobalHookRunner.runAfterCompaction.mockReset();
mockedContextEngine.info.ownsCompaction = false;
mockedCompactDirect.mockResolvedValue({
ok: false,
compacted: false,
reason: "nothing to compact",
});
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
mockedTruncateOversizedToolResultsInSession.mockResolvedValue({
truncated: false,
truncatedCount: 0,
reason: "no oversized tool results",
});
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
});
@ -81,8 +101,12 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedCompactDirect).toHaveBeenCalledWith(
expect.objectContaining({
trigger: "overflow",
authProfileId: "test-profile",
sessionId: "test-session",
sessionFile: "/tmp/session.json",
runtimeContext: expect.objectContaining({
trigger: "overflow",
authProfileId: "test-profile",
}),
}),
);
});
@ -132,6 +156,63 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(result.meta.error?.kind).toBe("context_overflow");
});
it("fires compaction hooks during overflow recovery for ownsCompaction engines", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedGlobalHookRunner.hasHooks.mockImplementation(
(hookName) => hookName === "before_compaction" || hookName === "after_compaction",
);
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() }))
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
summary: "engine-owned compaction",
tokensAfter: 50,
},
});
await runEmbeddedPiAgent(overflowBaseRunParams);
expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledWith(
{ messageCount: -1, sessionFile: "/tmp/session.json" },
expect.objectContaining({
sessionKey: "test-key",
}),
);
expect(mockedGlobalHookRunner.runAfterCompaction).toHaveBeenCalledWith(
{
messageCount: -1,
compactedCount: -1,
tokenCount: 50,
sessionFile: "/tmp/session.json",
},
expect.objectContaining({
sessionKey: "test-key",
}),
);
});
it("guards thrown engine-owned overflow compaction attempts", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedGlobalHookRunner.hasHooks.mockImplementation(
(hookName) => hookName === "before_compaction" || hookName === "after_compaction",
);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({ promptError: makeOverflowError() }),
);
mockedCompactDirect.mockRejectedValueOnce(new Error("engine boom"));
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1);
expect(mockedGlobalHookRunner.runAfterCompaction).not.toHaveBeenCalled();
expect(result.meta.error?.kind).toBe("context_overflow");
expect(result.payloads?.[0]?.isError).toBe(true);
});
it("returns retry_limit when repeated retries never converge", async () => {
mockedRunEmbeddedAttempt.mockClear();
mockedCompactDirect.mockClear();

View File

@ -1028,37 +1028,84 @@ export async function runEmbeddedPiAgent(
log.warn(
`context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`,
);
const compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
runtimeContext: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
},
});
let compactResult: Awaited<ReturnType<typeof contextEngine.compact>>;
// When the engine owns compaction, hooks are not fired inside
// compactEmbeddedPiSessionDirect (which is bypassed). Fire them
// here so subscribers (memory extensions, usage trackers) are
// notified even on overflow-recovery compactions.
const overflowEngineOwnsCompaction = contextEngine.info.ownsCompaction === true;
const overflowHookRunner = overflowEngineOwnsCompaction ? hookRunner : null;
if (overflowHookRunner?.hasHooks("before_compaction")) {
try {
await overflowHookRunner.runBeforeCompaction(
{ messageCount: -1, sessionFile: params.sessionFile },
hookCtx,
);
} catch (hookErr) {
log.warn(
`before_compaction hook failed during overflow recovery: ${String(hookErr)}`,
);
}
}
try {
compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
runtimeContext: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
},
});
} catch (compactErr) {
log.warn(
`contextEngine.compact() threw during overflow recovery for ${provider}/${modelId}: ${String(compactErr)}`,
);
compactResult = { ok: false, compacted: false, reason: String(compactErr) };
}
if (
compactResult.ok &&
compactResult.compacted &&
overflowHookRunner?.hasHooks("after_compaction")
) {
try {
await overflowHookRunner.runAfterCompaction(
{
messageCount: -1,
compactedCount: -1,
tokenCount: compactResult.result?.tokensAfter,
sessionFile: params.sessionFile,
},
hookCtx,
);
} catch (hookErr) {
log.warn(
`after_compaction hook failed during overflow recovery: ${String(hookErr)}`,
);
}
}
if (compactResult.compacted) {
autoCompactionCount += 1;
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);

View File

@ -1774,6 +1774,8 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
},
)
.catch((err) => {
@ -1982,6 +1984,8 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
},
)
.catch((err) => {
@ -2042,6 +2046,8 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
},
)
.catch((err) => {

View File

@ -3,10 +3,14 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: () => undefined,
getOAuthProviders: () => [],
}));
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
return {
...original,
getOAuthApiKey: () => undefined,
getOAuthProviders: () => [],
};
});
import { createOpenClawCodingTools } from "./pi-tools.js";

View File

@ -24,6 +24,11 @@ export type PinnedSandboxEntry = {
basename: string;
};
export type AnchoredSandboxEntry = {
canonicalParentPath: string;
basename: string;
};
export type PinnedSandboxDirectoryEntry = {
mountRootPath: string;
relativePath: string;
@ -154,6 +159,48 @@ export class SandboxFsPathGuard {
};
}
async resolveAnchoredSandboxEntry(
target: SandboxResolvedFsPath,
action: string,
): Promise<AnchoredSandboxEntry> {
const basename = path.posix.basename(target.containerPath);
if (!basename || basename === "." || basename === "/") {
throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
}
const parentPath = normalizeContainerPath(path.posix.dirname(target.containerPath));
const canonicalParentPath = await this.resolveCanonicalContainerPath({
containerPath: parentPath,
allowFinalSymlinkForUnlink: false,
});
this.resolveRequiredMount(canonicalParentPath, action);
return {
canonicalParentPath,
basename,
};
}
async resolveAnchoredPinnedEntry(
target: SandboxResolvedFsPath,
action: string,
): Promise<PinnedSandboxEntry> {
const anchoredTarget = await this.resolveAnchoredSandboxEntry(target, action);
const mount = this.resolveRequiredMount(anchoredTarget.canonicalParentPath, action);
const relativeParentPath = path.posix.relative(
mount.containerRoot,
anchoredTarget.canonicalParentPath,
);
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
);
}
return {
mountRootPath: mount.containerRoot,
relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
basename: anchoredTarget.basename,
};
}
resolvePinnedDirectoryEntry(
target: SandboxResolvedFsPath,
action: string,

View File

@ -1,4 +1,4 @@
import type { PathSafetyCheck } from "./fs-bridge-path-safety.js";
import type { AnchoredSandboxEntry, PathSafetyCheck } from "./fs-bridge-path-safety.js";
import type { SandboxResolvedFsPath } from "./fs-paths.js";
export type SandboxFsCommandPlan = {
@ -10,11 +10,14 @@ export type SandboxFsCommandPlan = {
allowFailure?: boolean;
};
export function buildStatPlan(target: SandboxResolvedFsPath): SandboxFsCommandPlan {
export function buildStatPlan(
target: SandboxResolvedFsPath,
anchoredTarget: AnchoredSandboxEntry,
): SandboxFsCommandPlan {
return {
checks: [{ target, options: { action: "stat files" } }],
script: 'set -eu; stat -c "%F|%s|%Y" -- "$1"',
args: [target.containerPath],
script: 'set -eu\ncd -- "$1"\nstat -c "%F|%s|%Y" -- "$2"',
args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename],
allowFailure: true,
};
}

View File

@ -4,7 +4,12 @@ import { describe, expect, it } from "vitest";
import {
createSandbox,
createSandboxFsBridge,
dockerExecResult,
findCallsByScriptFragment,
findCallByDockerArg,
findCallByScriptFragment,
getDockerArg,
getDockerScript,
installFsBridgeTestHarness,
mockedExecDockerRaw,
withTempDir,
@ -66,6 +71,13 @@ describe("sandbox fs bridge anchored ops", () => {
});
const pinnedCases = [
{
name: "write pins canonical parent + basename",
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
bridge.writeFile({ filePath: "nested/file.txt", data: "updated" }),
expectedArgs: ["write", "/workspace", "nested", "file.txt", "1"],
forbiddenArgs: ["/workspace/nested/file.txt"],
},
{
name: "mkdirp pins mount root + relative path",
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
@ -121,4 +133,74 @@ describe("sandbox fs bridge anchored ops", () => {
});
});
});
it.runIf(process.platform !== "win32")(
"write resolves symlink parents to canonical pinned paths",
async () => {
await withTempDir("openclaw-fs-bridge-contract-write-", async (stateDir) => {
const workspaceDir = path.join(stateDir, "workspace");
const realDir = path.join(workspaceDir, "real");
await fs.mkdir(realDir, { recursive: true });
await fs.symlink(realDir, path.join(workspaceDir, "alias"));
mockedExecDockerRaw.mockImplementation(async (args) => {
const script = getDockerScript(args);
if (script.includes('readlink -f -- "$cursor"')) {
const target = getDockerArg(args, 1);
return dockerExecResult(`${target.replace("/workspace/alias", "/workspace/real")}\n`);
}
if (script.includes('stat -c "%F|%s|%Y"')) {
return dockerExecResult("regular file|1|2");
}
return dockerExecResult("");
});
const bridge = createSandboxFsBridge({
sandbox: createSandbox({
workspaceDir,
agentWorkspaceDir: workspaceDir,
}),
});
await bridge.writeFile({ filePath: "alias/note.txt", data: "updated" });
const writeCall = findCallByDockerArg(1, "write");
expect(writeCall).toBeDefined();
const args = writeCall?.[0] ?? [];
expect(getDockerArg(args, 2)).toBe("/workspace");
expect(getDockerArg(args, 3)).toBe("real");
expect(getDockerArg(args, 4)).toBe("note.txt");
expect(args).not.toContain("alias");
const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
expect(
canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === "/workspace/alias"),
).toBe(true);
});
},
);
it("stat anchors parent + basename", async () => {
await withTempDir("openclaw-fs-bridge-contract-stat-", async (stateDir) => {
const workspaceDir = path.join(stateDir, "workspace");
await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8");
const bridge = createSandboxFsBridge({
sandbox: createSandbox({
workspaceDir,
agentWorkspaceDir: workspaceDir,
}),
});
await bridge.stat({ filePath: "nested/file.txt" });
const statCall = findCallByScriptFragment('stat -c "%F|%s|%Y" -- "$2"');
expect(statCall).toBeDefined();
const args = statCall?.[0] ?? [];
expect(getDockerArg(args, 1)).toBe("/workspace/nested");
expect(getDockerArg(args, 2)).toBe("file.txt");
expect(args).not.toContain("/workspace/nested/file.txt");
});
});
});

View File

@ -118,7 +118,10 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
const buffer = Buffer.isBuffer(params.data)
? params.data
: Buffer.from(params.data, params.encoding ?? "utf8");
const pinnedWriteTarget = this.pathGuard.resolvePinnedEntry(target, "write files");
const pinnedWriteTarget = await this.pathGuard.resolveAnchoredPinnedEntry(
target,
"write files",
);
await this.runCheckedCommand({
...buildPinnedWritePlan({
check: writeCheck,
@ -218,7 +221,11 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
signal?: AbortSignal;
}): Promise<SandboxFsStat | null> {
const target = this.resolveResolvedPath(params);
const result = await this.runPlannedCommand(buildStatPlan(target), params.signal);
const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target, "stat files");
const result = await this.runPlannedCommand(
buildStatPlan(target, anchoredTarget),
params.signal,
);
if (result.code !== 0) {
const stderr = result.stderr.toString("utf8");
if (stderr.includes("No such file or directory")) {

View File

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { resolveCoreToolProfilePolicy } from "./tool-catalog.js";
describe("tool-catalog", () => {
it("includes web_search and web_fetch in the coding profile policy", () => {
const policy = resolveCoreToolProfilePolicy("coding");
expect(policy).toBeDefined();
expect(policy!.allow).toContain("web_search");
expect(policy!.allow).toContain("web_fetch");
});
});

View File

@ -86,7 +86,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
label: "web_search",
description: "Search the web",
sectionId: "web",
profiles: [],
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{
@ -94,7 +94,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
label: "web_fetch",
description: "Fetch web content",
sectionId: "web",
profiles: [],
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{

View File

@ -1,4 +1,4 @@
import { describe, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import {
installSendPayloadContractSuite,
@ -34,4 +34,92 @@ describe("whatsappOutbound sendPayload", () => {
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness,
});
it("trims leading whitespace for direct text sends", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
await whatsappOutbound.sendText!({
cfg: {},
to: "5511999999999@c.us",
text: "\n \thello",
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "hello", {
verbose: false,
cfg: {},
accountId: undefined,
gifPlayback: undefined,
});
});
it("trims leading whitespace for direct media captions", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
await whatsappOutbound.sendMedia!({
cfg: {},
to: "5511999999999@c.us",
text: "\n \tcaption",
mediaUrl: "/tmp/test.png",
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "caption", {
verbose: false,
cfg: {},
mediaUrl: "/tmp/test.png",
mediaLocalRoots: undefined,
accountId: undefined,
gifPlayback: undefined,
});
});
it("trims leading whitespace for sendPayload text and caption delivery", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
await whatsappOutbound.sendPayload!({
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: "\n\nhello" },
deps: { sendWhatsApp },
});
await whatsappOutbound.sendPayload!({
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: "\n\ncaption", mediaUrl: "/tmp/test.png" },
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenNthCalledWith(1, "5511999999999@c.us", "hello", {
verbose: false,
cfg: {},
accountId: undefined,
gifPlayback: undefined,
});
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "5511999999999@c.us", "caption", {
verbose: false,
cfg: {},
mediaUrl: "/tmp/test.png",
mediaLocalRoots: undefined,
accountId: undefined,
gifPlayback: undefined,
});
});
it("skips whitespace-only text payloads", async () => {
const sendWhatsApp = vi.fn();
const result = await whatsappOutbound.sendPayload!({
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: { text: "\n \t" },
deps: { sendWhatsApp },
});
expect(result).toEqual({ channel: "whatsapp", messageId: "" });
expect(sendWhatsApp).not.toHaveBeenCalled();
});
});

View File

@ -5,6 +5,10 @@ import { resolveWhatsAppOutboundTarget } from "../../../whatsapp/resolve-outboun
import type { ChannelOutboundAdapter } from "../types.js";
import { sendTextMediaPayload } from "./direct-text-media.js";
function trimLeadingWhitespace(text: string | undefined): string {
return text?.trimStart() ?? "";
}
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
@ -13,12 +17,32 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) =>
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }),
sendPayload: async (ctx) => {
const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
if (!text && !hasMedia) {
return { channel: "whatsapp", messageId: "" };
}
return await sendTextMediaPayload({
channel: "whatsapp",
ctx: {
...ctx,
payload: {
...ctx.payload,
text,
},
},
adapter: whatsappOutbound,
});
},
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
if (!normalizedText) {
return { channel: "whatsapp", messageId: "" };
}
const send =
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
const result = await send(to, text, {
const result = await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
@ -27,9 +51,10 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
const send =
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
const result = await send(to, text, {
const result = await send(to, normalizedText, {
verbose: false,
cfg,
mediaUrl,

View File

@ -10,7 +10,7 @@ const resolveGatewayProgramArguments = vi.fn(async (_opts?: unknown) => ({
const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue({ outcome: "completed" });
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceReadCommand = vi.fn().mockResolvedValue(null);
const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" });
@ -48,20 +48,24 @@ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments: (opts: unknown) => resolveGatewayProgramArguments(opts),
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
install: serviceInstall,
uninstall: serviceUninstall,
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: serviceReadCommand,
readRuntime: serviceReadRuntime,
}),
}));
vi.mock("../daemon/service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/service.js")>();
return {
...actual,
resolveGatewayService: () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
install: serviceInstall,
uninstall: serviceUninstall,
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: serviceReadCommand,
readRuntime: serviceReadRuntime,
}),
};
});
vi.mock("../daemon/legacy.js", () => ({
findLegacyGatewayServices: async () => [],

View File

@ -65,7 +65,7 @@ describe("runServiceRestart config pre-flight (#35862)", () => {
service.restart.mockClear();
service.isLoaded.mockResolvedValue(true);
service.readCommand.mockResolvedValue({ environment: {} });
service.restart.mockResolvedValue(undefined);
service.restart.mockResolvedValue({ outcome: "completed" });
vi.unstubAllEnvs();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "");
@ -163,7 +163,7 @@ describe("runServiceStart config pre-flight (#35862)", () => {
service.isLoaded.mockClear();
service.restart.mockClear();
service.isLoaded.mockResolvedValue(true);
service.restart.mockResolvedValue(undefined);
service.restart.mockResolvedValue({ outcome: "completed" });
});
it("aborts start when config is invalid", async () => {

View File

@ -40,11 +40,12 @@ vi.mock("../../runtime.js", () => ({
}));
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart;
let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop;
describe("runServiceRestart token drift", () => {
beforeAll(async () => {
({ runServiceRestart, runServiceStop } = await import("./lifecycle-core.js"));
({ runServiceRestart, runServiceStart, runServiceStop } = await import("./lifecycle-core.js"));
});
beforeEach(() => {
@ -64,7 +65,7 @@ describe("runServiceRestart token drift", () => {
service.readCommand.mockResolvedValue({
environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" },
});
service.restart.mockResolvedValue(undefined);
service.restart.mockResolvedValue({ outcome: "completed" });
vi.unstubAllEnvs();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "");
@ -176,4 +177,41 @@ describe("runServiceRestart token drift", () => {
expect(payload.result).toBe("restarted");
expect(payload.message).toContain("unmanaged process");
});
it("skips restart health checks when restart is only scheduled", async () => {
const postRestartCheck = vi.fn(async () => {});
service.restart.mockResolvedValue({ outcome: "scheduled" });
const result = await runServiceRestart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [],
opts: { json: true },
postRestartCheck,
});
expect(result).toBe(true);
expect(postRestartCheck).not.toHaveBeenCalled();
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("emits scheduled when service start routes through a scheduled restart", async () => {
service.restart.mockResolvedValue({ outcome: "scheduled" });
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [],
opts: { json: true },
});
expect(service.isLoaded).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
});

View File

@ -3,6 +3,8 @@ import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/confi
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
import { describeGatewayServiceRestart } from "../../daemon/service.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
@ -223,7 +225,20 @@ export async function runServiceStart(params: {
}
try {
await params.service.restart({ env: process.env, stdout });
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
}
} catch (err) {
const hints = params.renderStartHints();
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
@ -317,7 +332,7 @@ export async function runServiceRestart(params: {
renderStartHints: () => string[];
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<GatewayServiceRestartResult | void>;
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
}): Promise<boolean> {
const json = Boolean(params.opts?.json);
@ -402,11 +417,42 @@ export async function runServiceRestart(params: {
}
try {
let restartResult: GatewayServiceRestartResult = { outcome: "completed" };
if (loaded) {
await params.service.restart({ env: process.env, stdout });
restartResult = await params.service.restart({ env: process.env, stdout });
}
let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return true;
}
if (params.postRestartCheck) {
await params.postRestartCheck({ json, stdout, warnings, fail });
const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail });
if (postRestartResult) {
restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return true;
}
}
}
let restarted = loaded;
if (loaded) {

View File

@ -132,6 +132,7 @@ describe("runDaemonRestart health checks", () => {
programArguments: ["openclaw", "gateway", "--port", "18789"],
environment: {},
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@ -204,6 +205,25 @@ describe("runDaemonRestart health checks", () => {
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(2);
});
it("skips stale-pid retry health checks when the retry restart is only scheduled", async () => {
const unhealthy: RestartHealthSnapshot = {
healthy: false,
staleGatewayPids: [1993],
runtime: { status: "stopped" },
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
};
waitForGatewayHealthyRestart.mockResolvedValueOnce(unhealthy);
terminateStaleGatewayPids.mockResolvedValue([1993]);
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
const result = await runDaemonRestart({ json: true });
expect(result).toBe(true);
expect(terminateStaleGatewayPids).toHaveBeenCalledWith([1993]);
expect(service.restart).toHaveBeenCalledTimes(1);
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);
});
it("fails restart when gateway remains unhealthy", async () => {
const unhealthy: RestartHealthSnapshot = {
healthy: false,

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