mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
Merge branch 'main' into fix-usage-tooltip-clipping
This commit is contained in:
@@ -287,6 +287,13 @@ Use the smallest Crabbox lane that proves the reported user path, not just the
|
||||
touched code. Aim for one after-fix E2E proof before commenting, closing, or
|
||||
opening a PR for a user-visible bug.
|
||||
|
||||
When the user says "test in Crabbox", do not simply copy tests to the remote
|
||||
box and run them there. Crabbox is for remote real-scenario proof: copy or
|
||||
install OpenClaw as the user would, run the same setup/update/CLI/Gateway/API
|
||||
call that failed, and capture behavior from that entrypoint. For regressions or
|
||||
bug reports, prove the broken state first when feasible, then run the same
|
||||
scenario after the fix.
|
||||
|
||||
Pick the lane by symptom:
|
||||
|
||||
- Docker/setup/install bug: build a package tarball and run the matching
|
||||
@@ -308,8 +315,9 @@ Pick the lane by symptom:
|
||||
|
||||
Efficient flow:
|
||||
|
||||
1. Reproduce or prove the pre-fix symptom when feasible. If the issue cannot be
|
||||
reproduced, capture the exact command and observed behavior instead.
|
||||
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
|
||||
when feasible. If the issue cannot be reproduced, capture the exact command
|
||||
and observed behavior instead.
|
||||
2. Patch locally and run narrow local tests for edit speed.
|
||||
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
|
||||
package install, Docker setup, onboarding, channel add, gateway start, or
|
||||
|
||||
@@ -65,6 +65,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## Validation
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.
|
||||
- Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.
|
||||
- Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.
|
||||
- CLI/channels: show configured official external channels such as Discord in `openclaw channels list` when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813.
|
||||
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
|
||||
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
|
||||
- WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr.
|
||||
|
||||
## 2026.5.17
|
||||
|
||||
### Changes
|
||||
|
||||
- Security/audit: add `security.audit.suppressions` for intentionally accepted audit findings, keeping suppressed matches out of the active summary while preserving them in JSON output with an active suppression notice. (#76949) Thanks @100menotu001.
|
||||
- Agents/subagents: label delegated task and subagent completion handoffs as ready for parent review, and tell requester agents to review/verify results before calling them done. (#78985) Thanks @100menotu001.
|
||||
- Providers/media: add fal and OpenRouter music-generation providers for the shared `music_generate` tool, including fal MiniMax/ACE/Stable Audio endpoints and OpenRouter Lyria audio output.
|
||||
- Maintainer tooling: warn before running JS package commands on raw Crabbox AWS boxes, pointing maintainers to Actions hydration or Blacksmith Testbox for CI-like proof.
|
||||
- Control UI: show provider quota usage in the Overview card and Chat header, and recover stale Chat in-progress state after missed terminal events. (#82647)
|
||||
- Mac app remote setup can now be preconfigured from `openclaw-mac configure-remote`, skips onboarding when config is already complete, supports direct LAN/Tailnet gateway URLs, allows private same-origin Control UI loads, and owns the SSH tunnel process when SSH is selected.
|
||||
- Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`.
|
||||
@@ -21,44 +35,78 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: add opt-in restart trace logs for restart signal, active-work drain, close, next-start, ready, and memory spans. (#82396) Thanks @samzong.
|
||||
- Gateway/performance: split startup benchmark HTTP-listen timing from full gateway-ready timing and add post-bind plugin and sidecar diagnostics to restart-readiness traces. (#82603) Thanks @samzong.
|
||||
- QA-Lab: add a deterministic local personal-agent scenario pack covering reminders, threaded replies, scoped memory recall, redaction, and safe tool followthrough. (#78219) Thanks @iFiras-Max1.
|
||||
- QA-Lab: add `--pack personal-agent` for `openclaw qa suite` so maintainers can run the accepted personal-agent scenario pack by selector. (#82760) Thanks @iFiras-Max1.
|
||||
- QA-Lab: add a private Codex-vs-Pi runtime parity axis with runtime-pair suite runs, parity reports, and release-check wiring. (#80238) Thanks @100yenadmin.
|
||||
- Slack: add Slack assistant thread lifecycle support with assistant view manifest entries, suggested prompts, thread-scoped assistant sessions, and Slack-provided assistant context. Fixes #80787. Thanks @mobybot27.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/sessions: let `openclaw sessions cleanup --fix-missing` prune malformed rows with unresolvable transcript metadata instead of throwing. Fixes #80970. (#82745) Thanks @IWhatsskill.
|
||||
- Gateway/usage: refresh large session usage summaries in the background and reuse durable transcript metadata so `sessions.usage` no longer blocks Gateway requests on full transcript rescans. Fixes #82773. (#82778) Thanks @hclsys.
|
||||
- TUI: restore the submitted draft when chat is busy instead of clearing it or queueing another run. Fixes #45326. (#82774) Thanks @hyspacex.
|
||||
- Cron/memory: treat claimed `before_agent_reply` cron hooks as execution progress, so long memory dreaming promotion jobs are not aborted by the isolated-run pre-execution watchdog. Fixes #82811.
|
||||
- Browser plugin: redact attach-details from Chrome MCP diagnostics and keep raw Chrome launch error output around long enough to surface in user reports without leaking sensitive paths.
|
||||
- System prompts: clarify MEMORY guidance over generic TTS hints in the embedded speech-core/system-prompt scaffolding so agents prefer memory-store usage over speech defaults. Fixes #81930. Thanks @giodl73-repo.
|
||||
- Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos.
|
||||
- Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns.
|
||||
- Agents/replay: normalize malformed assistant replay content before transport conversion while preserving empty-stop replay repair, so bad provider history no longer crashes with non-iterable content. Fixes #43795. (#82748) Thanks @IWhatsskill.
|
||||
- Gateway/macOS: write LaunchAgent stdout under `~/Library/Logs/openclaw`, suppress stderr, and attach stdin to `/dev/null` so launchd startup avoids symlinked state-dir log failures and silent module-evaluation hangs. Fixes #40207 and #46153. Thanks @dhruvkelawala and @frankr.
|
||||
- CLI/configure: let model-only section setup enter provider auth directly instead of first asking where the Gateway runs, unblocking OAuth/token setup in terminals where that unrelated prompt is unresponsive. Fixes #39223. Thanks @LevityLeads.
|
||||
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
|
||||
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
|
||||
- CLI/web: resolve provider-scoped web search/fetch SecretRefs for `infer web ... --provider ...` while leaving unrelated plugin secrets untouched. Fixes #82621. Thanks @leno23.
|
||||
- Providers/Anthropic Vertex: resolve installed provider public surfaces from package-local `dist/`, restoring `anthropic-vertex/*` model calls after plugin externalization. Fixes #82781. Thanks @0L1v3DaD.
|
||||
- Gateway/exec approvals: bind path-shaped allowlists, safe-bin trust, skill auto-allow, Allow Always persistence, and approval audit metadata to the executable realpath so symlinked binaries cannot keep approvals after retargeting. Fixes #45595. Thanks @jasonftl.
|
||||
- Mac app: let menu gateway/session error text wrap across a few lines and stop rebuilding dynamic Context/Gateway menu rows while the menu is open, reducing flicker.
|
||||
- Mac app: make device pairing approval sheets friendlier, with concise Mac/device copy, shortened identifiers, friendly scope labels, and Approve as the primary action.
|
||||
- Providers/Qwen: honor session thinking level for `qwen-chat-template` payloads so `/think off` disables nested llama.cpp chat-template thinking controls. Fixes #82768. Thanks @bfox55.
|
||||
- Feishu/wiki: reject numeric wiki space IDs before creating Lark clients and keep numeric-looking IDs documented as quoted opaque strings, preventing JavaScript precision loss in knowledge base calls. Fixes #45301. (#82769) Thanks @hyspacex.
|
||||
- Control UI: simplify Talk settings to Voice, Model, and Sensitivity defaults, with provider, transport, exact VAD, and timing controls behind Advanced.
|
||||
- Telegram: let catch-all mention patterns match captionless group photos, so media-only group messages reach the agent when the group is intentionally configured to respond to all messages. Fixes #44833. (#82756) Thanks @IWhatsskill.
|
||||
- Gateway/pairing: reject forged loopback Control UI origins from non-local proxy paths, and keep mobile pairing setup on Tailscale bind mode pointing users to Tailscale Serve/Funnel instead of cleartext tailnet WebSockets.
|
||||
- Telegram/Gateway: persist isolated polling offsets only after main-thread dispatch and preserve gateway caller scopes for Telegram message actions, fixing consumed-but-unrouted polling updates and recursive CLI send scope approvals. Fixes #82277. (#82705) Thanks @udaymanish6.
|
||||
- Memory-core: abort timed-out embedding provider calls so remote embedding HTTP requests do not continue running after memory query or indexing timeouts. Fixes #82732. Thanks @adityarya24.
|
||||
- Channels/stream previews: contain rejected background draft-stream flushes so preview send failures do not surface as fatal unhandled rejections. Fixes #82712. (#82713) Thanks @coygeek.
|
||||
- Codex/app-server: keep shared native app-server clients isolated per agent runtime key so starting one agent no longer closes another agent's active Codex turn. Fixes #82758. Thanks @PashaGanson.
|
||||
- Providers/OpenAI Codex: include base `gpt-5.5` and `gpt-5.4` reasoning metadata in the bundled Codex catalog so `/think xhigh` remains available for those models. Fixes #82744.
|
||||
- Providers/OpenAI Codex: keep the native hook relay as the final Codex app-server thread config patch so hook-backed approvals stay enabled even when lower-priority config disables hooks. Thanks @solomonneas.
|
||||
- Providers/MiniMax: declare CN endpoint auth aliases in the plugin manifest so `minimax-cn` and `minimax-portal-cn` reuse the correct base auth profiles instead of falling back to unrelated models after 401s. Fixes #63823. Thanks @kamusis.
|
||||
- Secrets/audit: treat `$VAR` auth-profile values as env SecretRefs and stop reporting env-ref credentials as plaintext, including mixed `keyRef` plus env-ref profile states. Fixes #53998. Thanks @schirloc and @artwalker.
|
||||
- Agents/model fallback: suppress fallback notices when the active OpenAI Codex runtime reports the same canonical OpenAI model.
|
||||
- Agents/music generation: remove model-controlled request timeouts, default internal provider requests to five minutes, and keep configured timeouts at a 120-second floor.
|
||||
- Agents/media generation: stop logging delivered failure summaries as missing message-tool delivery when no generated media was expected.
|
||||
- Agents/sessions: prioritize manual user turns ahead of queued cron and maintenance work in the same session lane, so visible follow-ups no longer wait behind background runs. Fixes #82764. (#82765) Thanks @galiniliev.
|
||||
- Agents/edit tool: honor `file_path` and related path aliases when resolving edit-recovery targets, so post-write errors no longer surface false edit failures after the file actually changed. Fixes #81909. Thanks @giodl73-repo.
|
||||
- QQBot: treat only explicit truthy `QQBOT_DEBUG` values as enabling debug logs, so false-like values such as `0` no longer expose debug output. Fixes #82644. (#82697) Thanks @leno23.
|
||||
- Agents/session_status: resolve implicit no-arg status lookups against the live run session, so `/think` changes report the current thinking level instead of stale sandbox state. Fixes #82669. (#82696) Thanks @leno23.
|
||||
- Discord: keep progress drafts visible for message-tool-only guild replies under the default coding tool profile. Fixes #82747. Thanks @eliranwong.
|
||||
- Discord: keep unmentioned room-event history until a visible Discord send succeeds, so quiet ambient context does not disappear before message-tool delivery. (#82573) Thanks @obviyus.
|
||||
- CLI/setup: order the model/auth provider picker as OpenAI, Anthropic, xAI, Google, then the remaining providers alphabetically.
|
||||
- Diagnostics/usage/voice-call: treat explicit zero and non-finite limits as empty results and reject invalid voice-call numeric CLI flags. Fixes #82646, #82650, #82651, and #82653. (#82679) Thanks @leno23.
|
||||
- CLI/config: avoid redundant startup config/plugin checks for the guided `openclaw config` flow and show progress while source checkout CLI artifacts build or load.
|
||||
- Config/Mac app: accept `gateway.remote.remotePort` in core config validation so Mac SSH remote setup stays compatible with the CLI.
|
||||
- Gateway/diagnostics: add opt-in critical memory pressure stability snapshots with gateway logs, V8 heap, cgroup, active-resource, and redacted large session-file evidence. Fixes #82518.
|
||||
- Doctor/Gateway: avoid treating unrelated macOS LaunchAgents as legacy gateways just because their environment values mention old checkout paths.
|
||||
- Gateway/heartbeat: defer heartbeat runs while the target reply operation is queued or active, preventing heartbeat prompts from interleaving with WebChat responses before the streaming lane starts. Fixes #82722. Thanks @Andy-Xie-1145.
|
||||
- CLI/setup: collapse raw gateway config keys in existing-config summaries into friendly `Model` and `Gateway` rows.
|
||||
- CLI/config: show concise human config-write output with an indented backup path instead of printing checksum-heavy overwrite audit details by default.
|
||||
- CLI/docs: call the canonical lowercase docs MCP search tool and surface MCP errors instead of returning empty search results. Fixes #82702. (#82704) Thanks @hclsys.
|
||||
- QA-Lab: add gateway log sentinels for plugin hook failures, Codex app-server stalls/timeouts, cron allowlist drift, live quota blockers, and direct-reply self-message transcripts so harness proof fails on self-health regressions. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: ignore heartbeat-only operational transcripts when capturing runtime parity cells so background checks cannot replace the scenario reply. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: pin threaded-memory parity runs to `memory-core`, keep bundled plugin resolution enabled for QA commands, and retry transient session-store lock reads. (#72045) Thanks @WuKongAI-CMU.
|
||||
- QA-Lab/qa-channel: keep mock memory ranking, inbound media notes, and opened-file realpath checks stable for mock OpenAI qa-channel runs. (#66826) Thanks @gumadeiras.
|
||||
- Gateway/exec approvals: wait for accepted async approval follow-up runs instead of direct-fallback sending duplicate completions when retries use different nonce keys. Fixes #82711. (#82717) Thanks @udaymanish6.
|
||||
- Agents/subagents: mark completed subagent handoffs as ready for parent review so requester agents verify results and continue required follow-up work before reporting done. (#82724) Thanks @100menotu001.
|
||||
- QA-Lab: validate Capture saved views loaded from browser storage so malformed local state cannot poison Capture inspector filters or layout controls. (#77722) Thanks @AsaZhou923.
|
||||
- Agents/performance: reuse prepared plugin manifest metadata across local CLI turns, model catalog normalization, auth lookups, and tool capability checks, restoring fast pre-provider startup for plugin-heavy installs. Thanks @shakkernerd.
|
||||
- CLI/config: add `--dry-run` support to `openclaw config unset`, with `--json` output and allow-exec validation parity with `config set`/`config patch` dry-run handling. (#81895) Thanks @giodl73-repo.
|
||||
- CLI/infer: resolve command SecretRefs before local provider-backed capability runs, so web search/fetch and other local infer commands can use plugin-scoped credential refs. Fixes #82621. (#82798) Thanks @joshavant.
|
||||
- Memory-core: retry disabled dreaming cron cleanup until cron is available after startup, so persisted managed dreaming jobs are removed after restart. Fixes #82383. (#82389) Thanks @neeravmakwana.
|
||||
- Providers/xAI: keep retired Grok 3, Grok 4 Fast, Grok 4.1 Fast, and Grok Code slugs out of model pickers while preserving compatibility resolution for existing configs.
|
||||
- Providers/xAI: replace the retired `grok-imagine-image-pro` image model with `grok-imagine-image-quality` in the bundled image-generation provider and docs. (#81399) Thanks @KateWilkins.
|
||||
- Providers/OAuth: let browser-hosted identity provider pages read successful localhost callback responses, preventing xAI Grok OAuth from showing a false connection failure after OpenClaw completes login.
|
||||
- Gateway/security: reject malformed HTTP and WebSocket request targets with the existing auth failure response instead of letting invalid URL parsing crash the Gateway. Fixes GHSA-6hc3-f4rg-377m.
|
||||
- Browser/CDP: redact credential-bearing Chrome MCP and managed Chrome launch diagnostics, and require exact loopback entries before treating `NO_PROXY` as already covering local CDP proxy bypasses.
|
||||
- Gateway/diagnostics: redact credential-bearing gateway target URLs and client diagnostics while preserving raw connection URLs for programmatic use, so connect-failure logs no longer surface embedded tokens.
|
||||
- Gateway/auth: honor `OPENCLAW_GATEWAY_TOKEN` as the remote interactive fallback when no remote token is configured, keeping remote TUI setup aligned with documented auth precedence.
|
||||
- Providers/xAI: continue polling video generations while xAI reports in-flight jobs as `pending`, so Grok video requests no longer fail before the final `done` response. (#82610) Thanks @Manzojunior.
|
||||
@@ -78,13 +126,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/sessions: estimate context usage from local/OpenAI-compatible transcripts when provider usage telemetry is missing, so status no longer shows empty usage for real local-model sessions. Fixes #73990. (#82317) Thanks @giodl73-repo.
|
||||
- Update/installers: override npm `min-release-age` quarantine for OpenClaw-managed package installs, so `openclaw update`, plugin updates, and hosted installer scripts can install the requested latest release immediately.
|
||||
- Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11.
|
||||
- Agents/replies: preserve active inbound reply context at the LLM boundary so Discord referenced-message turns do not answer from stale session history. Fixes #82608. (#82801) Thanks @joshavant.
|
||||
- Agents/OpenAI Responses: log redacted diagnostics for detail-less `response.failed` events while preserving failed response ids, so operators can correlate provider-side failures. Fixes #82558.
|
||||
- Agents/OpenRouter: strip non-replayable Anthropic/xAI reasoning provenance tags from follow-up requests, preventing poisoned thinking signatures from breaking second turns. Fixes #82335. (#82380) Thanks @hclsys.
|
||||
- Providers/xAI: send configurable reasoning effort only for Grok 4.3, preserving xAI's default low reasoning while omitting unsupported controls for Grok 4.20 reasoning models. (#81227) Thanks @jason-allen-oneal.
|
||||
- Image generation: raise Google, OpenRouter, and xAI hosted provider default timeouts to 180 seconds so slow hosted image requests have more time to complete. (#75337)
|
||||
- Agents/auth: redact OAuth refresh failure causes against in-memory, attempted, and reloaded credentials before generic token masking while ensuring failed ACP dispatch cleanup closes initialized runtimes.
|
||||
- Google/Gemini CLI OAuth: add provider-owned refresh support for `google-gemini-cli` so expired Gemini CLI tokens refresh in OpenClaw instead of falling through to the generic unknown-provider path. Fixes #42541. Thanks @jason-allen-oneal.
|
||||
- Agents/Anthropic transport: replay `reasoning_content` from compatible thinking blocks for Xiaomi/MiMo-style Anthropic Messages routes, preventing follow-up turns from losing required reasoning context. Fixes #81261. Thanks @Sunnyone2three.
|
||||
- Telegram: cache successful startup bot identity by account and token fingerprint for up to 24 hours, so restarts can skip redundant `getMe` probes during Telegram API slow periods without permanently pinning renamed bots. Refs #82525.
|
||||
- Telegram: keep streamed text replies in place when delayed TTS audio arrives, sending the audio as a follow-up instead of deleting the preview. Fixes #82570. (#82820) Thanks @joshavant.
|
||||
- Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths.
|
||||
- Codex app-server: mark native context compaction completion events as successful, preventing false "Compaction incomplete" notices after successful Codex-managed compaction. Fixes #82470. (#81593) Thanks @Kyzcreig.
|
||||
- Codex app-server: keep long-running turns alive while current-turn approvals, user input, dynamic tools, and notifications make progress, and carry that progress into the outer run timeout. (#82601) Thanks @100yenadmin.
|
||||
@@ -102,9 +153,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/doctor: migrate legacy top-level plugin `tools` declarations into `contracts.tools`, so `openclaw doctor --fix` repairs local plugins for the manifest tool contract. (#81112) Thanks @100yenadmin.
|
||||
- Slack: guide agents to use stable `<@USER_ID>` mention tokens from context instead of plain `@name` text, so user mentions link and notify correctly. Fixes #82090. (#82152) Thanks @neeravmakwana.
|
||||
- Auth: serialize provider login writes through the auth-profile lock for OpenAI Codex, Anthropic, Cloudflare AI Gateway, GitHub Copilot, and z.ai, preserving upsert semantics so a live Gateway cannot overwrite freshly refreshed OAuth credentials with an expired in-memory snapshot.
|
||||
- Auth/Codex: remove runtime support for `oauthRef` sidecar-backed OAuth profiles and add a doctor repair that migrates affected Codex profiles back to inline `auth-profiles.json` credentials. (#82777) Thanks @joshavant.
|
||||
- Slack: keep DM thread replies on the main direct-message session instead of routing them to invisible thread-scoped sessions. Refs #82390. (#82418) Thanks @kagura-agent.
|
||||
- Auth/macOS: avoid creating the OAuth profile master key in Keychain automatically, falling back to the file-backed secret key so headless agents do not trigger a Keychain prompt.
|
||||
- Codex app-server: release raw assistant completions when `turn/completed` is missing while keeping commentary/status items as progress, preventing completed Codex runs from hanging until timeout. Fixes #82343. (#82403) Thanks @IWhatsskill.
|
||||
- Codex app-server: keep a bounded terminal guard after post-tool raw assistant completions so missing `turn/completed` events fail fast instead of leaving embedded runs stuck. Fixes #82775. (#82816) Thanks @joshavant.
|
||||
- Agents/sessions: remove the transient `*.bak-<pid>-<ts>` backup written by `repairSessionFileIfNeeded` once the atomic replace succeeds, so a stuck session with a persistently malformed JSONL line no longer accumulates one snapshot per repair invocation. Fixes #80960. (#80969) Thanks @100yenadmin. Co-authored by @tynamite.
|
||||
- CLI/status: show plain empty-state messages instead of empty Channels and Sessions tables when no channels or sessions exist.
|
||||
- CLI/dashboard: probe Gateway readiness before handing out the dashboard URL, prompting to start or install the managed service when the Gateway is stopped and printing recovery commands instead of opening a dead browser tab.
|
||||
@@ -125,6 +178,7 @@ Docs: https://docs.openclaw.ai
|
||||
- MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. Fixes #82424. (#82443) Thanks @joshavant.
|
||||
- Agents/sandbox: honor explicit Docker sandbox env variables with credential-looking names during container creation, and recreate affected sandbox containers when the effective env policy changes. Fixes #82695. (#82763) Thanks @joshavant.
|
||||
- Plugins: accept deprecated `api.on("deactivate")` registrations as a dated compatibility alias for `gateway_stop`, so external plugin cleanup handlers run on Gateway shutdown while authors get migration guidance.
|
||||
- Plugins: resolve bundled entry, dist-runtime, package-state, and public artifact paths from packaged roots, so bundled plugin probes and hardlinked public surfaces no longer fall back to source files or fail during restart. Fixes #78462. Fixes #75797. Refs #76865. Thanks @ginishuh and @ymebosma.
|
||||
- Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged.
|
||||
- Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs.
|
||||
- Gateway/media: prevent image filenames from overriding generic non-image byte sniffing, so zip/octet-stream payloads mislabeled as images are offloaded or rejected before they become inline image attachments.
|
||||
@@ -150,6 +204,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Task persistence: drop malformed array/scalar requester-origin JSON from task and task-flow SQLite sidecars instead of restoring it as delivery metadata.
|
||||
- Agents/timeouts: clarify model idle-timeout errors and docs so provider `timeoutSeconds` is shown as bounded by the whole agent/run timeout ceiling.
|
||||
- Agents/OpenAI streams: yield cooperatively while processing bursty Completions and Responses chunks, keeping aborts, channel liveness timers, and startup heartbeats responsive under noisy model output. Refs #82462.
|
||||
- Media/images: avoid broad model/plugin discovery while preparing image requests, preventing Windows event-loop stalls that could block Telegram polling. Fixes #82338. (#82799) Thanks @joshavant.
|
||||
- Release tooling: align the published launcher Node floor, `npm start`, package script checks, sharded lint locking, Vitest root project coverage, and plugin-SDK declaration build cache metadata so release/package validation does not silently skip or ship stale surfaces.
|
||||
- Cron/agents: honor configured subagent model fallbacks for isolated scheduled runs and forward that fallback policy into embedded agent timeout failover. Fixes #74985. Thanks @chrisgwynne.
|
||||
- Codex app-server/MCP: scope user MCP servers to specific OpenClaw agent ids through an optional `mcp.servers.<name>.codex.agents` list and accept `codex.defaultToolsApprovalMode` (`auto`/`prompt`/`approve`) for native Codex approval defaults; OpenClaw strips the `codex` block before handing `mcp_servers` config to Codex. (#82180) Thanks @sercada.
|
||||
|
||||
@@ -380,10 +380,10 @@ final class AppState {
|
||||
{
|
||||
self.remoteTarget = configRemoteTarget
|
||||
} else if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
{
|
||||
self.remoteTarget = "\(NSUserName())@\(host)"
|
||||
} else {
|
||||
|
||||
@@ -9,31 +9,51 @@ struct ContextRootMenuLabelView: View {
|
||||
MenuItemHighlightColors.palette(self.isHighlighted)
|
||||
}
|
||||
|
||||
private var usesStackedLayout: Bool {
|
||||
self.subtitle.count > 28 || self.subtitle.contains("\n")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("Context")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
HStack(alignment: self.usesStackedLayout ? .top : .firstTextBaseline, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Context")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if self.usesStackedLayout {
|
||||
self.subtitleText
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(self.subtitle)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
if !self.usesStackedLayout {
|
||||
self.subtitleText
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.padding(.leading, 2)
|
||||
.padding(.top, self.usesStackedLayout ? 2 : 0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, self.usesStackedLayout ? 7 : 8)
|
||||
.padding(.leading, 22)
|
||||
.padding(.trailing, 14)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
}
|
||||
|
||||
private var subtitleText: some View {
|
||||
Text(self.subtitle)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ import Foundation
|
||||
import WebKit
|
||||
|
||||
private final class DashboardWindowContentView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override var mouseDownCanMoveWindow: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private final class DashboardWindowDragRegionView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override var mouseDownCanMoveWindow: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
self.window?.performDrag(with: event)
|
||||
@@ -275,7 +279,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
|
||||
private func showLoadFailure(_ error: Error) {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { return }
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return }
|
||||
dashboardWindowLogger.error(
|
||||
"dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
let html = Self.failureHTML(url: self.currentURL, message: error.localizedDescription)
|
||||
@@ -324,8 +328,8 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
<body>
|
||||
<main>
|
||||
<h1>Dashboard unavailable</h1>
|
||||
<p>\(Self.htmlEscape(message))</p>
|
||||
<code>\(Self.htmlEscape(url.absoluteString))</code>
|
||||
<p>\(self.htmlEscape(message))</p>
|
||||
<code>\(self.htmlEscape(url.absoluteString))</code>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,7 +33,7 @@ final class DevicePairingApprovalPrompter {
|
||||
let remoteIp: String?
|
||||
}
|
||||
|
||||
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
let requestId: String
|
||||
let deviceId: String
|
||||
let publicKey: String
|
||||
@@ -115,14 +115,16 @@ final class DevicePairingApprovalPrompter {
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow device to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
messageText: Self.alertTitle(for: req),
|
||||
informativeText: Self.alertSummary(for: req),
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: Self.approveButtonTitle(for: req)),
|
||||
accessoryView: Self.buildAccessoryView(for: req),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
var shouldRemove = response != .alertFirstButtonReturn
|
||||
var shouldRemove = response != .alertSecondButtonReturn
|
||||
defer {
|
||||
if shouldRemove {
|
||||
if self.queue.first == request {
|
||||
@@ -144,14 +146,14 @@ final class DevicePairingApprovalPrompter {
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
case .alertSecondButtonReturn:
|
||||
shouldRemove = false
|
||||
if let idx = self.queue.firstIndex(of: request) {
|
||||
self.queue.remove(at: idx)
|
||||
}
|
||||
self.queue.append(request)
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
default:
|
||||
@@ -233,24 +235,166 @@ final class DevicePairingApprovalPrompter {
|
||||
self.updatePendingCounts()
|
||||
}
|
||||
|
||||
private static func describe(_ req: PendingRequest) -> String {
|
||||
var lines: [String] = []
|
||||
lines.append("Device: \(req.displayName ?? req.deviceId)")
|
||||
if let platform = req.platform {
|
||||
lines.append("Platform: \(platform)")
|
||||
static func alertTitle(for req: PendingRequest) -> String {
|
||||
self.isMac(req.platform) ? "New Mac wants to connect" : "New device wants to connect"
|
||||
}
|
||||
|
||||
static func alertSummary(for req: PendingRequest) -> String {
|
||||
let subject = self.isMac(req.platform) ? "this Mac app" : "this device"
|
||||
return "Approve \(subject) to control OpenClaw. Only approve if this is yours; you can remove it later in Settings."
|
||||
}
|
||||
|
||||
static func approveButtonTitle(for req: PendingRequest) -> String {
|
||||
self.isMac(req.platform) ? "Approve Mac" : "Approve Device"
|
||||
}
|
||||
|
||||
static func buildAccessoryView(for req: PendingRequest) -> NSView {
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 8
|
||||
stack.edgeInsets = NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
|
||||
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Device", value: self.deviceName(for: req)))
|
||||
if let platform = self.prettyPlatform(req.platform) {
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Platform", value: platform))
|
||||
}
|
||||
if let role = req.role {
|
||||
lines.append("Role: \(role)")
|
||||
if let role = self.prettyRole(req.role) {
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Role", value: role))
|
||||
}
|
||||
if let scopes = req.scopes, !scopes.isEmpty {
|
||||
lines.append("Scopes: \(scopes.joined(separator: ", "))")
|
||||
let accessItems = self.friendlyScopeNames(req.scopes)
|
||||
if !accessItems.isEmpty {
|
||||
stack.addArrangedSubview(self.makeSectionLabel("Access requested"))
|
||||
for item in accessItems {
|
||||
stack.addArrangedSubview(self.makeBullet(item))
|
||||
}
|
||||
}
|
||||
if let remoteIp = req.remoteIp {
|
||||
lines.append("IP: \(remoteIp)")
|
||||
stack.addArrangedSubview(self.makeDetailLine(req))
|
||||
|
||||
let fitting = stack.fittingSize
|
||||
stack.frame = NSRect(x: 0, y: 0, width: 420, height: fitting.height)
|
||||
return stack
|
||||
}
|
||||
|
||||
static func deviceName(for req: PendingRequest) -> String {
|
||||
let trimmedName = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmedName, !trimmedName.isEmpty, trimmedName != req.deviceId {
|
||||
return trimmedName
|
||||
}
|
||||
return self.isMac(req.platform) ? "OpenClaw Mac app" : "New device"
|
||||
}
|
||||
|
||||
static func prettyPlatform(_ raw: String?) -> String? {
|
||||
let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let platform, !platform.isEmpty else { return nil }
|
||||
switch platform.lowercased() {
|
||||
case "macintel", "x86_64-apple-darwin":
|
||||
return "Mac (Intel)"
|
||||
case "macarm", "macarm64", "arm64-apple-darwin", "aarch64-apple-darwin":
|
||||
return "Mac (Apple silicon)"
|
||||
case "darwin":
|
||||
return "Mac"
|
||||
default:
|
||||
if platform.lowercased().contains("mac") {
|
||||
return "Mac"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyRole(_ raw: String?) -> String? {
|
||||
let role = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let role, !role.isEmpty else { return nil }
|
||||
return role == "operator" ? "Operator" : role
|
||||
}
|
||||
|
||||
static func friendlyScopeNames(_ scopes: [String]?) -> [String] {
|
||||
guard let scopes else { return [] }
|
||||
var seen = Set<String>()
|
||||
return scopes.compactMap { scope in
|
||||
let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty, seen.insert(normalized).inserted else { return nil }
|
||||
switch normalized {
|
||||
case "operator.admin":
|
||||
return "Admin access"
|
||||
case "operator.read":
|
||||
return "Read OpenClaw data"
|
||||
case "operator.write":
|
||||
return "Send messages and make changes"
|
||||
case "operator.approvals":
|
||||
return "Manage approvals"
|
||||
case "operator.pairing":
|
||||
return "Pair and repair devices"
|
||||
case "operator.talk.secrets":
|
||||
return "Use Talk credentials"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func shortIdentifier(_ id: String) -> String {
|
||||
let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count > 20 else { return trimmed }
|
||||
return "\(trimmed.prefix(8))...\(trimmed.suffix(7))"
|
||||
}
|
||||
|
||||
private static func isMac(_ platform: String?) -> Bool {
|
||||
guard let platform else { return false }
|
||||
let lower = platform.lowercased()
|
||||
return lower.contains("mac") || lower.contains("darwin")
|
||||
}
|
||||
|
||||
private static func makeValueRow(label: String, value: String) -> NSView {
|
||||
let row = NSStackView()
|
||||
row.orientation = .horizontal
|
||||
row.alignment = .firstBaseline
|
||||
row.spacing = 8
|
||||
|
||||
let labelField = self.makeLabel("\(label):", font: .systemFont(ofSize: 12, weight: .semibold))
|
||||
labelField.textColor = .secondaryLabelColor
|
||||
labelField.setContentHuggingPriority(.required, for: .horizontal)
|
||||
let valueField = self.makeLabel(value, font: .systemFont(ofSize: 12, weight: .regular))
|
||||
valueField.maximumNumberOfLines = 2
|
||||
|
||||
row.addArrangedSubview(labelField)
|
||||
row.addArrangedSubview(valueField)
|
||||
return row
|
||||
}
|
||||
|
||||
private static func makeSectionLabel(_ text: String) -> NSTextField {
|
||||
let label = self.makeLabel(text, font: .systemFont(ofSize: 12, weight: .semibold))
|
||||
label.textColor = .secondaryLabelColor
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeBullet(_ text: String) -> NSTextField {
|
||||
let label = self.makeLabel("• \(text)", font: .systemFont(ofSize: 12, weight: .regular))
|
||||
label.maximumNumberOfLines = 2
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeDetailLine(_ req: PendingRequest) -> NSTextField {
|
||||
var parts = ["ID \(self.shortIdentifier(req.deviceId))"]
|
||||
if let remoteIp = req.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines), !remoteIp.isEmpty {
|
||||
parts.append("IP \(remoteIp.replacingOccurrences(of: "::ffff:", with: ""))")
|
||||
}
|
||||
if req.isRepair == true {
|
||||
lines.append("Repair: yes")
|
||||
parts.append("repair request")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
let label = self.makeLabel(
|
||||
parts.joined(separator: " · "),
|
||||
font: .monospacedSystemFont(ofSize: 11, weight: .regular))
|
||||
label.textColor = .tertiaryLabelColor
|
||||
label.maximumNumberOfLines = 2
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeLabel(_ text: String, font: NSFont) -> NSTextField {
|
||||
let label = NSTextField(labelWithString: text)
|
||||
label.font = font
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.textColor = .labelColor
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ struct MenuHeaderCard<Content: View>: View {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
self.content
|
||||
}
|
||||
|
||||
@@ -65,28 +65,28 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
|
||||
// Refresh in background for the next open; keep width stable while open.
|
||||
// Refresh in the background for the next open. Rebuilding custom menu
|
||||
// rows while AppKit is tracking the menu causes visible flicker.
|
||||
self.loadTask?.cancel()
|
||||
let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
|
||||
self.loadTask = Task { [weak self] in
|
||||
let shouldRepaintAfterRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
|
||||
self.loadTask = Task { [weak self, weak menu] in
|
||||
guard let self else { return }
|
||||
let forceRefresh = shouldRepaintAfterRefresh
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await self.refreshCostUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
if shouldRepaintAfterRefresh {
|
||||
await self.repaintOpenMenu(menu)
|
||||
}
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self] in
|
||||
let shouldRepaintNodesAfterRefresh = self.shouldRepaintNodesAfterRefresh()
|
||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
||||
guard let self else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
if !shouldRepaintAfterRefresh, shouldRepaintNodesAfterRefresh {
|
||||
await self.repaintOpenMenuNodes(menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.originalDelegate?.menuDidClose?(menu)
|
||||
self.isMenuOpen = false
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
@@ -122,25 +120,18 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
|
||||
self.loadTask?.cancel()
|
||||
self.loadTask = Task { [weak self, weak menu] in
|
||||
guard let self, let menu else { return }
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: true)
|
||||
await self.refreshUsageCache(force: true)
|
||||
await self.refreshCostUsageCache(force: true)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
await self.repaintOpenMenu(menu)
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
||||
guard let self, let menu else { return }
|
||||
guard let self else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
await self.repaintOpenMenuNodes(menu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +269,28 @@ extension MenuSessionsInjector {
|
||||
_ = cursor
|
||||
}
|
||||
|
||||
private func repaintOpenMenu(_ menu: NSMenu?) async {
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen, let menu else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
private func repaintOpenMenuNodes(_ menu: NSMenu?) async {
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen, let menu else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRepaintNodesAfterRefresh() -> Bool {
|
||||
guard self.isControlChannelConnected else { return false }
|
||||
return self.sortedNodeEntries().isEmpty
|
||||
|| self.nodesStore.lastError?.nonEmpty != nil
|
||||
|| self.nodesStore.statusMessage?.nonEmpty != nil
|
||||
}
|
||||
|
||||
private func buildContextSubmenu(
|
||||
width: CGFloat,
|
||||
isConnected: Bool,
|
||||
@@ -374,7 +387,7 @@ extension MenuSessionsInjector {
|
||||
return self.cachedErrorText ?? "Loading…"
|
||||
}
|
||||
|
||||
return self.controlChannelStatusText(for: channelState)
|
||||
return Self.menuStatusText(self.controlChannelStatusText(for: channelState))
|
||||
}
|
||||
|
||||
private func activeRows(from snapshot: SessionStoreSnapshot) -> [SessionRow] {
|
||||
@@ -527,7 +540,7 @@ extension MenuSessionsInjector {
|
||||
case .connecting:
|
||||
"Connecting…"
|
||||
case let .degraded(message):
|
||||
message.nonEmpty ?? "Gateway disconnected"
|
||||
Self.menuStatusText(message.nonEmpty ?? "Gateway disconnected")
|
||||
case .disconnected:
|
||||
"Gateway disconnected"
|
||||
}
|
||||
@@ -684,7 +697,7 @@ extension MenuSessionsInjector {
|
||||
self.previewTasks.removeAll()
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 3) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: symbolName)
|
||||
@@ -810,6 +823,19 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
return "Sessions unavailable"
|
||||
}
|
||||
|
||||
private static func menuStatusText(_ text: String) -> String {
|
||||
let lines = text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let singleLine = (lines.isEmpty ? text : lines.joined(separator: " "))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard singleLine.count > 180 else { return singleLine }
|
||||
return "\(singleLine.prefix(177))…"
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
@@ -1283,6 +1309,14 @@ extension MenuSessionsInjector {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
|
||||
func testingControlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
|
||||
self.controlChannelStatusText(for: state)
|
||||
}
|
||||
|
||||
func testingMenuStatusText(_ text: String) -> String {
|
||||
Self.menuStatusText(text)
|
||||
}
|
||||
|
||||
func testingFindInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ final class NodePairingApprovalPrompter {
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow node to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Node"),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
}
|
||||
@@ -307,11 +308,11 @@ final class NodePairingApprovalPrompter {
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
await self.notify(resolution: .approved, request: request, via: "local")
|
||||
case .alertSecondButtonReturn:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
await self.notify(resolution: .rejected, request: request, via: "local")
|
||||
|
||||
@@ -26,6 +26,18 @@ enum PairingAlertSupport {
|
||||
case rejected
|
||||
}
|
||||
|
||||
struct ButtonTitles {
|
||||
var approve: String = "Approve"
|
||||
var postpone: String = "Not Now"
|
||||
var reject: String = "Reject"
|
||||
|
||||
init(approve: String = "Approve", postpone: String = "Not Now", reject: String = "Reject") {
|
||||
self.approve = approve
|
||||
self.postpone = postpone
|
||||
self.reject = reject
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let decision: String
|
||||
@@ -71,14 +83,17 @@ enum PairingAlertSupport {
|
||||
static func configureDefaultPairingAlert(
|
||||
_ alert: NSAlert,
|
||||
messageText: String,
|
||||
informativeText: String)
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil)
|
||||
{
|
||||
alert.alertStyle = .warning
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = messageText
|
||||
alert.informativeText = informativeText
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
alert.accessoryView = accessoryView
|
||||
alert.addButton(withTitle: buttonTitles.approve)
|
||||
alert.addButton(withTitle: buttonTitles.postpone)
|
||||
alert.addButton(withTitle: buttonTitles.reject)
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
@@ -133,13 +148,20 @@ enum PairingAlertSupport {
|
||||
static func beginPairingAlert(
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil,
|
||||
alertHostWindow: inout NSWindow?,
|
||||
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert
|
||||
{
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
self.configureDefaultPairingAlert(alert, messageText: messageText, informativeText: informativeText)
|
||||
self.configureDefaultPairingAlert(
|
||||
alert,
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
buttonTitles: buttonTitles,
|
||||
accessoryView: accessoryView)
|
||||
|
||||
let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow)
|
||||
self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in
|
||||
@@ -152,6 +174,8 @@ enum PairingAlertSupport {
|
||||
requestId: String,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil,
|
||||
activeAlert: inout NSAlert?,
|
||||
activeRequestId: inout String?,
|
||||
alertHostWindow: inout NSWindow?,
|
||||
@@ -161,6 +185,8 @@ enum PairingAlertSupport {
|
||||
activeAlert = self.beginPairingAlert(
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
buttonTitles: buttonTitles,
|
||||
accessoryView: accessoryView,
|
||||
alertHostWindow: &alertHostWindow,
|
||||
completion: completion)
|
||||
}
|
||||
@@ -170,6 +196,8 @@ enum PairingAlertSupport {
|
||||
requestId: String,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
buttonTitles: ButtonTitles = ButtonTitles(),
|
||||
accessoryView: NSView? = nil,
|
||||
state: PairingAlertState,
|
||||
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
|
||||
{
|
||||
@@ -177,6 +205,8 @@ enum PairingAlertSupport {
|
||||
requestId: requestId,
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
buttonTitles: buttonTitles,
|
||||
accessoryView: accessoryView,
|
||||
activeAlert: &state.activeAlert,
|
||||
activeRequestId: &state.activeRequestId,
|
||||
alertHostWindow: &state.alertHostWindow,
|
||||
|
||||
@@ -55,7 +55,10 @@ actor RemoteTunnelManager {
|
||||
if let create = self.createInFlight {
|
||||
self.logger.info("control tunnel create in flight; joining")
|
||||
let tunnel = try await create.task.value
|
||||
return try await self.installCreatedTunnel(tunnel, token: create.token, fallbackPort: UInt16(GatewayEnvironment.gatewayPort()))
|
||||
return try await self.installCreatedTunnel(
|
||||
tunnel,
|
||||
token: create.token,
|
||||
fallbackPort: UInt16(GatewayEnvironment.gatewayPort()))
|
||||
}
|
||||
await self.waitForRestartBackoffIfNeeded()
|
||||
|
||||
@@ -80,7 +83,11 @@ actor RemoteTunnelManager {
|
||||
return try await self.installCreatedTunnel(tunnel, token: token, fallbackPort: desiredPort)
|
||||
}
|
||||
|
||||
private func installCreatedTunnel(_ tunnel: RemotePortTunnel, token: UUID, fallbackPort: UInt16) async throws -> UInt16 {
|
||||
private func installCreatedTunnel(
|
||||
_ tunnel: RemotePortTunnel,
|
||||
token: UUID,
|
||||
fallbackPort: UInt16) async throws -> UInt16
|
||||
{
|
||||
if self.createInFlight?.token == token {
|
||||
self.createInFlight = nil
|
||||
}
|
||||
|
||||
@@ -161,12 +161,17 @@ private func configureSSHRemote(_ opts: ConfigureRemoteOptions) throws -> Config
|
||||
onboardingSkipped: true)
|
||||
}
|
||||
|
||||
private func configureDirectRemote(_ opts: ConfigureRemoteOptions, directUrlRaw: String) throws -> ConfigureRemoteOutput {
|
||||
private func configureDirectRemote(
|
||||
_ opts: ConfigureRemoteOptions,
|
||||
directUrlRaw: String) throws -> ConfigureRemoteOutput
|
||||
{
|
||||
guard let directURL = normalizeDirectURL(directUrlRaw) else {
|
||||
throw NSError(
|
||||
domain: "ConfigureRemote",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Direct URL must be ws:// for private/Tailscale hosts or wss:// for remote hosts"])
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Direct URL must be ws:// for private/Tailscale hosts or wss:// for remote hosts",
|
||||
])
|
||||
}
|
||||
|
||||
let configURL = openClawConfigURL()
|
||||
@@ -212,7 +217,7 @@ private func openClawConfigURL() -> URL {
|
||||
private func loadConfigRoot(from url: URL) throws -> [String: Any] {
|
||||
guard FileManager().isReadableFile(atPath: url.path) else { return [:] }
|
||||
let data = try Data(contentsOf: url)
|
||||
return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
return try (JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
}
|
||||
|
||||
private func saveConfigRoot(_ root: [String: Any], to url: URL) throws {
|
||||
|
||||
@@ -16,6 +16,9 @@ struct LowCoverageViewSmokeTests {
|
||||
|
||||
let withRows = ContextMenuCardView(rows: SessionRow.previewRows, statusText: nil, isLoading: false)
|
||||
_ = withRows.body
|
||||
|
||||
let longStatus = "Gateway connection dropped; gateway likely restarted and needs a few seconds to reconnect."
|
||||
_ = ContextRootMenuLabelView(subtitle: longStatus, width: 320).body
|
||||
}
|
||||
|
||||
@Test func `settings toggle row builds body`() {
|
||||
|
||||
@@ -118,9 +118,12 @@ struct MenuSessionsInjectorTests {
|
||||
injector.injectForTesting(into: menu)
|
||||
let contextItem = try #require(menu.items.first { $0.tag == 9_415_557 && $0.title == "Context" })
|
||||
let contextSubmenu = try #require(contextItem.submenu)
|
||||
#expect(menu.items.filter { $0.tag == 9_415_557 && $0.title == "Context" }.count == 1)
|
||||
#expect(menu.items.count(where: { $0.tag == 9_415_557 && $0.title == "Context" }) == 1)
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
|
||||
#expect(contextSubmenu.items.compactMap { $0.representedObject as? String }.filter { ["main", "discord:group:alpha"].contains($0) }.count == 2)
|
||||
#expect(contextSubmenu.items.compactMap { $0.representedObject as? String }.count(where: { [
|
||||
"main",
|
||||
"discord:group:alpha",
|
||||
].contains($0) }) == 2)
|
||||
#expect(contextSubmenu.items.allSatisfy { $0.title != "Usage cost (30 days)" })
|
||||
let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }))
|
||||
let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" }))
|
||||
@@ -174,6 +177,22 @@ struct MenuSessionsInjectorTests {
|
||||
#expect(usageCostItem?.submenu?.delegate == nil)
|
||||
}
|
||||
|
||||
@Test func `status text keeps useful error detail`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
let longError = """
|
||||
Gateway connection dropped; gateway likely restarted.
|
||||
Reconnect after the gateway finishes booting.
|
||||
Details that should stay readable instead of collapsing into one tiny menu ellipsis.
|
||||
"""
|
||||
|
||||
let normalized = injector.testingControlChannelStatusText(for: .degraded(longError))
|
||||
|
||||
#expect(normalized.contains("Gateway connection dropped"))
|
||||
#expect(normalized.contains("Reconnect after"))
|
||||
#expect(normalized.count <= 180)
|
||||
#expect(!normalized.contains("\n"))
|
||||
}
|
||||
|
||||
@Test func `node status text distinguishes paired disconnected nodes`() {
|
||||
let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false)
|
||||
let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Testing
|
||||
import AppKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@@ -7,4 +8,60 @@ struct NodePairingApprovalPrompterTests {
|
||||
@Test func `node pairing approval prompter exercises`() async {
|
||||
await NodePairingApprovalPrompter.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func `pairing alert makes approve the primary action`() {
|
||||
let alert = NSAlert()
|
||||
PairingAlertSupport.configureDefaultPairingAlert(
|
||||
alert,
|
||||
messageText: "New Mac wants to connect",
|
||||
informativeText: "Approve this Mac app to control OpenClaw.",
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Mac"))
|
||||
|
||||
#expect(alert.alertStyle == .informational)
|
||||
#expect(alert.buttons.map(\.title) == ["Approve Mac", "Not Now", "Reject"])
|
||||
if #available(macOS 11.0, *) {
|
||||
#expect(alert.buttons[2].hasDestructiveAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `device pairing copy summarizes Mac requests`() {
|
||||
let request = DevicePairingApprovalPrompter.PendingRequest(
|
||||
requestId: "req-1",
|
||||
deviceId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd",
|
||||
publicKey: "pub",
|
||||
displayName: nil,
|
||||
platform: "MacIntel",
|
||||
clientId: nil,
|
||||
clientMode: nil,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
],
|
||||
remoteIp: "192.0.2.10",
|
||||
silent: nil,
|
||||
isRepair: nil,
|
||||
ts: 1)
|
||||
|
||||
#expect(DevicePairingApprovalPrompter.alertTitle(for: request) == "New Mac wants to connect")
|
||||
#expect(DevicePairingApprovalPrompter.approveButtonTitle(for: request) == "Approve Mac")
|
||||
#expect(DevicePairingApprovalPrompter.deviceName(for: request) == "OpenClaw Mac app")
|
||||
#expect(DevicePairingApprovalPrompter.prettyPlatform(request.platform) == "Mac (Intel)")
|
||||
#expect(DevicePairingApprovalPrompter.shortIdentifier(request.deviceId) == "4a865684...f4e82bd")
|
||||
#expect(DevicePairingApprovalPrompter.friendlyScopeNames(request.scopes) == [
|
||||
"Admin access",
|
||||
"Read OpenClaw data",
|
||||
"Send messages and make changes",
|
||||
"Manage approvals",
|
||||
"Pair and repair devices",
|
||||
])
|
||||
#expect(!DevicePairingApprovalPrompter.alertSummary(for: request).contains(request.deviceId))
|
||||
|
||||
let accessory = DevicePairingApprovalPrompter.buildAccessoryView(for: request)
|
||||
#expect(accessory.frame.width >= 380)
|
||||
#expect(accessory.frame.height > 80)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1501,18 +1501,22 @@ public struct SecretsReloadParams: Codable, Sendable {}
|
||||
public struct SecretsResolveParams: Codable, Sendable {
|
||||
public let commandname: String
|
||||
public let targetids: [String]
|
||||
public let provideroverrides: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
commandname: String,
|
||||
targetids: [String])
|
||||
targetids: [String],
|
||||
provideroverrides: [String: AnyCodable]?)
|
||||
{
|
||||
self.commandname = commandname
|
||||
self.targetids = targetids
|
||||
self.provideroverrides = provideroverrides
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case commandname = "commandName"
|
||||
case targetids = "targetIds"
|
||||
case provideroverrides = "providerOverrides"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
1b2d60a1ce15bdac9db5259df0480a6073646faf1de81d88bf53dc6e43ae2949 plugin-sdk-api-baseline.json
|
||||
d76b67aa2618604da379147f44ac0746850bc5f5174404c979dc82ec6c45e05d plugin-sdk-api-baseline.jsonl
|
||||
2c665b045d30f690c5fd6adb89481a003d5cc55ab4eed1a0456ef47136f6b684 plugin-sdk-api-baseline.json
|
||||
f4b6c016576cd19409356ef23d18da0e54cb6c5904f864049461ace921e1f72c plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -393,6 +393,7 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
|
||||
- non-Ogg audio, including Microsoft Edge TTS MP3/WebM output, is transcoded with `ffmpeg` to 48 kHz mono Ogg/Opus before PTT delivery
|
||||
- `/tts latest` sends the latest assistant reply as one voice note and suppresses repeat sends for the same reply; `/tts chat on|off|default` controls auto-TTS for the current WhatsApp chat
|
||||
- animated GIF playback is supported via `gifPlayback: true` on video sends
|
||||
- `forceDocument` / `asDocument` sends outbound images, GIFs, and videos through the Baileys document payload to avoid WhatsApp media compression while preserving the resolved filename and MIME type
|
||||
- captions are applied to the first media item when sending multi-media reply payloads, except PTT voice notes send the audio first and visible text separately because WhatsApp clients do not render voice-note captions consistently
|
||||
- media source can be HTTP(S), `file://`, or local paths
|
||||
|
||||
@@ -402,7 +403,7 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
|
||||
- inbound media save cap: `channels.whatsapp.mediaMaxMb` (default `50`)
|
||||
- outbound media send cap: `channels.whatsapp.mediaMaxMb` (default `50`)
|
||||
- per-account overrides use `channels.whatsapp.accounts.<accountId>.mediaMaxMb`
|
||||
- images are auto-optimized (resize/quality sweep) to fit limits
|
||||
- images are auto-optimized (resize/quality sweep) to fit limits unless `forceDocument` / `asDocument` requests document delivery
|
||||
- on media send failure, first-item fallback sends text warning instead of dropping the response silently
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -55,7 +55,7 @@ Available sections:
|
||||
|
||||
Notes:
|
||||
|
||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||
- The full wizard and gateway-related sections ask where the Gateway runs and update `gateway.mode`. Section filters that do not include `gateway`, `daemon`, or `health` go directly to the requested setup.
|
||||
- After local config writes, configure installs selected downloadable plugins when the chosen setup path requires them. Remote gateway config does not install local plugin packages.
|
||||
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
|
||||
- If you run the daemon install step, token auth requires a token, and `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ Name lookup:
|
||||
- Optional: `--media`, `--presentation`, `--delivery`, `--pin`, `--reply-to`, `--thread-id`, `--gif-playback`, `--force-document`, `--silent`
|
||||
- Shared presentation payloads: `--presentation` sends semantic blocks (`text`, `context`, `divider`, `buttons`, `select`) that core renders through the selected channel's declared capabilities. See [Message Presentation](/plugins/message-presentation).
|
||||
- Generic delivery preferences: `--delivery` accepts delivery hints such as `{ "pin": true }`; `--pin` is shorthand for pinned delivery when the channel supports it.
|
||||
- Telegram only: `--force-document` (send images, GIFs, and videos as documents to avoid Telegram compression)
|
||||
- Telegram + WhatsApp: `--force-document` (send images, GIFs, and videos as documents to avoid channel compression)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- Telegram + Discord: `--silent`
|
||||
@@ -302,7 +302,7 @@ openclaw message send --channel msteams \
|
||||
--presentation '{"title":"Status update","blocks":[{"type":"text","text":"Build completed"}]}'
|
||||
```
|
||||
|
||||
Send a Telegram image as a document to avoid compression:
|
||||
Send a Telegram or WhatsApp image as a document to avoid compression:
|
||||
|
||||
```bash
|
||||
openclaw message send --channel telegram --target @mychat \
|
||||
|
||||
@@ -25,20 +25,20 @@ The first pack is intentionally narrow:
|
||||
## Scenarios
|
||||
|
||||
The machine-readable pack metadata lives in
|
||||
`extensions/qa-lab/src/scenario-packs.ts`. The initial pack does not add a CLI
|
||||
pack selector, so run the scenarios explicitly:
|
||||
`extensions/qa-lab/src/scenario-packs.ts`. Run the pack with
|
||||
`--pack personal-agent`:
|
||||
|
||||
```bash
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI=1 pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--scenario personal-reminder-roundtrip \
|
||||
--scenario personal-channel-thread-reply \
|
||||
--scenario personal-memory-preference-recall \
|
||||
--scenario personal-redaction-no-secret-leak \
|
||||
--scenario personal-tool-safety-followthrough \
|
||||
--pack personal-agent \
|
||||
--concurrency 1
|
||||
```
|
||||
|
||||
`--pack` is additive with repeated `--scenario` flags. Explicit scenarios run
|
||||
first, then the pack scenarios run in `QA_PERSONAL_AGENT_SCENARIO_IDS` order with
|
||||
duplicates removed.
|
||||
|
||||
The pack is designed for `qa-channel` with `mock-openai` or another local QA
|
||||
provider lane. It should not be pointed at live chat services or real personal
|
||||
accounts.
|
||||
|
||||
@@ -231,6 +231,9 @@ Host and Multipass suite runs execute multiple selected scenarios in parallel
|
||||
with isolated gateway workers by default. `qa-channel` defaults to concurrency
|
||||
4, capped by the selected scenario count. Use `--concurrency <count>` to tune
|
||||
the worker count, or `--concurrency 1` for serial execution.
|
||||
Use `--pack personal-agent` to run the personal assistant benchmark pack. The
|
||||
pack selector is additive with repeated `--scenario` flags: explicit scenarios
|
||||
run first, then pack scenarios run in pack order with duplicates removed.
|
||||
The command exits non-zero when any scenario fails. Use `--allow-failures` when
|
||||
you want artifacts without a failing exit code.
|
||||
Live runs forward the supported QA auth inputs that are practical for the
|
||||
|
||||
+1
-1
@@ -1560,7 +1560,7 @@ lives on the [Models FAQ](/help/faq-models).
|
||||
|
||||
Service/supervisor logs (when the gateway runs via launchd/systemd):
|
||||
|
||||
- macOS: `$OPENCLAW_STATE_DIR/logs/gateway.log` and `gateway.err.log` (default: `~/.openclaw/logs/...`; profiles use `~/.openclaw-<profile>/logs/...`)
|
||||
- macOS launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-<profile>.log`; stderr is suppressed)
|
||||
- Linux: `journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`
|
||||
- Windows: `schtasks /Query /TN "OpenClaw Gateway (<profile>)" /V /FO LIST`
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ Behavior:
|
||||
|
||||
Logging:
|
||||
|
||||
- launchd stdout/err: `/tmp/openclaw/openclaw-gateway.log`
|
||||
- launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-<profile>.log`)
|
||||
- launchd stderr: suppressed
|
||||
|
||||
## Version compatibility
|
||||
|
||||
|
||||
+18
-18
@@ -328,24 +328,24 @@ OpenClaw reads this before provider runtime loads.
|
||||
Provider setup lists use these manifest choices, descriptor-derived setup
|
||||
choices, and install-catalog metadata without loading provider runtime.
|
||||
|
||||
| Field | Required | Type | What it means |
|
||||
| --------------------- | -------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | Yes | `string` | Provider id this choice belongs to. |
|
||||
| `method` | Yes | `string` | Auth method id to dispatch to. |
|
||||
| `choiceId` | Yes | `string` | Stable auth-choice id used by onboarding and CLI flows. |
|
||||
| `choiceLabel` | No | `string` | User-facing label. If omitted, OpenClaw falls back to `choiceId`. |
|
||||
| `choiceHint` | No | `string` | Short helper text for the picker. |
|
||||
| `assistantPriority` | No | `number` | Lower values sort earlier in assistant-driven interactive pickers. |
|
||||
| `assistantVisibility` | No | `"visible"` \| `"manual-only"` | Hide the choice from assistant pickers while still allowing manual CLI selection. |
|
||||
| `deprecatedChoiceIds` | No | `string[]` | Legacy choice ids that should redirect users to this replacement choice. |
|
||||
| `groupId` | No | `string` | Optional group id for grouping related choices. |
|
||||
| `groupLabel` | No | `string` | User-facing label for that group. |
|
||||
| `groupHint` | No | `string` | Short helper text for the group. |
|
||||
| `optionKey` | No | `string` | Internal option key for simple one-flag auth flows. |
|
||||
| `cliFlag` | No | `string` | CLI flag name, such as `--openrouter-api-key`. |
|
||||
| `cliOption` | No | `string` | Full CLI option shape, such as `--openrouter-api-key <key>`. |
|
||||
| `cliDescription` | No | `string` | Description used in CLI help. |
|
||||
| `onboardingScopes` | No | `Array<"text-inference" \| "image-generation">` | Which onboarding surfaces this choice should appear in. If omitted, it defaults to `["text-inference"]`. |
|
||||
| Field | Required | Type | What it means |
|
||||
| --------------------- | -------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | Yes | `string` | Provider id this choice belongs to. |
|
||||
| `method` | Yes | `string` | Auth method id to dispatch to. |
|
||||
| `choiceId` | Yes | `string` | Stable auth-choice id used by onboarding and CLI flows. |
|
||||
| `choiceLabel` | No | `string` | User-facing label. If omitted, OpenClaw falls back to `choiceId`. |
|
||||
| `choiceHint` | No | `string` | Short helper text for the picker. |
|
||||
| `assistantPriority` | No | `number` | Lower values sort earlier in assistant-driven interactive pickers. |
|
||||
| `assistantVisibility` | No | `"visible"` \| `"manual-only"` | Hide the choice from assistant pickers while still allowing manual CLI selection. |
|
||||
| `deprecatedChoiceIds` | No | `string[]` | Legacy choice ids that should redirect users to this replacement choice. |
|
||||
| `groupId` | No | `string` | Optional group id for grouping related choices. |
|
||||
| `groupLabel` | No | `string` | User-facing label for that group. |
|
||||
| `groupHint` | No | `string` | Short helper text for the group. |
|
||||
| `optionKey` | No | `string` | Internal option key for simple one-flag auth flows. |
|
||||
| `cliFlag` | No | `string` | CLI flag name, such as `--openrouter-api-key`. |
|
||||
| `cliOption` | No | `string` | Full CLI option shape, such as `--openrouter-api-key <key>`. |
|
||||
| `cliDescription` | No | `string` | Description used in CLI help. |
|
||||
| `onboardingScopes` | No | `Array<"text-inference" \| "image-generation" \| "music-generation">` | Which onboarding surfaces this choice should appear in. If omitted, it defaults to `["text-inference"]`. |
|
||||
|
||||
## commandAliases reference
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Adds fal model provider support to OpenClaw.
|
||||
|
||||
## Surface
|
||||
|
||||
providers: fal; contracts: imageGenerationProviders, videoGenerationProviders
|
||||
providers: fal; contracts: imageGenerationProviders, musicGenerationProviders, videoGenerationProviders
|
||||
|
||||
## Related docs
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Adds OpenRouter model provider support to OpenClaw.
|
||||
|
||||
## Surface
|
||||
|
||||
providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders
|
||||
providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders
|
||||
|
||||
## Related docs
|
||||
|
||||
|
||||
+37
-4
@@ -1,13 +1,14 @@
|
||||
---
|
||||
summary: "fal image and video generation setup in OpenClaw"
|
||||
summary: "fal image, video, and music generation setup in OpenClaw"
|
||||
title: "Fal"
|
||||
read_when:
|
||||
- You want to use fal image generation in OpenClaw
|
||||
- You need the FAL_KEY auth flow
|
||||
- You want fal defaults for image_generate or video_generate
|
||||
- You want fal defaults for image_generate, video_generate, or music_generate
|
||||
---
|
||||
|
||||
OpenClaw ships a bundled `fal` provider for hosted image and video generation.
|
||||
OpenClaw ships a bundled `fal` provider for hosted image, video, and music
|
||||
generation.
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ------------------------------------------------------------- |
|
||||
@@ -151,6 +152,35 @@ The bundled `fal` video-generation provider defaults to
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Music generation
|
||||
|
||||
The bundled `fal` plugin also registers a music-generation provider for the
|
||||
shared `music_generate` tool.
|
||||
|
||||
| Capability | Value |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Default model | `fal/fal-ai/minimax-music/v2.6` |
|
||||
| Models | `fal-ai/minimax-music/v2.6`, `fal-ai/ace-step/prompt-to-audio`, `fal-ai/stable-audio-25/text-to-audio` |
|
||||
| Runtime | Synchronous request plus generated audio download |
|
||||
|
||||
Use fal as the default music provider:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
musicGenerationModel: {
|
||||
primary: "fal/fal-ai/minimax-music/v2.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`fal-ai/minimax-music/v2.6` supports explicit lyrics and instrumental mode.
|
||||
ACE-Step and Stable Audio are prompt-to-audio endpoints; choose them with the
|
||||
`model` override when you want those model families.
|
||||
|
||||
<Tip>
|
||||
Use `openclaw models list --provider fal` to see the full list of available fal
|
||||
models, including any recently added entries.
|
||||
@@ -165,7 +195,10 @@ models, including any recently added entries.
|
||||
<Card title="Video generation" href="/tools/video-generation" icon="video">
|
||||
Shared video tool parameters and provider selection.
|
||||
</Card>
|
||||
<Card title="Music generation" href="/tools/music-generation" icon="music">
|
||||
Shared music tool parameters and provider selection.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/config-agents#agent-defaults" icon="gear">
|
||||
Agent defaults including image and video model selection.
|
||||
Agent defaults including image, video, and music model selection.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -4,6 +4,7 @@ read_when:
|
||||
- You want a single API key for many LLMs
|
||||
- You want to run models via OpenRouter in OpenClaw
|
||||
- You want to use OpenRouter for image generation
|
||||
- You want to use OpenRouter for music generation
|
||||
- You want to use OpenRouter for video generation
|
||||
title: "OpenRouter"
|
||||
---
|
||||
@@ -107,6 +108,34 @@ second durations, `720P`/`1080P` resolutions, and `16:9`/`9:16` aspect
|
||||
ratios. Video-to-video is not registered for OpenRouter because the upstream
|
||||
video generation API currently accepts text and image references.
|
||||
|
||||
## Music generation
|
||||
|
||||
OpenRouter can also back the `music_generate` tool through chat completions
|
||||
audio output. Use an OpenRouter audio model under
|
||||
`agents.defaults.musicGenerationModel`:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "sk-or-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
musicGenerationModel: {
|
||||
primary: "openrouter/google/lyria-3-pro-preview",
|
||||
timeoutMs: 180_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The bundled OpenRouter music provider defaults to
|
||||
`google/lyria-3-pro-preview` and also exposes
|
||||
`google/lyria-3-clip-preview`. OpenClaw sends `modalities: ["text",
|
||||
"audio"]`, enables streaming, collects the streamed audio chunks, and saves
|
||||
the result as generated media for channel delivery. Reference images are
|
||||
accepted for Lyria models through the shared `music_generate image=...`
|
||||
parameter.
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
OpenRouter can also be used as a TTS provider through its OpenAI-compatible
|
||||
|
||||
@@ -60,7 +60,7 @@ telephony, meetings, browser realtime, and native push-to-talk clients.
|
||||
| DeepInfra | ✓ | ✓ | | ✓ | ✓ | | ✓ |
|
||||
| Deepgram | | | | | ✓ | ✓ | |
|
||||
| ElevenLabs | | | | ✓ | ✓ | | |
|
||||
| fal | ✓ | ✓ | | | | | |
|
||||
| fal | ✓ | ✓ | ✓ | | | | |
|
||||
| Google | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
|
||||
| Gradium | | | | ✓ | | | |
|
||||
| Local CLI | | | | ✓ | | | |
|
||||
@@ -68,7 +68,7 @@ telephony, meetings, browser realtime, and native push-to-talk clients.
|
||||
| MiniMax | ✓ | ✓ | ✓ | ✓ | | | |
|
||||
| Mistral | | | | | ✓ | | |
|
||||
| OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
|
||||
| OpenRouter | ✓ | ✓ | | ✓ | ✓ | | ✓ |
|
||||
| OpenRouter | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
|
||||
| Qwen | | ✓ | | | | | |
|
||||
| Runway | | ✓ | | | | | |
|
||||
| SenseAudio | | | | | ✓ | | |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Generate music via music_generate across Google Lyria, MiniMax, and ComfyUI workflows"
|
||||
summary: "Generate music via music_generate across ComfyUI, fal, Google Lyria, MiniMax, and OpenRouter workflows"
|
||||
read_when:
|
||||
- Generating music or audio via the agent
|
||||
- Configuring music-generation providers and models
|
||||
@@ -9,8 +9,8 @@ sidebarTitle: "Music generation"
|
||||
---
|
||||
|
||||
The `music_generate` tool lets the agent create music or audio through the
|
||||
shared music-generation capability with configured providers — Google,
|
||||
MiniMax, and workflow-configured ComfyUI today.
|
||||
shared music-generation capability with configured providers — ComfyUI,
|
||||
fal, Google, MiniMax, and OpenRouter today.
|
||||
|
||||
For session-backed agent runs, OpenClaw starts music generation as a
|
||||
background task, tracks it in the task ledger, then wakes the agent again
|
||||
@@ -94,22 +94,26 @@ Generate an energetic chiptune loop about launching a rocket at sunrise.
|
||||
|
||||
## Supported providers
|
||||
|
||||
| Provider | Default model | Reference inputs | Supported controls | Auth |
|
||||
| -------- | ---------------------- | ---------------- | --------------------------------------------------------- | -------------------------------------- |
|
||||
| ComfyUI | `workflow` | Up to 1 image | Workflow-defined music or audio | `COMFY_API_KEY`, `COMFY_CLOUD_API_KEY` |
|
||||
| Google | `lyria-3-clip-preview` | Up to 10 images | `lyrics`, `instrumental`, `format` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
|
||||
| MiniMax | `music-2.6` | None | `lyrics`, `instrumental`, `durationSeconds`, `format=mp3` | `MINIMAX_API_KEY` or MiniMax OAuth |
|
||||
| Provider | Default model | Reference inputs | Supported controls | Auth |
|
||||
| ---------- | ---------------------------- | ---------------- | --------------------------------------------------------- | -------------------------------------- |
|
||||
| ComfyUI | `workflow` | Up to 1 image | Workflow-defined music or audio | `COMFY_API_KEY`, `COMFY_CLOUD_API_KEY` |
|
||||
| fal | `fal-ai/minimax-music/v2.6` | None | `lyrics`, `instrumental`, `durationSeconds`, `format` | `FAL_KEY` or `FAL_API_KEY` |
|
||||
| Google | `lyria-3-clip-preview` | Up to 10 images | `lyrics`, `instrumental`, `format` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
|
||||
| MiniMax | `music-2.6` | None | `lyrics`, `instrumental`, `durationSeconds`, `format=mp3` | `MINIMAX_API_KEY` or MiniMax OAuth |
|
||||
| OpenRouter | `google/lyria-3-pro-preview` | Up to 1 image | `lyrics`, `instrumental`, `durationSeconds`, `format` | `OPENROUTER_API_KEY` |
|
||||
|
||||
### Capability matrix
|
||||
|
||||
The explicit mode contract used by `music_generate`, contract tests, and the
|
||||
shared live sweep:
|
||||
|
||||
| Provider | `generate` | `edit` | Edit limit | Shared live lanes |
|
||||
| -------- | :--------: | :----: | ---------- | ------------------------------------------------------------------------- |
|
||||
| ComfyUI | ✓ | ✓ | 1 image | Not in the shared sweep; covered by `extensions/comfy/comfy.live.test.ts` |
|
||||
| Google | ✓ | ✓ | 10 images | `generate`, `edit` |
|
||||
| MiniMax | ✓ | — | None | `generate` |
|
||||
| Provider | `generate` | `edit` | Edit limit | Shared live lanes |
|
||||
| ---------- | :--------: | :----: | ---------- | ------------------------------------------------------------------------- |
|
||||
| ComfyUI | ✓ | ✓ | 1 image | Not in the shared sweep; covered by `extensions/comfy/comfy.live.test.ts` |
|
||||
| fal | ✓ | — | None | `generate` |
|
||||
| Google | ✓ | ✓ | 10 images | `generate`, `edit` |
|
||||
| MiniMax | ✓ | — | None | `generate` |
|
||||
| OpenRouter | ✓ | ✓ | 1 image | `generate`, `edit` |
|
||||
|
||||
Use `action: "list"` to inspect available shared providers and models at
|
||||
runtime:
|
||||
@@ -225,7 +229,7 @@ openclaw tasks cancel <taskId>
|
||||
defaults: {
|
||||
musicGenerationModel: {
|
||||
primary: "google/lyria-3-clip-preview",
|
||||
fallbacks: ["minimax/music-2.6"],
|
||||
fallbacks: ["fal/fal-ai/minimax-music/v2.6", "minimax/music-2.6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,6 +262,12 @@ explicit `model`, `primary`, and `fallbacks` entries.
|
||||
shared `music_generate` tool through the music-generation provider
|
||||
registry.
|
||||
</Accordion>
|
||||
<Accordion title="fal">
|
||||
Uses fal model endpoints through the shared provider auth path. The
|
||||
bundled provider defaults to `fal-ai/minimax-music/v2.6` and also exposes
|
||||
`fal-ai/ace-step/prompt-to-audio` and
|
||||
`fal-ai/stable-audio-25/text-to-audio` for prompt-to-audio requests.
|
||||
</Accordion>
|
||||
<Accordion title="Google (Lyria 3)">
|
||||
Uses Lyria 3 batch generation. The current bundled flow supports
|
||||
prompt, optional lyrics text, and optional reference images.
|
||||
@@ -267,6 +277,11 @@ explicit `model`, `primary`, and `fallbacks` entries.
|
||||
lyrics, instrumental mode, duration steering, and mp3 output through
|
||||
either `minimax` API-key auth or `minimax-portal` OAuth.
|
||||
</Accordion>
|
||||
<Accordion title="OpenRouter">
|
||||
Uses OpenRouter chat completions audio output with streaming enabled. The
|
||||
bundled provider defaults to `google/lyria-3-pro-preview` and also exposes
|
||||
`openrouter/google/lyria-3-clip-preview`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Choosing the right path
|
||||
@@ -278,8 +293,8 @@ explicit `model`, `primary`, and `fallbacks` entries.
|
||||
|
||||
If you are debugging ComfyUI-specific behavior, see
|
||||
[ComfyUI](/providers/comfy). If you are debugging shared provider
|
||||
behavior, start with [Google (Gemini)](/providers/google) or
|
||||
[MiniMax](/providers/minimax).
|
||||
behavior, start with [fal](/providers/fal), [Google (Gemini)](/providers/google),
|
||||
[MiniMax](/providers/minimax), or [OpenRouter](/providers/openrouter).
|
||||
|
||||
## Provider capability modes
|
||||
|
||||
@@ -331,7 +346,9 @@ profiles by default, and runs both `generate` and declared `edit` coverage when
|
||||
the provider enables edit mode. Coverage today:
|
||||
|
||||
- `google`: `generate` plus `edit`
|
||||
- `fal`: `generate` only
|
||||
- `minimax`: `generate` only
|
||||
- `openrouter`: `generate` plus `edit`
|
||||
- `comfy`: separate Comfy live coverage, not the shared provider sweep
|
||||
|
||||
Opt-in live coverage for the bundled ComfyUI music path:
|
||||
|
||||
@@ -86,7 +86,9 @@ requester chat when the run finishes.
|
||||
<Accordion title="Manual-spawn delivery resilience">
|
||||
- OpenClaw hands completions back to the requester session through an `agent` turn with a stable idempotency key.
|
||||
- If the requester run is still active, OpenClaw first tries to wake/steer that run instead of starting a second visible reply path.
|
||||
- If an active requester cannot be woken, OpenClaw falls back to a requester-agent handoff with the same completion context instead of dropping the announce.
|
||||
- If the requester-agent completion handoff fails or produces no visible output, OpenClaw treats delivery as failed and falls back to queue routing/retry. It does not raw-send the child result directly to the external chat.
|
||||
- Group and channel completion handoffs follow the same message-tool-only visible reply policy as normal group/channel turns, so the requester agent must use the message tool when required.
|
||||
- If direct handoff cannot be used, it falls back to queue routing.
|
||||
- If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up.
|
||||
- Completion delivery keeps the resolved requester route: thread-bound or conversation-bound completion routes win when available; if the completion origin only provides a channel, OpenClaw fills the missing target/account from the requester session's resolved route (`lastChannel` / `lastTo` / `lastAccountId`) so direct delivery still works.
|
||||
|
||||
@@ -331,7 +331,7 @@ export async function createBedrockEmbeddingProvider(
|
||||
family,
|
||||
});
|
||||
|
||||
const invoke = async (body: string): Promise<string> => {
|
||||
const invoke = async (body: string, signal?: AbortSignal): Promise<string> => {
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
const sdk = new BedrockRuntimeClient({ region: client.region });
|
||||
try {
|
||||
@@ -342,6 +342,7 @@ export async function createBedrockEmbeddingProvider(
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
}),
|
||||
signal ? { abortSignal: signal } : undefined,
|
||||
);
|
||||
return new TextDecoder().decode(res.body);
|
||||
} finally {
|
||||
@@ -351,37 +352,46 @@ export async function createBedrockEmbeddingProvider(
|
||||
|
||||
const isCohere = family === "cohere-v3" || family === "cohere-v4";
|
||||
|
||||
const embedSingle = async (text: string): Promise<number[]> => {
|
||||
const raw = await invoke(buildBody(family, text, client.dimensions));
|
||||
const embedSingle = async (text: string, signal?: AbortSignal): Promise<number[]> => {
|
||||
const raw = await invoke(buildBody(family, text, client.dimensions), signal);
|
||||
return sanitizeAndNormalizeEmbedding(parseSingle(family, raw));
|
||||
};
|
||||
|
||||
const embedCohere = async (
|
||||
texts: string[],
|
||||
inputType: "search_query" | "search_document",
|
||||
signal?: AbortSignal,
|
||||
): Promise<number[][]> => {
|
||||
const raw = await invoke(buildCohereBody(family, texts, inputType, client.dimensions));
|
||||
const raw = await invoke(buildCohereBody(family, texts, inputType, client.dimensions), signal);
|
||||
return parseCohereBatch(family, raw).map((e) => sanitizeAndNormalizeEmbedding(e));
|
||||
};
|
||||
|
||||
const embedQuery = async (text: string): Promise<number[]> => {
|
||||
const embedQuery = async (
|
||||
text: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<number[]> => {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return (await embedCohere([text], "search_query"))[0] ?? [];
|
||||
return (await embedCohere([text], "search_query", options?.signal))[0] ?? [];
|
||||
}
|
||||
return embedSingle(text);
|
||||
return embedSingle(text, options?.signal);
|
||||
};
|
||||
|
||||
const embedBatch = async (texts: string[]): Promise<number[][]> => {
|
||||
const embedBatch = async (
|
||||
texts: string[],
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<number[][]> => {
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return embedCohere(texts, "search_document");
|
||||
return embedCohere(texts, "search_document", options?.signal);
|
||||
}
|
||||
return Promise.all(texts.map((t) => (t.trim() ? embedSingle(t) : Promise.resolve([]))));
|
||||
return Promise.all(
|
||||
texts.map((t) => (t.trim() ? embedSingle(t, options?.signal) : Promise.resolve([]))),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -308,6 +308,29 @@ describe("withNoProxyForLocalhost preserves user-configured NO_PROXY", () => {
|
||||
delete process.env.no_proxy;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat substring matches as complete loopback coverage", async () => {
|
||||
const userNoProxy = "notlocalhost,127.0.0.10,[::1].example";
|
||||
process.env.NO_PROXY = userNoProxy;
|
||||
process.env.no_proxy = userNoProxy;
|
||||
process.env.HTTP_PROXY = "http://proxy:8080";
|
||||
|
||||
try {
|
||||
const { withNoProxyForLocalhost } = await import("./cdp-proxy-bypass.js");
|
||||
|
||||
await withNoProxyForLocalhost(async () => {
|
||||
expect(process.env.NO_PROXY).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
expect(process.env.no_proxy).toBe(`${userNoProxy},localhost,127.0.0.1,[::1]`);
|
||||
});
|
||||
|
||||
expect(process.env.NO_PROXY).toBe(userNoProxy);
|
||||
expect(process.env.no_proxy).toBe(userNoProxy);
|
||||
} finally {
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.no_proxy;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("withNoProxyForCdpUrl", () => {
|
||||
|
||||
@@ -47,9 +47,13 @@ const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]";
|
||||
|
||||
function noProxyAlreadyCoversLocalhost(): boolean {
|
||||
const current = process.env.NO_PROXY || process.env.no_proxy || "";
|
||||
return (
|
||||
current.includes("localhost") && current.includes("127.0.0.1") && current.includes("[::1]")
|
||||
const entries = new Set(
|
||||
current
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return entries.has("localhost") && entries.has("127.0.0.1") && entries.has("[::1]");
|
||||
}
|
||||
|
||||
export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clickChromeMcpElement,
|
||||
@@ -119,6 +121,7 @@ describe("chrome MCP page parsing", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("parses list_pages text responses when structuredContent is missing", async () => {
|
||||
@@ -230,6 +233,61 @@ describe("chrome MCP page parsing", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("redacts remote CDP URL secrets from attach failures", async () => {
|
||||
const secretToken = "browserless-secret-token-1234567890"; // pragma: allowlist secret
|
||||
const user = "browser-user";
|
||||
const password = "browser-password-1234567890"; // pragma: allowlist secret
|
||||
const cdpUrl = `wss://${user}:${password}@browserless.example/chrome?token=${secretToken}`;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-test-"));
|
||||
const configPath = path.join(tempDir, "openclaw.json");
|
||||
await fs.writeFile(configPath, JSON.stringify({ logging: { redactSensitive: "off" } }));
|
||||
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
|
||||
const fakeMcpCommand = path.join(tempDir, "fake-mcp.mjs");
|
||||
await fs.writeFile(
|
||||
fakeMcpCommand,
|
||||
`#!/usr/bin/env node
|
||||
const cdpUrl = process.argv.find((arg) => arg.includes("browserless.example")) ?? "";
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input += chunk;
|
||||
const match = input.match(/"id"\\s*:\\s*(\\d+)/);
|
||||
if (!match) return;
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: Number(match[1]),
|
||||
error: { code: -32000, message: "attach failed for " + cdpUrl },
|
||||
});
|
||||
process.stdout.write(body + "\\n");
|
||||
});
|
||||
`,
|
||||
);
|
||||
await fs.chmod(fakeMcpCommand, 0o755);
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await ensureChromeMcpAvailable(
|
||||
"remote-profile",
|
||||
{
|
||||
cdpUrl,
|
||||
mcpCommand: fakeMcpCommand,
|
||||
},
|
||||
{ ephemeral: true },
|
||||
);
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(message).toContain("Chrome MCP existing-session attach failed");
|
||||
expect(message).toContain("attach failed");
|
||||
expect(message).toContain("browserless.example");
|
||||
expect(message).not.toContain(cdpUrl);
|
||||
expect(message).not.toContain(user);
|
||||
expect(message).not.toContain(password);
|
||||
expect(message).not.toContain(secretToken);
|
||||
});
|
||||
|
||||
it("parses new_page text responses and returns the created tab", async () => {
|
||||
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
readStringValue,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { redactToolPayloadText } from "../logging/redact.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { asRecord } from "../record-shared.js";
|
||||
import { redactCdpUrl } from "./cdp.helpers.js";
|
||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||
import type { BrowserTab } from "./client.types.js";
|
||||
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
|
||||
@@ -87,6 +89,7 @@ const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
|
||||
const CHROME_MCP_NAVIGATE_TIMEOUT_MS = 20_000;
|
||||
const CHROME_MCP_HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||
const CHROME_MCP_STDERR_MAX_BYTES = 8 * 1024;
|
||||
const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi;
|
||||
const STALE_SELECTED_PAGE_ERROR =
|
||||
"The selected page has been closed. Call list_pages to see open pages.";
|
||||
|
||||
@@ -377,6 +380,14 @@ function drainStderr(transport: StdioClientTransport): () => string {
|
||||
return () => Buffer.concat(chunks).toString("utf8").trim().slice(-CHROME_MCP_STDERR_MAX_BYTES);
|
||||
}
|
||||
|
||||
function redactChromeMcpDiagnosticText(text: string): string {
|
||||
return redactToolPayloadText(
|
||||
text.replace(CDP_URL_IN_TEXT_RE, (match) =>
|
||||
redactToolPayloadText(redactCdpUrl(match) ?? match),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
@@ -432,18 +443,21 @@ async function createRealSession(
|
||||
const stderr = getStderr();
|
||||
if (stderr) {
|
||||
log.warn(
|
||||
`Chrome MCP attach failed for profile "${profileName}". Subprocess stderr:\n${stderr}`,
|
||||
`Chrome MCP attach failed for profile "${profileName}". Subprocess stderr:\n${redactChromeMcpDiagnosticText(stderr)}`,
|
||||
);
|
||||
}
|
||||
const targetLabel = options.browserUrl
|
||||
? `the configured Chrome endpoint (${options.browserUrl})`
|
||||
? `the configured Chrome endpoint (${redactToolPayloadText(redactCdpUrl(options.browserUrl) ?? options.browserUrl)})`
|
||||
: options.userDataDir
|
||||
? `the configured Chromium user data dir (${options.userDataDir})`
|
||||
: "Google Chrome's default profile";
|
||||
const detail = redactChromeMcpDiagnosticText(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
||||
`Make sure ${targetLabel} is running locally with remote debugging enabled. ` +
|
||||
`Details: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`Details: ${detail}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -517,6 +517,16 @@ describe("chrome.ts internal", () => {
|
||||
});
|
||||
|
||||
it("clears stale singleton locks and retries once after profile-in-use launch failure", async () => {
|
||||
const configPath = path.join(tmpDir, "openclaw.json");
|
||||
await fsp.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
logging: {
|
||||
redactPatterns: ["profile appears to be in use by another Chromium process"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
|
||||
let cdpReachable = false;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -1012,6 +1022,10 @@ describe("chrome.ts internal", () => {
|
||||
it("buffers stderr chunks when Chrome emits diagnostics while CDP comes up", async () => {
|
||||
// Covers onStderr (pushing chunks to stderrChunks) plus the
|
||||
// stderrHint truthy branch on failure.
|
||||
const configDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-redact-off-"));
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
await fsp.writeFile(configPath, JSON.stringify({ logging: { redactSensitive: "off" } }));
|
||||
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (
|
||||
@@ -1027,10 +1041,11 @@ describe("chrome.ts internal", () => {
|
||||
return false;
|
||||
});
|
||||
const fakeProc = makeFakeProc();
|
||||
const secretToken = "chrome-stderr-secret-1234567890"; // pragma: allowlist secret
|
||||
spawnMock.mockImplementation(() => {
|
||||
// Synthesize stderr data shortly after spawn.
|
||||
void Promise.resolve().then(() =>
|
||||
fakeProc.stderr.emit("data", Buffer.from("chrome crash log\n")),
|
||||
fakeProc.stderr.emit("data", Buffer.from(`chrome crash log token=${secretToken}\n`)),
|
||||
);
|
||||
return fakeProc;
|
||||
});
|
||||
@@ -1048,7 +1063,16 @@ describe("chrome.ts internal", () => {
|
||||
noSandbox: true,
|
||||
extraArgs: [],
|
||||
} as unknown as ResolvedBrowserConfig;
|
||||
await expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow(/Chrome stderr:/);
|
||||
let message = "";
|
||||
try {
|
||||
await launchOpenClawChrome(resolved, profile);
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
expect(message).toContain("Chrome stderr:");
|
||||
expect(message).toContain("chrome crash log");
|
||||
expect(message).not.toContain(secretToken);
|
||||
await fsp.rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("omits the sandbox hint on non-linux platforms", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { redactToolPayloadText } from "../logging/redact.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import { hasChromeProxyControlArg, omitChromeProxyEnv } from "./browser-proxy-mode.js";
|
||||
@@ -543,6 +544,7 @@ export async function launchOpenClawChrome(
|
||||
.catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`);
|
||||
const stderrOutput =
|
||||
normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? "";
|
||||
const redactedStderrOutput = redactToolPayloadText(stderrOutput);
|
||||
if (
|
||||
allowSingletonRecovery &&
|
||||
CHROME_SINGLETON_IN_USE_PATTERN.test(stderrOutput) &&
|
||||
@@ -554,8 +556,8 @@ export async function launchOpenClawChrome(
|
||||
await terminateChromeForRetry(proc, userDataDir);
|
||||
return await launchOnceAndWait(false);
|
||||
}
|
||||
const stderrHint = stderrOutput
|
||||
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
|
||||
const stderrHint = redactedStderrOutput
|
||||
? `\nChrome stderr:\n${redactedStderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
|
||||
: "";
|
||||
const launchHints = chromeLaunchHints({ stderrOutput, resolved, profile, launchOptions });
|
||||
try {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { redactSensitiveText } from "../sdk-security-runtime.js";
|
||||
export { redactSensitiveText, redactToolPayloadText } from "openclaw/plugin-sdk/logging-core";
|
||||
|
||||
@@ -652,7 +652,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("selects an oauthRef-backed Codex profile for app-server login", () => {
|
||||
it("does not select Codex profiles without inline OAuth credential material", () => {
|
||||
expect(
|
||||
resolveCodexAppServerAuthProfileId({
|
||||
store: {
|
||||
@@ -664,19 +664,14 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
access: "",
|
||||
refresh: "",
|
||||
expires: Date.now() + 60_000,
|
||||
oauthRef: {
|
||||
source: "openclaw-credentials",
|
||||
provider: "openai-codex",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("openai-codex:default");
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("answers refresh requests from a discovered oauthRef-backed Codex profile", async () => {
|
||||
it("answers refresh requests from a discovered inline Codex OAuth profile", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
|
||||
access: "refreshed-ref-backed-access-token",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CODEX_APP_SERVER_CONFIG_KEYS,
|
||||
CODEX_COMPUTER_USE_CONFIG_KEYS,
|
||||
@@ -773,6 +773,26 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(second).not.toContain("sk-second");
|
||||
});
|
||||
|
||||
it("keeps secret-derived shared-client keys stable across module reloads", async () => {
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
authToken: "tok_reload",
|
||||
headers: {},
|
||||
env: { OPENAI_API_KEY: "sk-reload" },
|
||||
};
|
||||
const first = codexAppServerStartOptionsKey(startOptions);
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("./config.js");
|
||||
|
||||
expect(reloaded.codexAppServerStartOptionsKey(startOptions)).toEqual(first);
|
||||
expect(first).not.toContain("tok_reload");
|
||||
expect(first).not.toContain("sk-reload");
|
||||
});
|
||||
|
||||
it("derives distinct shared-client keys for distinct agent dirs", () => {
|
||||
const startOptions = {
|
||||
transport: "stdio" as const,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { hostname as readHostName } from "node:os";
|
||||
import { z } from "zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET = randomBytes(32);
|
||||
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
const UNIX_CODEX_REQUIREMENTS_PATH = "/etc/codex/requirements.toml";
|
||||
const WINDOWS_CODEX_REQUIREMENTS_SUFFIX = "\\OpenAI\\Codex\\requirements.toml";
|
||||
|
||||
@@ -991,6 +992,14 @@ function hashSecretForKey(value: string | undefined, label: string): string | nu
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function getStartOptionsKeySecret(): Buffer {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[START_OPTIONS_KEY_SECRET_SYMBOL]?: Buffer;
|
||||
};
|
||||
globalState[START_OPTIONS_KEY_SECRET_SYMBOL] ??= randomBytes(32);
|
||||
return globalState[START_OPTIONS_KEY_SECRET_SYMBOL];
|
||||
}
|
||||
|
||||
function splitShellWords(value: string): string[] {
|
||||
const words: string[] = [];
|
||||
let current = "";
|
||||
|
||||
@@ -238,6 +238,7 @@ function createAppServerHarness(
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleServerRequest: AppServerRequestHandler | undefined;
|
||||
const closeHandlers = new Set<() => void>();
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
return requestImpl(method, params);
|
||||
@@ -255,6 +256,10 @@ function createAppServerHarness(
|
||||
handleServerRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addCloseHandler: (handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
} as never;
|
||||
});
|
||||
|
||||
@@ -302,6 +307,11 @@ function createAppServerHarness(
|
||||
},
|
||||
});
|
||||
},
|
||||
close() {
|
||||
for (const handler of closeHandlers) {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2244,7 +2254,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not release post-tool raw assistant progress after the assistant idle timeout", async () => {
|
||||
it("times out post-tool raw assistant progress after the assistant idle timeout", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
@@ -2317,26 +2327,28 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
),
|
||||
{ interval: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not release post-native-tool raw assistant progress after the assistant idle timeout", async () => {
|
||||
it("times out post-native-tool raw assistant progress after the assistant idle timeout", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
@@ -2403,23 +2415,25 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
),
|
||||
{ interval: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("logs raw assistant item context when the terminal watchdog fires", async () => {
|
||||
@@ -4839,6 +4853,46 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(result.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fail when a buffered terminal notification is followed by client close", async () => {
|
||||
let harness: ReturnType<typeof createAppServerHarness>;
|
||||
let resolveBufferedTerminal!: () => void;
|
||||
const bufferedTerminal = new Promise<void>((resolve) => {
|
||||
resolveBufferedTerminal = resolve;
|
||||
});
|
||||
harness = createAppServerHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: { id: "tool-1", type: "commandExecution" },
|
||||
},
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
resolveBufferedTerminal();
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
{ turnTerminalIdleTimeoutMs: 60_000 },
|
||||
);
|
||||
await bufferedTerminal;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
harness.close();
|
||||
|
||||
const result = await run;
|
||||
expect(result.promptError ?? undefined).toBeUndefined();
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it("does not time out when turn progress arrives before turn/start returns", async () => {
|
||||
let harness: ReturnType<typeof createAppServerHarness>;
|
||||
harness = createAppServerHarness(async (method) => {
|
||||
@@ -5022,6 +5076,41 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
});
|
||||
|
||||
it("releases completion when the app-server client closes during an active turn", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
{ turnTerminalIdleTimeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
harness.close();
|
||||
|
||||
const result = await run;
|
||||
expect(result.promptError).toBe("codex app-server client closed before turn completed");
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fail a turn when the client closes after terminal completion is queued", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
{ turnTerminalIdleTimeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
await harness.waitForMethod("turn/start");
|
||||
const completed = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
harness.close();
|
||||
await completed;
|
||||
|
||||
const result = await run;
|
||||
expect(result.promptError ?? undefined).toBeUndefined();
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a user prompt containing the interrupted marker as terminal", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const markerPrompt =
|
||||
@@ -6329,6 +6418,85 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("keeps native hook relay config as the final thread config patch", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start" || method === "thread/resume") {
|
||||
return threadStartResult("thread-hooks");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const pluginAppPolicyContext = createPluginAppPolicyContext();
|
||||
const finalConfigPatch = {
|
||||
"features.hooks": true,
|
||||
"hooks.PreToolUse": [
|
||||
{
|
||||
hooks: [{ type: "command", command: "openclaw-native-hook-relay", timeout: 5 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const buildPluginThreadConfig = vi.fn(async () => ({
|
||||
enabled: true,
|
||||
configPatch: {
|
||||
"features.hooks": false,
|
||||
"hooks.PreToolUse": [],
|
||||
...createPluginAppConfigPatch(),
|
||||
},
|
||||
fingerprint: "plugin-apps-config-1",
|
||||
inputFingerprint: "plugin-apps-input-1",
|
||||
policyContext: pluginAppPolicyContext,
|
||||
diagnostics: [],
|
||||
}));
|
||||
const pluginThreadConfig = {
|
||||
enabled: true,
|
||||
inputFingerprint: "plugin-apps-input-1",
|
||||
build: buildPluginThreadConfig,
|
||||
};
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
config: { "features.hooks": false },
|
||||
finalConfigPatch,
|
||||
pluginThreadConfig,
|
||||
});
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
config: { "features.hooks": false },
|
||||
finalConfigPatch,
|
||||
pluginThreadConfig: {
|
||||
...pluginThreadConfig,
|
||||
enabledPluginConfigKeys: ["google-calendar"],
|
||||
},
|
||||
});
|
||||
|
||||
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
expect(requestCalls[0]?.[1].config).toMatchObject({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
|
||||
...createPluginAppConfigPatch(),
|
||||
});
|
||||
expect(requestCalls[1]?.[1].config).toMatchObject({
|
||||
"features.hooks": true,
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": true,
|
||||
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
|
||||
});
|
||||
});
|
||||
|
||||
it("revalidates compatible plugin app bindings without resending app config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -867,7 +867,6 @@ export async function runCodexAppServerAttempt(
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
|
||||
);
|
||||
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
|
||||
@@ -930,6 +929,7 @@ export async function runCodexAppServerAttempt(
|
||||
appServer: pluginAppServer,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
config: threadConfig,
|
||||
finalConfigPatch: nativeHookRelayConfig,
|
||||
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
|
||||
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
|
||||
contextEngineProjection,
|
||||
@@ -1035,9 +1035,12 @@ export async function runCodexAppServerAttempt(
|
||||
let userInputBridge: ReturnType<typeof createCodexUserInputBridge> | undefined;
|
||||
let steeringQueue: ReturnType<typeof createCodexSteeringQueue> | undefined;
|
||||
let completed = false;
|
||||
let terminalTurnNotificationQueued = false;
|
||||
let timedOut = false;
|
||||
let turnCompletionIdleTimedOut = false;
|
||||
let turnCompletionIdleTimeoutMessage: string | undefined;
|
||||
let clientClosedPromptError: string | undefined;
|
||||
let clientClosedAbort = false;
|
||||
let lifecycleStarted = false;
|
||||
let lifecycleTerminalEmitted = false;
|
||||
let resolveCompletion: (() => void) | undefined;
|
||||
@@ -1057,6 +1060,7 @@ export async function runCodexAppServerAttempt(
|
||||
let turnCompletionIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let turnCompletionIdleWatchArmed = false;
|
||||
let turnCompletionIdleWatchPinnedByTerminalError = false;
|
||||
let turnCompletionIdleTimeoutOverrideMs: number | undefined;
|
||||
let turnAssistantCompletionIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let turnAssistantCompletionIdleWatchArmed = false;
|
||||
let turnAssistantCompletionLastActivityAt = Date.now();
|
||||
@@ -1192,8 +1196,9 @@ export async function runCodexAppServerAttempt(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timeoutMs = turnCompletionIdleTimeoutOverrideMs ?? turnCompletionIdleTimeoutMs;
|
||||
const idleMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
|
||||
if (idleMs < turnCompletionIdleTimeoutMs) {
|
||||
if (idleMs < timeoutMs) {
|
||||
scheduleTurnCompletionIdleWatch();
|
||||
return;
|
||||
}
|
||||
@@ -1206,7 +1211,7 @@ export async function runCodexAppServerAttempt(
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnCompletionIdleTimeoutMs,
|
||||
timeoutMs,
|
||||
lastActivityReason: turnCompletionLastActivityReason,
|
||||
...turnCompletionLastActivityDetails,
|
||||
});
|
||||
@@ -1214,7 +1219,7 @@ export async function runCodexAppServerAttempt(
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnCompletionIdleTimeoutMs,
|
||||
timeoutMs,
|
||||
lastActivityReason: turnCompletionLastActivityReason,
|
||||
...turnCompletionLastActivityDetails,
|
||||
});
|
||||
@@ -1270,7 +1275,8 @@ export async function runCodexAppServerAttempt(
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
|
||||
const delayMs = Math.max(1, turnCompletionIdleTimeoutMs - elapsedMs);
|
||||
const timeoutMs = turnCompletionIdleTimeoutOverrideMs ?? turnCompletionIdleTimeoutMs;
|
||||
const delayMs = Math.max(1, timeoutMs - elapsedMs);
|
||||
turnCompletionIdleTimer = setTimeout(fireTurnCompletionIdleTimeout, delayMs);
|
||||
turnCompletionIdleTimer.unref?.();
|
||||
}
|
||||
@@ -1348,6 +1354,7 @@ export async function runCodexAppServerAttempt(
|
||||
turnCompletionLastActivityAt = Date.now();
|
||||
turnCompletionLastActivityReason = reason;
|
||||
turnCompletionLastActivityDetails = options?.details;
|
||||
turnCompletionIdleTimeoutOverrideMs = undefined;
|
||||
if (options?.attemptProgress) {
|
||||
turnAttemptLastProgressAt = turnCompletionLastActivityAt;
|
||||
turnAttemptLastProgressReason = reason;
|
||||
@@ -1377,6 +1384,7 @@ export async function runCodexAppServerAttempt(
|
||||
const disarmTurnCompletionIdleWatch = () => {
|
||||
turnCompletionIdleWatchArmed = false;
|
||||
turnCompletionIdleWatchPinnedByTerminalError = false;
|
||||
turnCompletionIdleTimeoutOverrideMs = undefined;
|
||||
clearTurnCompletionIdleTimer();
|
||||
};
|
||||
|
||||
@@ -1393,9 +1401,14 @@ export async function runCodexAppServerAttempt(
|
||||
scheduleTurnAssistantCompletionIdleWatch();
|
||||
};
|
||||
|
||||
const armTurnCompletionIdleWatch = (options?: { pinnedByTerminalError?: boolean }) => {
|
||||
const armTurnCompletionIdleWatch = (options?: {
|
||||
pinnedByTerminalError?: boolean;
|
||||
timeoutMs?: number;
|
||||
}) => {
|
||||
turnCompletionIdleWatchArmed = true;
|
||||
turnCompletionIdleWatchPinnedByTerminalError = options?.pinnedByTerminalError === true;
|
||||
turnCompletionIdleTimeoutOverrideMs =
|
||||
options?.timeoutMs !== undefined ? Math.max(1, Math.floor(options.timeoutMs)) : undefined;
|
||||
scheduleTurnCompletionIdleWatch();
|
||||
};
|
||||
|
||||
@@ -1462,6 +1475,19 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
};
|
||||
|
||||
const isTerminalTurnNotificationForTurn = (
|
||||
notification: CodexServerNotification,
|
||||
notificationTurnId: string,
|
||||
): boolean => {
|
||||
if (!isTurnNotification(notification.params, thread.threadId, notificationTurnId)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
notification.method === "turn/completed" ||
|
||||
isCodexTurnAbortMarkerNotification(notification, { currentPromptText: promptBuild.prompt })
|
||||
);
|
||||
};
|
||||
|
||||
const handleNotification = async (notification: CodexServerNotification) => {
|
||||
userInputBridge?.handleNotification(notification);
|
||||
if (!projector || !turnId) {
|
||||
@@ -1504,6 +1530,11 @@ export async function runCodexAppServerAttempt(
|
||||
notification,
|
||||
turnCrossedToolHandoff,
|
||||
);
|
||||
const postToolRawAssistantCompletionNeedsTerminalGuard =
|
||||
isCurrentTurnNotification &&
|
||||
turnCrossedToolHandoff &&
|
||||
isRawAssistantCompletionNotification(notification) &&
|
||||
activeTurnItemIds.size === 0;
|
||||
const shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem =
|
||||
isCurrentTurnNotification &&
|
||||
notification.method === "item/completed" &&
|
||||
@@ -1521,6 +1552,8 @@ export async function runCodexAppServerAttempt(
|
||||
disarmTurnAssistantCompletionIdleWatch();
|
||||
} else if (isCurrentTurnNotification && assistantCompletionCanRelease) {
|
||||
armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification));
|
||||
} else if (postToolRawAssistantCompletionNeedsTerminalGuard) {
|
||||
armTurnCompletionIdleWatch({ timeoutMs: turnAssistantCompletionIdleTimeoutMs });
|
||||
} else if (unblockedAssistantCompletionRelease) {
|
||||
armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification));
|
||||
} else if (shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem) {
|
||||
@@ -1545,6 +1578,7 @@ export async function runCodexAppServerAttempt(
|
||||
isCurrentTurnNotification &&
|
||||
!trackedDynamicToolCompletion &&
|
||||
!rawToolOutputCompletion &&
|
||||
!postToolRawAssistantCompletionNeedsTerminalGuard &&
|
||||
!shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem
|
||||
) {
|
||||
// The short completion-idle watchdog guards blind gaps after Codex
|
||||
@@ -1560,7 +1594,10 @@ export async function runCodexAppServerAttempt(
|
||||
const isTurnAbortMarker =
|
||||
isCurrentTurnNotification &&
|
||||
isCodexTurnAbortMarkerNotification(notification, { currentPromptText: promptBuild.prompt });
|
||||
const isTurnTerminal = isTurnCompletion || isTurnAbortMarker;
|
||||
const isTurnTerminal = isTerminalTurnNotificationForTurn(notification, turnId);
|
||||
if (isTurnTerminal) {
|
||||
terminalTurnNotificationQueued = true;
|
||||
}
|
||||
try {
|
||||
await waitForCodexNotificationDispatchTurn();
|
||||
await projector.handleNotification(notification);
|
||||
@@ -1591,6 +1628,9 @@ export async function runCodexAppServerAttempt(
|
||||
pendingNotifications.push(notification);
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (isTerminalTurnNotificationForTurn(notification, turnId)) {
|
||||
terminalTurnNotificationQueued = true;
|
||||
}
|
||||
notificationQueue = notificationQueue.then(
|
||||
() => handleNotification(notification),
|
||||
() => handleNotification(notification),
|
||||
@@ -1758,6 +1798,7 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
}
|
||||
});
|
||||
let closeCleanup: (() => void) | undefined;
|
||||
|
||||
const forceContextEngineCompactionForCodexOverflow = async (error: unknown): Promise<boolean> => {
|
||||
if (!activeContextEngine?.info.ownsCompaction) {
|
||||
@@ -2016,6 +2057,40 @@ export async function runCodexAppServerAttempt(
|
||||
nativePostToolUseRelayEnabled:
|
||||
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true,
|
||||
});
|
||||
if (
|
||||
isTerminalTurnStatus(turn.turn.status) ||
|
||||
pendingNotifications.some((notification) =>
|
||||
isTerminalTurnNotificationForTurn(notification, activeTurnId),
|
||||
)
|
||||
) {
|
||||
terminalTurnNotificationQueued = true;
|
||||
}
|
||||
closeCleanup = (
|
||||
client as {
|
||||
addCloseHandler?: (handler: (client: CodexAppServerClient) => void) => () => void;
|
||||
}
|
||||
).addCloseHandler?.(() => {
|
||||
if (completed || terminalTurnNotificationQueued || runAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
clientClosedPromptError = "codex app-server client closed before turn completed";
|
||||
trajectoryRecorder?.recordEvent("turn.client_closed", {
|
||||
threadId: thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server client closed before turn completed", {
|
||||
threadId: thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
});
|
||||
clientClosedAbort = true;
|
||||
runAbortController.abort("client_closed");
|
||||
completed = true;
|
||||
clearTurnAttemptIdleTimer();
|
||||
clearTurnCompletionIdleTimer();
|
||||
clearTurnAssistantCompletionIdleTimer();
|
||||
clearTurnTerminalIdleTimer();
|
||||
resolveCompletion?.();
|
||||
});
|
||||
emitLifecycleStart();
|
||||
const activeProjector = projector;
|
||||
turnTerminalIdleWatchArmed = true;
|
||||
@@ -2080,12 +2155,15 @@ export async function runCodexAppServerAttempt(
|
||||
try {
|
||||
await completion;
|
||||
const result = activeProjector.buildResult(toolBridge.telemetry, { yieldDetected });
|
||||
const finalAborted = result.aborted || runAbortController.signal.aborted;
|
||||
let finalPromptError = turnCompletionIdleTimedOut
|
||||
? turnCompletionIdleTimeoutMessage
|
||||
: timedOut
|
||||
? "codex app-server attempt timed out"
|
||||
: result.promptError;
|
||||
const finalAborted =
|
||||
result.aborted || (runAbortController.signal.aborted && !clientClosedAbort);
|
||||
let finalPromptError =
|
||||
clientClosedPromptError ??
|
||||
(turnCompletionIdleTimedOut
|
||||
? turnCompletionIdleTimeoutMessage
|
||||
: timedOut
|
||||
? "codex app-server attempt timed out"
|
||||
: result.promptError);
|
||||
const finalPromptErrorMessage =
|
||||
typeof finalPromptError === "string"
|
||||
? finalPromptError
|
||||
@@ -2104,7 +2182,8 @@ export async function runCodexAppServerAttempt(
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
}
|
||||
const finalPromptErrorSource = timedOut ? "prompt" : result.promptErrorSource;
|
||||
const finalPromptErrorSource =
|
||||
timedOut || clientClosedPromptError ? "prompt" : result.promptErrorSource;
|
||||
recordCodexTrajectoryCompletion(trajectoryRecorder, {
|
||||
attempt: params,
|
||||
result,
|
||||
@@ -2221,11 +2300,14 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
if (trajectoryRecorder && !trajectoryEndRecorded) {
|
||||
trajectoryRecorder.recordEvent("session.ended", {
|
||||
status: timedOut || runAbortController.signal.aborted ? "interrupted" : "cleanup",
|
||||
status:
|
||||
timedOut || (runAbortController.signal.aborted && !clientClosedAbort)
|
||||
? "interrupted"
|
||||
: "cleanup",
|
||||
threadId: thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
timedOut,
|
||||
aborted: runAbortController.signal.aborted,
|
||||
aborted: runAbortController.signal.aborted && !clientClosedAbort,
|
||||
});
|
||||
}
|
||||
await runAgentCleanupStep({
|
||||
@@ -2247,6 +2329,7 @@ export async function runCodexAppServerAttempt(
|
||||
clearTurnTerminalIdleTimer();
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
closeCleanup?.();
|
||||
nativeHookRelay?.unregister();
|
||||
runAbortController.signal.removeEventListener("abort", abortListener);
|
||||
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
|
||||
import { codexAppServerStartOptionsKey } from "./config.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -38,6 +39,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
|
||||
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
|
||||
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
|
||||
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
|
||||
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
|
||||
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
|
||||
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
|
||||
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
|
||||
@@ -110,6 +112,7 @@ describe("shared Codex app-server client", () => {
|
||||
({
|
||||
clearSharedCodexAppServerClient,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait,
|
||||
createIsolatedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
@@ -274,6 +277,70 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.authProfileId).toBe("openai-codex:work");
|
||||
});
|
||||
|
||||
it("migrates legacy singleton global state into the keyed registry", async () => {
|
||||
const legacy = createClientHarness();
|
||||
const next = createClientHarness();
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
authToken: "tok-legacy",
|
||||
headers: {},
|
||||
};
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: unknown;
|
||||
};
|
||||
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
|
||||
key,
|
||||
client: legacy.client,
|
||||
promise: Promise.resolve(legacy.client),
|
||||
};
|
||||
|
||||
await expect(getSharedCodexAppServerClient({ startOptions })).resolves.toBe(legacy.client);
|
||||
|
||||
legacy.client.close();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(next.client);
|
||||
const list = listCodexAppServerModels({ timeoutMs: 1000, startOptions });
|
||||
await sendInitializeResult(next, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(next);
|
||||
|
||||
await expect(list).resolves.toEqual({ models: [] });
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves the managed binary before bridging and spawning the shared client", async () => {
|
||||
const harness = createClientHarness();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -299,7 +366,7 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startCall?.commandSource).toBe("resolved-managed");
|
||||
});
|
||||
|
||||
it("restarts the shared client when the bridged auth token changes", async () => {
|
||||
it("starts an independent shared client when the bridged auth token changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
@@ -338,10 +405,10 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a superseded shared-client failure tear down the newer client", async () => {
|
||||
it("does not let one shared-client failure tear down another keyed client", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
@@ -375,12 +442,13 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
await expect(firstFailure).resolves.toBeInstanceOf(Error);
|
||||
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
first.client.close();
|
||||
await expect(firstFailure).resolves.toBeInstanceOf(Error);
|
||||
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -410,6 +478,44 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("waits only for the shared client that is still current", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
const secondCloseAndWait = vi.spyOn(second.client, "closeAndWait");
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
await expect(
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait(first.client, {
|
||||
exitTimeoutMs: 25,
|
||||
forceKillDelayMs: 5,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(secondCloseAndWait).not.toHaveBeenCalled();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a fresh websocket Authorization header after shared-client token rotation", async () => {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const authHeaders: Array<string | undefined> = [];
|
||||
|
||||
@@ -13,20 +13,61 @@ import {
|
||||
import { resolveManagedCodexAppServerStartOptions } from "./managed-binary.js";
|
||||
import { withTimeout } from "./timeout.js";
|
||||
|
||||
type SharedCodexAppServerClientState = {
|
||||
type SharedCodexAppServerClientEntry = {
|
||||
client?: CodexAppServerClient;
|
||||
promise?: Promise<CodexAppServerClient>;
|
||||
};
|
||||
|
||||
type SharedCodexAppServerClientState = {
|
||||
clients: Map<string, SharedCodexAppServerClientEntry>;
|
||||
};
|
||||
|
||||
type LegacySharedCodexAppServerClientState = Partial<SharedCodexAppServerClientEntry> & {
|
||||
key?: string;
|
||||
clients?: unknown;
|
||||
};
|
||||
|
||||
const SHARED_CODEX_APP_SERVER_CLIENT_STATE = Symbol.for("openclaw.codexAppServerClientState");
|
||||
|
||||
function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[SHARED_CODEX_APP_SERVER_CLIENT_STATE]?: SharedCodexAppServerClientState;
|
||||
[SHARED_CODEX_APP_SERVER_CLIENT_STATE]?: unknown;
|
||||
};
|
||||
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] ??= {};
|
||||
return globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE];
|
||||
const state = globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE];
|
||||
if (isSharedCodexAppServerClientState(state)) {
|
||||
return state;
|
||||
}
|
||||
const legacyState = readLegacySharedCodexAppServerClientState(state);
|
||||
const clients = new Map<string, SharedCodexAppServerClientEntry>();
|
||||
if (legacyState?.key && (legacyState.client || legacyState.promise)) {
|
||||
const legacyKey = legacyState.key;
|
||||
clients.set(legacyKey, { client: legacyState.client, promise: legacyState.promise });
|
||||
legacyState.client?.addCloseHandler((closedClient) =>
|
||||
clearSharedClientEntryIfCurrent(legacyKey, closedClient),
|
||||
);
|
||||
}
|
||||
const nextState: SharedCodexAppServerClientState = { clients };
|
||||
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] = nextState;
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function isSharedCodexAppServerClientState(
|
||||
value: unknown,
|
||||
): value is SharedCodexAppServerClientState {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
(value as { clients?: unknown }).clients instanceof Map
|
||||
);
|
||||
}
|
||||
|
||||
function readLegacySharedCodexAppServerClientState(
|
||||
value: unknown,
|
||||
): LegacySharedCodexAppServerClientState | undefined {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return value as LegacySharedCodexAppServerClientState;
|
||||
}
|
||||
|
||||
export async function getSharedCodexAppServerClient(options?: {
|
||||
@@ -36,7 +77,6 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
@@ -61,16 +101,14 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
authProfileId,
|
||||
agentDir: usesNativeAuth ? undefined : agentDir,
|
||||
});
|
||||
if (state.key && state.key !== key) {
|
||||
clearSharedCodexAppServerClient();
|
||||
}
|
||||
state.key = key;
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const entry = getOrCreateSharedClientEntry(state, key);
|
||||
const sharedPromise =
|
||||
state.promise ??
|
||||
(state.promise = (async () => {
|
||||
entry.promise ??
|
||||
(entry.promise = (async () => {
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
state.client = client;
|
||||
client.addCloseHandler(clearSharedClientIfCurrent);
|
||||
entry.client = client;
|
||||
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
|
||||
try {
|
||||
await client.initialize();
|
||||
await applyCodexAppServerAuthProfile({
|
||||
@@ -95,8 +133,9 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
"codex app-server initialize timed out",
|
||||
);
|
||||
} catch (error) {
|
||||
if (state.promise === sharedPromise && state.key === key) {
|
||||
clearSharedCodexAppServerClient();
|
||||
const currentEntry = state.clients.get(key);
|
||||
if (currentEntry?.promise === sharedPromise) {
|
||||
clearSharedClientEntry(key, currentEntry);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -150,18 +189,16 @@ export async function createIsolatedCodexAppServerClient(options?: {
|
||||
|
||||
export function resetSharedCodexAppServerClientForTests(): void {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
state.client = undefined;
|
||||
state.promise = undefined;
|
||||
state.key = undefined;
|
||||
state.clients.clear();
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClient(): void {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const client = state.client;
|
||||
state.client = undefined;
|
||||
state.promise = undefined;
|
||||
state.key = undefined;
|
||||
client?.close();
|
||||
const clients = collectSharedClients(state);
|
||||
state.clients.clear();
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClientIfCurrent(
|
||||
@@ -171,14 +208,35 @@ export function clearSharedCodexAppServerClientIfCurrent(
|
||||
return false;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
if (state.client !== client) {
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
state.clients.delete(key);
|
||||
client.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function clearSharedCodexAppServerClientIfCurrentAndWait(
|
||||
client: CodexAppServerClient | undefined,
|
||||
options?: {
|
||||
exitTimeoutMs?: number;
|
||||
forceKillDelayMs?: number;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
state.client = undefined;
|
||||
state.promise = undefined;
|
||||
state.key = undefined;
|
||||
client.close();
|
||||
return true;
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
state.clients.delete(key);
|
||||
await client.closeAndWait(options);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function clearSharedCodexAppServerClientAndWait(options?: {
|
||||
@@ -186,19 +244,46 @@ export async function clearSharedCodexAppServerClientAndWait(options?: {
|
||||
forceKillDelayMs?: number;
|
||||
}): Promise<void> {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const client = state.client;
|
||||
state.client = undefined;
|
||||
state.promise = undefined;
|
||||
state.key = undefined;
|
||||
await client?.closeAndWait(options);
|
||||
const clients = collectSharedClients(state);
|
||||
state.clients.clear();
|
||||
await Promise.all(clients.map((client) => client.closeAndWait(options)));
|
||||
}
|
||||
|
||||
function clearSharedClientIfCurrent(client: CodexAppServerClient): void {
|
||||
function getOrCreateSharedClientEntry(
|
||||
state: SharedCodexAppServerClientState,
|
||||
key: string,
|
||||
): SharedCodexAppServerClientEntry {
|
||||
let entry = state.clients.get(key);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
state.clients.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function clearSharedClientEntry(key: string, entry: SharedCodexAppServerClientEntry): void {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
if (state.client !== client) {
|
||||
if (state.clients.get(key) !== entry) {
|
||||
return;
|
||||
}
|
||||
state.client = undefined;
|
||||
state.promise = undefined;
|
||||
state.key = undefined;
|
||||
state.clients.delete(key);
|
||||
entry.client?.close();
|
||||
}
|
||||
|
||||
function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClient): void {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const entry = state.clients.get(key);
|
||||
if (entry?.client === client) {
|
||||
state.clients.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
|
||||
return [
|
||||
...new Set(
|
||||
[...state.clients.values()]
|
||||
.map((entry) => entry.client)
|
||||
.filter((client): client is CodexAppServerClient => Boolean(client)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function startOrResumeThread(params: {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
developerInstructions?: string;
|
||||
config?: JsonObject;
|
||||
finalConfigPatch?: JsonObject;
|
||||
mcpServersFingerprint?: string;
|
||||
mcpServersFingerprintEvaluated?: boolean;
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
@@ -230,7 +231,11 @@ export async function startOrResumeThread(params: {
|
||||
} else {
|
||||
try {
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
const resumeConfig = mergeCodexThreadConfigs(params.config, userMcpServersConfigPatch);
|
||||
const resumeConfig = mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
params.finalConfigPatch,
|
||||
);
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await params.client.request(
|
||||
"thread/resume",
|
||||
@@ -324,6 +329,7 @@ export async function startOrResumeThread(params: {
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
pluginThreadConfig?.configPatch,
|
||||
params.finalConfigPatch,
|
||||
);
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await params.client.request(
|
||||
|
||||
@@ -40,7 +40,7 @@ import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.
|
||||
import type { v2 } from "../app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientAndWait,
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait,
|
||||
getSharedCodexAppServerClient,
|
||||
} from "../app-server/shared-client.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
@@ -84,19 +84,22 @@ export function prepareTargetCodexAppServer(
|
||||
): CodexMigrationTargetAppServerPreparation {
|
||||
const appServer = resolveTargetCodexAppServer(ctx);
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
let warmedClient: Awaited<ReturnType<typeof getSharedCodexAppServerClient>> | undefined;
|
||||
const ready = getSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
timeoutMs: 60_000,
|
||||
agentDir: targets.agentDir,
|
||||
config: ctx.config,
|
||||
}).then(
|
||||
() => undefined,
|
||||
(client) => {
|
||||
warmedClient = client;
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return {
|
||||
async dispose() {
|
||||
await ready;
|
||||
await clearSharedCodexAppServerClientAndWait({
|
||||
await clearSharedCodexAppServerClientIfCurrentAndWait(warmedClient, {
|
||||
exitTimeoutMs: 2_000,
|
||||
forceKillDelayMs: 250,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ const handleDiscordActionMock = vi
|
||||
.spyOn(runtimeModule, "handleDiscordAction")
|
||||
.mockResolvedValue({ content: [], details: { ok: true } });
|
||||
const { handleDiscordMessageAction } = await import("./handle-action.js");
|
||||
const { beginDiscordInboundEventDeliveryCorrelation } =
|
||||
await import("../inbound-event-delivery.js");
|
||||
|
||||
function discordConfig(actions?: Record<string, boolean>): OpenClawConfig {
|
||||
return {
|
||||
@@ -201,6 +203,68 @@ describe("handleDiscordMessageAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies inbound event delivery after message sends", async () => {
|
||||
const markDelivered = vi.fn();
|
||||
const end = beginDiscordInboundEventDeliveryCorrelation(
|
||||
"agent:main:discord:channel:c1",
|
||||
{
|
||||
outboundTo: "channel:c1",
|
||||
outboundAccountId: "default",
|
||||
markInboundEventDelivered: markDelivered,
|
||||
},
|
||||
{ inboundEventKind: "room_event" },
|
||||
);
|
||||
|
||||
try {
|
||||
await handleDiscordMessageAction({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "channel:c1",
|
||||
message: "hello",
|
||||
},
|
||||
cfg: discordConfig(),
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
inboundEventKind: "room_event",
|
||||
});
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
|
||||
expect(markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("notifies inbound event delivery after visible message actions", async () => {
|
||||
const markDelivered = vi.fn();
|
||||
const end = beginDiscordInboundEventDeliveryCorrelation(
|
||||
"agent:main:discord:channel:c1",
|
||||
{
|
||||
outboundTo: "channel:c1",
|
||||
outboundAccountId: "default",
|
||||
markInboundEventDelivered: markDelivered,
|
||||
},
|
||||
{ inboundEventKind: "room_event" },
|
||||
);
|
||||
|
||||
try {
|
||||
await handleDiscordMessageAction({
|
||||
action: "upload-file",
|
||||
params: {
|
||||
to: "channel:c1",
|
||||
filePath: "/tmp/image.png",
|
||||
},
|
||||
cfg: discordConfig(),
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
inboundEventKind: "room_event",
|
||||
});
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
|
||||
expect(markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps upload-file to Discord sendMessage with media read context", async () => {
|
||||
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
|
||||
const mediaAccess = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { handleDiscordAction } from "../../action-runtime-api.js";
|
||||
import { notifyDiscordInboundEventOutboundSuccess } from "../inbound-event-delivery.js";
|
||||
import {
|
||||
buildDiscordInteractiveComponents,
|
||||
buildDiscordPresentationComponents,
|
||||
@@ -45,6 +46,8 @@ export async function handleDiscordMessageAction(
|
||||
| "mediaAccess"
|
||||
| "mediaLocalRoots"
|
||||
| "mediaReadFile"
|
||||
| "sessionKey"
|
||||
| "inboundEventKind"
|
||||
>,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const { action, params, cfg } = ctx;
|
||||
@@ -54,6 +57,13 @@ export async function handleDiscordMessageAction(
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
mediaReadFile: ctx.mediaReadFile,
|
||||
} as const;
|
||||
const notifyVisibleOutbound = (to: string, fallbackSessionKey?: string) =>
|
||||
notifyDiscordInboundEventOutboundSuccess({
|
||||
sessionKey: ctx.sessionKey ?? fallbackSessionKey ?? undefined,
|
||||
to,
|
||||
accountId,
|
||||
inboundEventKind: ctx.inboundEventKind,
|
||||
});
|
||||
|
||||
const readTarget = () => {
|
||||
const target =
|
||||
@@ -106,7 +116,7 @@ export async function handleDiscordMessageAction(
|
||||
const sessionKey = readStringParam(params, "__sessionKey");
|
||||
const agentId = readStringParam(params, "__agentId");
|
||||
const threadName = readStringParam(params, "threadName");
|
||||
return await handleDiscordAction(
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -127,6 +137,8 @@ export async function handleDiscordMessageAction(
|
||||
cfg,
|
||||
actionOptions,
|
||||
);
|
||||
notifyVisibleOutbound(to, sessionKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === "upload-file") {
|
||||
@@ -147,7 +159,7 @@ export async function handleDiscordMessageAction(
|
||||
const suppressEmbeds = readBooleanParam(params, "suppressEmbeds");
|
||||
const sessionKey = readStringParam(params, "__sessionKey");
|
||||
const agentId = readStringParam(params, "__agentId");
|
||||
return await handleDiscordAction(
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -164,6 +176,8 @@ export async function handleDiscordMessageAction(
|
||||
cfg,
|
||||
actionOptions,
|
||||
);
|
||||
notifyVisibleOutbound(to, sessionKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
@@ -177,7 +191,7 @@ export async function handleDiscordMessageAction(
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
action: "poll",
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -191,6 +205,8 @@ export async function handleDiscordMessageAction(
|
||||
cfg,
|
||||
actionOptions,
|
||||
);
|
||||
notifyVisibleOutbound(to);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
@@ -315,7 +331,7 @@ export async function handleDiscordMessageAction(
|
||||
integer: true,
|
||||
});
|
||||
const appliedTags = readStringArrayParam(params, "appliedTags");
|
||||
return await handleDiscordAction(
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
action: "threadCreate",
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -329,25 +345,30 @@ export async function handleDiscordMessageAction(
|
||||
cfg,
|
||||
actionOptions,
|
||||
);
|
||||
notifyVisibleOutbound(resolveChannelId());
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === "sticker") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const stickerIds =
|
||||
readStringArrayParam(params, "stickerId", {
|
||||
required: true,
|
||||
label: "sticker-id",
|
||||
}) ?? [];
|
||||
return await handleDiscordAction(
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
action: "sticker",
|
||||
accountId: accountId ?? undefined,
|
||||
to: readStringParam(params, "to", { required: true }),
|
||||
to,
|
||||
stickerIds,
|
||||
content: readStringParam(params, "message"),
|
||||
},
|
||||
cfg,
|
||||
actionOptions,
|
||||
);
|
||||
notifyVisibleOutbound(to);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === "set-presence") {
|
||||
@@ -371,6 +392,9 @@ export async function handleDiscordMessageAction(
|
||||
resolveChannelId,
|
||||
});
|
||||
if (adminResult !== undefined) {
|
||||
if (action === "thread-reply") {
|
||||
notifyVisibleOutbound(readStringParam(params, "threadId") ?? readTarget());
|
||||
}
|
||||
return adminResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,33 @@ describe("discordMessageActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares inbound event delivery metadata for durable core sends", async () => {
|
||||
const prepared = await discordMessageActions.prepareSendPayload?.({
|
||||
ctx: {
|
||||
channel: "discord",
|
||||
action: "send",
|
||||
cfg: {} as OpenClawConfig,
|
||||
params: {},
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
inboundEventKind: "room_event",
|
||||
},
|
||||
to: "channel:123",
|
||||
payload: { text: "hello" },
|
||||
});
|
||||
|
||||
expect(prepared).toEqual({
|
||||
text: "hello",
|
||||
channelData: {
|
||||
discord: {
|
||||
__openclawInboundEventDelivery: {
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
inboundEventKind: "room_event",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-serializable Discord component sends on the legacy action path", async () => {
|
||||
const prepared = await discordMessageActions.prepareSendPayload?.({
|
||||
ctx: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
|
||||
import { readDiscordComponentSpec } from "./components.js";
|
||||
import { withDiscordInboundEventDeliveryMetadata } from "./inbound-event-delivery.js";
|
||||
|
||||
let discordChannelActionsRuntimePromise:
|
||||
| Promise<typeof import("./channel-actions.runtime.js")>
|
||||
@@ -180,6 +181,10 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
if (ctx.action !== "send") {
|
||||
return null;
|
||||
}
|
||||
const payloadWithDeliveryMetadata = withDiscordInboundEventDeliveryMetadata(payload, {
|
||||
sessionKey: ctx.sessionKey,
|
||||
inboundEventKind: ctx.inboundEventKind,
|
||||
});
|
||||
const rawComponents = ctx.params.components;
|
||||
if (typeof rawComponents === "function") {
|
||||
return null;
|
||||
@@ -195,18 +200,18 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
const filename = normalizeOptionalString(ctx.params.filename);
|
||||
if (!componentSpec && !nativeComponents && !embeds?.length && !filename) {
|
||||
return payload;
|
||||
return payloadWithDeliveryMetadata;
|
||||
}
|
||||
const discordData =
|
||||
payload.channelData?.discord &&
|
||||
typeof payload.channelData.discord === "object" &&
|
||||
!Array.isArray(payload.channelData.discord)
|
||||
? (payload.channelData.discord as Record<string, unknown>)
|
||||
payloadWithDeliveryMetadata.channelData?.discord &&
|
||||
typeof payloadWithDeliveryMetadata.channelData.discord === "object" &&
|
||||
!Array.isArray(payloadWithDeliveryMetadata.channelData.discord)
|
||||
? (payloadWithDeliveryMetadata.channelData.discord as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...payload,
|
||||
...payloadWithDeliveryMetadata,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
...payloadWithDeliveryMetadata.channelData,
|
||||
discord: {
|
||||
...discordData,
|
||||
...(componentSpec ? { components: componentSpec } : {}),
|
||||
@@ -227,6 +232,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
sessionKey,
|
||||
inboundEventKind,
|
||||
}) => {
|
||||
return await (
|
||||
await loadDiscordChannelActionsRuntime()
|
||||
@@ -240,6 +247,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(inboundEventKind ? { inboundEventKind } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
|
||||
export type DiscordInboundEventDeliveryEnd = () => void;
|
||||
|
||||
type ActiveEvent = {
|
||||
outboundTo: string;
|
||||
outboundAccountId?: string;
|
||||
markInboundEventDelivered: () => void;
|
||||
};
|
||||
|
||||
const DISCORD_INBOUND_EVENT_DELIVERY_KEY = "__openclawInboundEventDelivery";
|
||||
const registry = new Map<string, ActiveEvent>();
|
||||
|
||||
function normalizeDiscordDeliveryTarget(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^discord:/iu, "")
|
||||
.replace(/^channel:/iu, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveDiscordInboundEventDeliveryCorrelationKey(
|
||||
sessionKey: string | undefined,
|
||||
inboundEventKind?: string,
|
||||
): string | undefined {
|
||||
const key = sessionKey?.trim();
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
return inboundEventKind === "room_event" ? `${key}:room_event` : key;
|
||||
}
|
||||
|
||||
export function beginDiscordInboundEventDeliveryCorrelation(
|
||||
sessionKey: string | undefined,
|
||||
event: ActiveEvent,
|
||||
options?: { inboundEventKind?: string },
|
||||
): DiscordInboundEventDeliveryEnd {
|
||||
const key = resolveDiscordInboundEventDeliveryCorrelationKey(
|
||||
sessionKey,
|
||||
options?.inboundEventKind,
|
||||
);
|
||||
if (!key) {
|
||||
return () => {};
|
||||
}
|
||||
registry.set(key, event);
|
||||
return () => {
|
||||
if (registry.get(key) === event) {
|
||||
registry.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyDiscordInboundEventOutboundSuccess(params: {
|
||||
sessionKey: string | undefined;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
inboundEventKind?: string;
|
||||
}): void {
|
||||
const key = resolveDiscordInboundEventDeliveryCorrelationKey(
|
||||
params.sessionKey,
|
||||
params.inboundEventKind,
|
||||
);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const event = registry.get(key);
|
||||
if (
|
||||
!event ||
|
||||
normalizeDiscordDeliveryTarget(event.outboundTo) !== normalizeDiscordDeliveryTarget(params.to)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.outboundAccountId && params.accountId && params.accountId !== event.outboundAccountId) {
|
||||
return;
|
||||
}
|
||||
registry.delete(key);
|
||||
event.markInboundEventDelivered();
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function withDiscordInboundEventDeliveryMetadata(
|
||||
payload: ReplyPayload,
|
||||
params: {
|
||||
sessionKey?: string | null;
|
||||
inboundEventKind?: string;
|
||||
},
|
||||
): ReplyPayload {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey || params.inboundEventKind !== "room_event") {
|
||||
return payload;
|
||||
}
|
||||
const channelData = readRecord(payload.channelData) ?? {};
|
||||
const discordData = readRecord(channelData.discord) ?? {};
|
||||
return {
|
||||
...payload,
|
||||
channelData: {
|
||||
...channelData,
|
||||
discord: {
|
||||
...discordData,
|
||||
[DISCORD_INBOUND_EVENT_DELIVERY_KEY]: {
|
||||
sessionKey,
|
||||
inboundEventKind: params.inboundEventKind,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyDiscordInboundEventOutboundPayloadSuccess(params: {
|
||||
payload: ReplyPayload;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
}): void {
|
||||
const channelData = readRecord(params.payload.channelData);
|
||||
const discordData = readRecord(channelData?.discord);
|
||||
const metadata = readRecord(discordData?.[DISCORD_INBOUND_EVENT_DELIVERY_KEY]);
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
notifyDiscordInboundEventOutboundSuccess({
|
||||
sessionKey: readString(metadata.sessionKey),
|
||||
inboundEventKind: readString(metadata.inboundEventKind),
|
||||
to: params.to,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
buildChannelInboundEventContext,
|
||||
formatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions,
|
||||
toHistoryMediaEntries,
|
||||
toInboundMediaFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
|
||||
@@ -151,6 +152,7 @@ export async function buildDiscordMessageProcessContext(params: {
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const channelHistory = createChannelHistoryWindow({ historyMap: guildHistories });
|
||||
const isRoomEvent = ctx.inboundEventKind === "room_event";
|
||||
let combinedBody = formatInboundEnvelope({
|
||||
channel: "Discord",
|
||||
from: fromLabel,
|
||||
@@ -162,7 +164,8 @@ export async function buildDiscordMessageProcessContext(params: {
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
const shouldIncludeChannelHistory =
|
||||
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
|
||||
!isDirectMessage &&
|
||||
(isRoomEvent || !(isGuildMessage && channelConfig?.autoThread && !threadChannel));
|
||||
if (shouldIncludeChannelHistory) {
|
||||
combinedBody = channelHistory.buildPendingContext({
|
||||
historyKey: messageChannelId,
|
||||
@@ -277,7 +280,7 @@ export async function buildDiscordMessageProcessContext(params: {
|
||||
message,
|
||||
messageChannelId,
|
||||
isGuildMessage,
|
||||
channelConfig,
|
||||
channelConfig: isRoomEvent ? null : channelConfig,
|
||||
threadChannel,
|
||||
channelType: channelInfo?.type,
|
||||
channelName: channelInfo?.name,
|
||||
@@ -424,6 +427,20 @@ export async function buildDiscordMessageProcessContext(params: {
|
||||
},
|
||||
});
|
||||
const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
|
||||
if (isRoomEvent && shouldIncludeChannelHistory) {
|
||||
await channelHistory.recordWithMedia({
|
||||
historyKey: messageChannelId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: senderName,
|
||||
body: text,
|
||||
timestamp: resolveTimestampMs(message.timestamp),
|
||||
messageId: message.id,
|
||||
},
|
||||
media: toHistoryMediaEntries(mediaList, { messageId: message.id }),
|
||||
messageId: message.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
||||
|
||||
@@ -141,6 +141,8 @@ type DispatchInboundParams = {
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
disableBlockStreaming?: boolean;
|
||||
suppressDefaultToolProgressMessages?: boolean;
|
||||
queuedDeliveryCorrelations?: Array<{ begin: () => () => void }>;
|
||||
suppressTyping?: boolean;
|
||||
onCompactionStart?: () => Promise<void> | void;
|
||||
onCompactionEnd?: () => Promise<void> | void;
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
@@ -189,6 +191,7 @@ let createDiscordDirectMessageContextOverrides: typeof import("./message-handler
|
||||
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
|
||||
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
|
||||
let processDiscordMessage: typeof import("./message-handler.process.js").processDiscordMessage;
|
||||
let notifyDiscordInboundEventOutboundSuccess: typeof import("../inbound-event-delivery.js").notifyDiscordInboundEventOutboundSuccess;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
dispatchInboundMessage: (params: DispatchInboundParams) => dispatchInboundMessage(params),
|
||||
@@ -342,6 +345,7 @@ beforeAll(async () => {
|
||||
({ __testing: threadBindingTesting, createThreadBindingManager } =
|
||||
await import("./thread-bindings.js"));
|
||||
({ processDiscordMessage } = await import("./message-handler.process.js"));
|
||||
({ notifyDiscordInboundEventOutboundSuccess } = await import("../inbound-event-delivery.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1328,6 +1332,115 @@ describe("processDiscordMessage session routing", () => {
|
||||
expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records Discord room events in history while source replies are tool-only", async () => {
|
||||
const guildHistories = new Map();
|
||||
const ctx = await createBaseContext({
|
||||
guildHistories,
|
||||
historyLimit: 10,
|
||||
shouldRequireMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
inboundEventKind: "room_event",
|
||||
baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(getLastDispatchReplyOptions()?.suppressTyping).toBe(true);
|
||||
expect(getLastDispatchReplyOptions()?.queuedDeliveryCorrelations).toHaveLength(1);
|
||||
expect(guildHistories.get("c1")).toMatchObject([
|
||||
{
|
||||
body: "hi",
|
||||
messageId: "m1",
|
||||
sender: "Alice",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears Discord room event history after a visible action send succeeds", async () => {
|
||||
const guildHistories = new Map();
|
||||
dispatchInboundMessage.mockImplementationOnce(async () => {
|
||||
notifyDiscordInboundEventOutboundSuccess({
|
||||
sessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
inboundEventKind: "room_event",
|
||||
to: "channel:c1",
|
||||
accountId: "default",
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createBaseContext({
|
||||
guildHistories,
|
||||
historyLimit: 10,
|
||||
shouldRequireMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
inboundEventKind: "room_event",
|
||||
baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(guildHistories.get("c1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears Discord group DM room event history after a visible action send succeeds", async () => {
|
||||
const guildHistories = new Map();
|
||||
dispatchInboundMessage.mockImplementationOnce(async () => {
|
||||
notifyDiscordInboundEventOutboundSuccess({
|
||||
sessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
inboundEventKind: "room_event",
|
||||
to: "channel:c1",
|
||||
accountId: "default",
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createBaseContext({
|
||||
guildHistories,
|
||||
historyLimit: 10,
|
||||
isGuildMessage: false,
|
||||
isGroupDm: true,
|
||||
isDirectMessage: false,
|
||||
shouldRequireMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
inboundEventKind: "room_event",
|
||||
baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(guildHistories.get("c1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears Discord room event history after a queued core send succeeds", async () => {
|
||||
const guildHistories = new Map();
|
||||
const ctx = await createBaseContext({
|
||||
guildHistories,
|
||||
historyLimit: 10,
|
||||
shouldRequireMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
inboundEventKind: "room_event",
|
||||
baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const begin = getLastDispatchReplyOptions()?.queuedDeliveryCorrelations?.[0]?.begin;
|
||||
expect(begin).toBeTypeOf("function");
|
||||
const end = begin?.();
|
||||
notifyDiscordInboundEventOutboundSuccess({
|
||||
sessionKey: BASE_CHANNEL_ROUTE.sessionKey,
|
||||
inboundEventKind: "room_event",
|
||||
to: "channel:c1",
|
||||
accountId: "default",
|
||||
});
|
||||
end?.();
|
||||
|
||||
expect(guildHistories.get("c1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses PluralKit original ids for inbound dedupe while preserving the Discord message id", async () => {
|
||||
const ctx = await createBaseContext({
|
||||
canonicalMessageId: "orig-123",
|
||||
|
||||
@@ -32,10 +32,12 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-run
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js";
|
||||
import { removeReactionDiscord } from "../send.js";
|
||||
import { editMessageDiscord } from "../send.messages.js";
|
||||
import { resolveDiscordTargetChannelId } from "../send.shared.js";
|
||||
@@ -423,6 +425,28 @@ export async function processDiscordMessage(
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "discord", accountId);
|
||||
const clearGroupHistory = () => {
|
||||
if (isDirectMessage) {
|
||||
return;
|
||||
}
|
||||
createChannelHistoryWindow({ historyMap: guildHistories }).clear({
|
||||
historyKey: messageChannelId,
|
||||
limit: historyLimit,
|
||||
});
|
||||
};
|
||||
const beginDeliveryCorrelation = () =>
|
||||
isRoomEvent
|
||||
? beginDiscordInboundEventDeliveryCorrelation(
|
||||
ctxPayload.SessionKey,
|
||||
{
|
||||
outboundTo: messageChannelId,
|
||||
outboundAccountId: route.accountId,
|
||||
markInboundEventDelivered: clearGroupHistory,
|
||||
},
|
||||
{ inboundEventKind: ctxPayload.InboundEventKind },
|
||||
)
|
||||
: () => {};
|
||||
const endDiscordInboundEventDeliveryCorrelation = beginDeliveryCorrelation();
|
||||
|
||||
const deliverChannelId = deliverTarget.startsWith("channel:")
|
||||
? deliverTarget.slice("channel:".length)
|
||||
@@ -651,12 +675,14 @@ export async function processDiscordMessage(
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
record: turn.record,
|
||||
history: {
|
||||
isGroup: isGuildMessage,
|
||||
historyKey: messageChannelId,
|
||||
historyMap: guildHistories,
|
||||
limit: historyLimit,
|
||||
},
|
||||
history: isRoomEvent
|
||||
? undefined
|
||||
: {
|
||||
isGroup: isGuildMessage,
|
||||
historyKey: messageChannelId,
|
||||
historyMap: guildHistories,
|
||||
limit: historyLimit,
|
||||
},
|
||||
onPreDispatchFailure: settleDispatchBeforeStart,
|
||||
runDispatch: async () =>
|
||||
await dispatchInboundMessage({
|
||||
@@ -668,6 +694,10 @@ export async function processDiscordMessage(
|
||||
abortSignal,
|
||||
skillFilter: channelConfig?.skills,
|
||||
sourceReplyDeliveryMode,
|
||||
queuedDeliveryCorrelations: isRoomEvent
|
||||
? [{ begin: beginDeliveryCorrelation }]
|
||||
: undefined,
|
||||
suppressTyping: isRoomEvent ? true : undefined,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
||||
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
|
||||
? true
|
||||
@@ -827,6 +857,7 @@ export async function processDiscordMessage(
|
||||
dispatchError = true;
|
||||
throw err;
|
||||
} finally {
|
||||
endDiscordInboundEventDeliveryCorrelation();
|
||||
try {
|
||||
await draftPreview.cleanup();
|
||||
} finally {
|
||||
|
||||
@@ -12,6 +12,7 @@ await installDiscordOutboundModuleSpies(hoisted);
|
||||
|
||||
let normalizeDiscordOutboundTarget: typeof import("./normalize.js").normalizeDiscordOutboundTarget;
|
||||
let discordOutbound: typeof import("./outbound-adapter.js").discordOutbound;
|
||||
let beginDiscordInboundEventDeliveryCorrelation: typeof import("./inbound-event-delivery.js").beginDiscordInboundEventDeliveryCorrelation;
|
||||
|
||||
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
|
||||
|
||||
@@ -39,6 +40,7 @@ function mockObjectArg(
|
||||
beforeAll(async () => {
|
||||
({ normalizeDiscordOutboundTarget } = await import("./normalize.js"));
|
||||
({ discordOutbound } = await import("./outbound-adapter.js"));
|
||||
({ beginDiscordInboundEventDeliveryCorrelation } = await import("./inbound-event-delivery.js"));
|
||||
});
|
||||
|
||||
describe("normalizeDiscordOutboundTarget", () => {
|
||||
@@ -459,6 +461,47 @@ describe("discordOutbound", () => {
|
||||
expect(touchThread).toHaveBeenCalledWith({ threadId: "thread-1" });
|
||||
});
|
||||
|
||||
it("notifies inbound event delivery after shared outbound delivery succeeds", async () => {
|
||||
const markDelivered = vi.fn();
|
||||
const end = beginDiscordInboundEventDeliveryCorrelation(
|
||||
"agent:main:discord:channel:c1",
|
||||
{
|
||||
outboundTo: "thread-1",
|
||||
outboundAccountId: "default",
|
||||
markInboundEventDelivered: markDelivered,
|
||||
},
|
||||
{ inboundEventKind: "room_event" },
|
||||
);
|
||||
|
||||
try {
|
||||
await discordOutbound.afterDeliverPayload?.({
|
||||
cfg: {},
|
||||
target: {
|
||||
channel: "discord",
|
||||
to: "channel:parent-1",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
},
|
||||
payload: {
|
||||
text: "delivered",
|
||||
channelData: {
|
||||
discord: {
|
||||
__openclawInboundEventDelivery: {
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
inboundEventKind: "room_event",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
results: [{ channel: "discord", messageId: "msg-1" }],
|
||||
});
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
|
||||
expect(markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends component payload media sequences with the component message first", async () => {
|
||||
hoisted.sendDiscordComponentMessageMock.mockResolvedValueOnce({
|
||||
messageId: "component-1",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { chunkDiscordTextWithMode } from "./chunk.js";
|
||||
import { withDiscordDeliveryRetry } from "./delivery-retry.js";
|
||||
import { notifyDiscordInboundEventOutboundPayloadSuccess } from "./inbound-event-delivery.js";
|
||||
import { isLikelyDiscordVideoMedia } from "./media-detection.js";
|
||||
import type { ThreadBindingRecord } from "./monitor/thread-bindings.js";
|
||||
import { normalizeDiscordOutboundTarget } from "./normalize.js";
|
||||
@@ -287,7 +288,12 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
afterDeliverPayload: async ({ target }) => {
|
||||
afterDeliverPayload: async ({ target, payload }) => {
|
||||
notifyDiscordInboundEventOutboundPayloadSuccess({
|
||||
payload,
|
||||
to: resolveDiscordOutboundTarget({ to: target.to, threadId: target.threadId }),
|
||||
accountId: target.accountId,
|
||||
});
|
||||
const threadId = normalizeOptionalStringifiedId(target.threadId);
|
||||
if (!threadId) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildFalImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { buildFalMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
import { createFalProvider } from "./provider-registration.js";
|
||||
import { buildFalVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
@@ -8,10 +9,11 @@ const PROVIDER_ID = "fal";
|
||||
export default definePluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "fal Provider",
|
||||
description: "Bundled fal image and video generation provider",
|
||||
description: "Bundled fal image, video, and music generation provider",
|
||||
register(api) {
|
||||
api.registerProvider(createFalProvider());
|
||||
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
|
||||
api.registerMusicGenerationProvider(buildFalMusicGenerationProvider());
|
||||
api.registerVideoGenerationProvider(buildFalVideoGenerationProvider());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildFalMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
|
||||
const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequestMock,
|
||||
resolveApiKeyForProviderMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({
|
||||
apiKey: "fal-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
})),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>();
|
||||
return {
|
||||
...original,
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
};
|
||||
});
|
||||
|
||||
function postRequest(): Record<string, unknown> {
|
||||
const request = postJsonRequestMock.mock.calls[0]?.[0];
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
||||
throw new Error("expected fal music request");
|
||||
}
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("fal music generation provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
postJsonRequestMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitMusicGenerationCapabilities(buildFalMusicGenerationProvider());
|
||||
});
|
||||
|
||||
it("submits MiniMax music through fal and downloads the generated track", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
audio: {
|
||||
url: "https://v3b.fal.media/files/b/kangaroo/out.mp3",
|
||||
content_type: "audio/mpeg",
|
||||
file_name: "out.mp3",
|
||||
},
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(Buffer.from("mp3-bytes"), {
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await buildFalMusicGenerationProvider().generateMusic({
|
||||
provider: "fal",
|
||||
model: "",
|
||||
prompt: "city pop chorus",
|
||||
cfg: {},
|
||||
lyrics: "[Verse]\nNeon rain",
|
||||
durationSeconds: 42,
|
||||
format: "mp3",
|
||||
});
|
||||
|
||||
expect(postRequest().url).toBe("https://fal.run/fal-ai/minimax-music/v2.6");
|
||||
expect(postRequest().body).toEqual({
|
||||
prompt: "city pop chorus",
|
||||
lyrics: "[Verse]\nNeon rain",
|
||||
duration: 42,
|
||||
audio_setting: {
|
||||
sample_rate: 44100,
|
||||
bitrate: 256000,
|
||||
format: "mp3",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://v3b.fal.media/files/b/kangaroo/out.mp3",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(result.model).toBe("fal-ai/minimax-music/v2.6");
|
||||
expect(result.tracks[0]?.mimeType).toBe("audio/mpeg");
|
||||
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("mp3-bytes"));
|
||||
expect(result.tracks[0]?.fileName).toBe("out.mp3");
|
||||
expect(result.metadata?.audioUrl).toBe("https://v3b.fal.media/files/b/kangaroo/out.mp3");
|
||||
});
|
||||
|
||||
it("rejects MiniMax lyrics requests that also ask for instrumental output", async () => {
|
||||
await expect(
|
||||
buildFalMusicGenerationProvider().generateMusic({
|
||||
provider: "fal",
|
||||
model: "fal-ai/minimax-music/v2.6",
|
||||
prompt: "city pop chorus",
|
||||
cfg: {},
|
||||
lyrics: "[Verse]\nNeon rain",
|
||||
instrumental: true,
|
||||
}),
|
||||
).rejects.toThrow("fal MiniMax music generation cannot use lyrics when instrumental=true.");
|
||||
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps ACE-Step duration and instrumental controls", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
audio: { url: "https://example.com/out.wav", content_type: "audio/wav" },
|
||||
seed: 42,
|
||||
tags: "lofi, chill",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(Buffer.from("wav-bytes"), {
|
||||
headers: { "content-type": "audio/wav" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await buildFalMusicGenerationProvider().generateMusic({
|
||||
provider: "fal",
|
||||
model: "fal-ai/ace-step/prompt-to-audio",
|
||||
prompt: "lofi beach loop",
|
||||
cfg: {},
|
||||
instrumental: true,
|
||||
durationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(postRequest().url).toBe("https://fal.run/fal-ai/ace-step/prompt-to-audio");
|
||||
expect(postRequest().body).toEqual({
|
||||
prompt: "lofi beach loop",
|
||||
instrumental: true,
|
||||
duration: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Stable Audio duration controls", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
audio: "https://example.com/stable.wav",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(Buffer.from("wav-bytes"), {
|
||||
headers: { "content-type": "audio/wav" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await buildFalMusicGenerationProvider().generateMusic({
|
||||
provider: "fal",
|
||||
model: "fal-ai/stable-audio-25/text-to-audio",
|
||||
prompt: "orchestral hit",
|
||||
cfg: {},
|
||||
durationSeconds: 12,
|
||||
});
|
||||
|
||||
expect(postRequest().url).toBe("https://fal.run/fal-ai/stable-audio-25/text-to-audio");
|
||||
expect(postRequest().body).toEqual({
|
||||
prompt: "orchestral hit",
|
||||
seconds_total: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
downloadGeneratedMusicAsset,
|
||||
extractGeneratedMusicFileCandidates,
|
||||
type MusicGenerationProvider,
|
||||
type MusicGenerationRequest,
|
||||
} from "openclaw/plugin-sdk/music-generation";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const DEFAULT_FAL_BASE_URL = "https://fal.run";
|
||||
const DEFAULT_FAL_MUSIC_MODEL = "fal-ai/minimax-music/v2.6";
|
||||
const FAL_ACE_STEP_MODEL = "fal-ai/ace-step/prompt-to-audio";
|
||||
const FAL_STABLE_AUDIO_MODEL = "fal-ai/stable-audio-25/text-to-audio";
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
|
||||
const FAL_MUSIC_MODELS = [
|
||||
DEFAULT_FAL_MUSIC_MODEL,
|
||||
FAL_ACE_STEP_MODEL,
|
||||
FAL_STABLE_AUDIO_MODEL,
|
||||
] as const;
|
||||
|
||||
function resolveFalMusicModel(model: string | undefined): string {
|
||||
return normalizeOptionalString(model) ?? DEFAULT_FAL_MUSIC_MODEL;
|
||||
}
|
||||
|
||||
function resolveFalMusicBaseUrl(req: MusicGenerationRequest): string | undefined {
|
||||
return normalizeOptionalString(req.cfg?.models?.providers?.fal?.baseUrl);
|
||||
}
|
||||
|
||||
function buildFalMinimaxBody(req: MusicGenerationRequest): Record<string, unknown> {
|
||||
const lyrics = normalizeOptionalString(req.lyrics);
|
||||
if (lyrics && req.instrumental === true) {
|
||||
throw new Error("fal MiniMax music generation cannot use lyrics when instrumental=true.");
|
||||
}
|
||||
return {
|
||||
prompt: req.prompt,
|
||||
...(lyrics ? { lyrics } : {}),
|
||||
...(req.instrumental === true ? { is_instrumental: true } : {}),
|
||||
...(!lyrics && req.instrumental !== true ? { lyrics_optimizer: true } : {}),
|
||||
...(typeof req.durationSeconds === "number" ? { duration: req.durationSeconds } : {}),
|
||||
audio_setting: {
|
||||
sample_rate: 44_100,
|
||||
bitrate: 256_000,
|
||||
format: req.format ?? "mp3",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildFalAceStepBody(req: MusicGenerationRequest): Record<string, unknown> {
|
||||
if (normalizeOptionalString(req.lyrics)) {
|
||||
throw new Error("fal ACE-Step music generation does not support explicit lyrics.");
|
||||
}
|
||||
return {
|
||||
prompt: req.prompt,
|
||||
...(req.instrumental === true ? { instrumental: true } : {}),
|
||||
...(typeof req.durationSeconds === "number" ? { duration: req.durationSeconds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFalStableAudioBody(req: MusicGenerationRequest): Record<string, unknown> {
|
||||
if (normalizeOptionalString(req.lyrics)) {
|
||||
throw new Error("fal Stable Audio music generation does not support explicit lyrics.");
|
||||
}
|
||||
if (req.instrumental === true) {
|
||||
throw new Error("fal Stable Audio music generation does not support instrumental mode.");
|
||||
}
|
||||
return {
|
||||
prompt: req.prompt,
|
||||
...(typeof req.durationSeconds === "number" ? { seconds_total: req.durationSeconds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFalMusicRequestBody(
|
||||
req: MusicGenerationRequest,
|
||||
model: string,
|
||||
): Record<string, unknown> {
|
||||
if (model === FAL_ACE_STEP_MODEL) {
|
||||
return buildFalAceStepBody(req);
|
||||
}
|
||||
if (model === FAL_STABLE_AUDIO_MODEL) {
|
||||
return buildFalStableAudioBody(req);
|
||||
}
|
||||
return buildFalMinimaxBody(req);
|
||||
}
|
||||
|
||||
function resolveFalMusicMetadata(payload: unknown): Record<string, unknown> | undefined {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const key of ["seed", "tags"]) {
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
export function buildFalMusicGenerationProvider(): MusicGenerationProvider {
|
||||
return {
|
||||
id: "fal",
|
||||
label: "fal",
|
||||
defaultModel: DEFAULT_FAL_MUSIC_MODEL,
|
||||
models: [...FAL_MUSIC_MODELS],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "fal",
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxTracks: 1,
|
||||
maxDurationSeconds: 240,
|
||||
supportsLyrics: true,
|
||||
supportsLyricsByModel: {
|
||||
[FAL_ACE_STEP_MODEL]: false,
|
||||
[FAL_STABLE_AUDIO_MODEL]: false,
|
||||
},
|
||||
supportsInstrumental: true,
|
||||
supportsInstrumentalByModel: {
|
||||
[FAL_STABLE_AUDIO_MODEL]: false,
|
||||
},
|
||||
supportsDuration: true,
|
||||
supportsFormat: true,
|
||||
supportedFormats: ["mp3", "wav"],
|
||||
supportedFormatsByModel: {
|
||||
[DEFAULT_FAL_MUSIC_MODEL]: ["mp3"],
|
||||
[FAL_ACE_STEP_MODEL]: ["wav"],
|
||||
[FAL_STABLE_AUDIO_MODEL]: ["wav"],
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
async generateMusic(req) {
|
||||
if ((req.inputImages?.length ?? 0) > 0) {
|
||||
throw new Error("fal music generation does not support image reference inputs.");
|
||||
}
|
||||
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "fal",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("fal API key missing");
|
||||
}
|
||||
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolveFalMusicBaseUrl(req),
|
||||
defaultBaseUrl: DEFAULT_FAL_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Key ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: "fal",
|
||||
capability: "audio",
|
||||
transport: "http",
|
||||
});
|
||||
const model = resolveFalMusicModel(req.model);
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/${model}`,
|
||||
headers,
|
||||
body: buildFalMusicRequestBody(req, model),
|
||||
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "fal music generation failed");
|
||||
const payload = await response.json();
|
||||
const [candidate] = extractGeneratedMusicFileCandidates(payload);
|
||||
if (!candidate) {
|
||||
throw new Error("fal music generation response missing audio output");
|
||||
}
|
||||
const track = await downloadGeneratedMusicAsset({
|
||||
candidate,
|
||||
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
fetchFn: fetch,
|
||||
provider: "fal",
|
||||
requestFailedMessage: "fal generated music download failed",
|
||||
});
|
||||
const lyrics =
|
||||
typeof payload === "object" && payload && !Array.isArray(payload)
|
||||
? normalizeOptionalString((payload as Record<string, unknown>).lyrics)
|
||||
: undefined;
|
||||
return {
|
||||
tracks: [track],
|
||||
model,
|
||||
...(lyrics ? { lyrics: [lyrics] } : {}),
|
||||
metadata: {
|
||||
...resolveFalMusicMetadata(payload),
|
||||
...(track.metadata?.url ? { audioUrl: track.metadata.url } : {}),
|
||||
instrumental: req.instrumental === true,
|
||||
...(req.format ? { requestedFormat: req.format } : {}),
|
||||
...(typeof req.durationSeconds === "number"
|
||||
? { requestedDurationSeconds: req.durationSeconds }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,8 +16,8 @@
|
||||
"choiceLabel": "fal API key",
|
||||
"groupId": "fal",
|
||||
"groupLabel": "fal",
|
||||
"groupHint": "Image and video generation",
|
||||
"onboardingScopes": ["image-generation"],
|
||||
"groupHint": "Image, video, and music generation",
|
||||
"onboardingScopes": ["image-generation", "music-generation"],
|
||||
"optionKey": "falApiKey",
|
||||
"cliFlag": "--fal-api-key",
|
||||
"cliOption": "--fal-api-key <key>",
|
||||
@@ -26,6 +26,7 @@
|
||||
],
|
||||
"contracts": {
|
||||
"imageGenerationProviders": ["fal"],
|
||||
"musicGenerationProviders": ["fal"],
|
||||
"videoGenerationProviders": ["fal"]
|
||||
},
|
||||
"configSchema": {
|
||||
|
||||
@@ -4,6 +4,7 @@ describePluginRegistrationContract({
|
||||
pluginId: "fal",
|
||||
providerIds: ["fal"],
|
||||
imageGenerationProviderIds: ["fal"],
|
||||
musicGenerationProviderIds: ["fal"],
|
||||
videoGenerationProviderIds: ["fal"],
|
||||
requireGenerateImage: true,
|
||||
requireGenerateVideo: true,
|
||||
|
||||
@@ -14,16 +14,16 @@ export function createFalProvider(): ProviderPlugin {
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "fal API key",
|
||||
hint: "Image and video generation API key",
|
||||
hint: "Image, video, and music generation API key",
|
||||
run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }),
|
||||
wizard: {
|
||||
choiceId: "fal-api-key",
|
||||
choiceLabel: "fal API key",
|
||||
choiceHint: "Image and video generation API key",
|
||||
choiceHint: "Image, video, and music generation API key",
|
||||
groupId: "fal",
|
||||
groupLabel: "fal",
|
||||
groupHint: "Image and video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
groupHint: "Image, video, and music generation",
|
||||
onboardingScopes: ["image-generation", "music-generation"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -15,7 +15,7 @@ export function createFalProvider(): ProviderPlugin {
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "fal API key",
|
||||
hint: "Image and video generation API key",
|
||||
hint: "Image, video, and music generation API key",
|
||||
optionKey: "falApiKey",
|
||||
flagName: "--fal-api-key",
|
||||
envVar: "FAL_KEY",
|
||||
@@ -26,11 +26,11 @@ export function createFalProvider(): ProviderPlugin {
|
||||
wizard: {
|
||||
choiceId: "fal-api-key",
|
||||
choiceLabel: "fal API key",
|
||||
choiceHint: "Image and video generation API key",
|
||||
choiceHint: "Image, video, and music generation API key",
|
||||
groupId: "fal",
|
||||
groupLabel: "fal",
|
||||
groupHint: "Image and video generation",
|
||||
onboardingScopes: ["image-generation"],
|
||||
groupHint: "Image, video, and music generation",
|
||||
onboardingScopes: ["image-generation", "music-generation"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { buildFalImageGenerationProvider } from "./image-generation-provider.js";
|
||||
export { buildFalMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
export { buildFalVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
@@ -221,7 +221,7 @@ async function createGitHubCopilotEmbeddingProvider(
|
||||
): Promise<{ provider: MemoryEmbeddingProvider; client: GitHubCopilotEmbeddingClient }> {
|
||||
const initialSession = await resolveGitHubCopilotEmbeddingSession(client);
|
||||
|
||||
const embed = async (input: string[]): Promise<number[][]> => {
|
||||
const embed = async (input: string[], signal?: AbortSignal): Promise<number[][]> => {
|
||||
if (input.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -232,6 +232,7 @@ async function createGitHubCopilotEmbeddingProvider(
|
||||
url,
|
||||
fetchImpl: client.fetchImpl,
|
||||
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
|
||||
signal,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: session.headers,
|
||||
@@ -259,11 +260,11 @@ async function createGitHubCopilotEmbeddingProvider(
|
||||
provider: {
|
||||
id: COPILOT_EMBEDDING_PROVIDER_ID,
|
||||
model: client.model,
|
||||
embedQuery: async (text) => {
|
||||
const [vector] = await embed([text]);
|
||||
embedQuery: async (text, options) => {
|
||||
const [vector] = await embed([text], options?.signal);
|
||||
return vector ?? [];
|
||||
},
|
||||
embedBatch: embed,
|
||||
embedBatch: async (texts, options) => await embed(texts, options?.signal),
|
||||
},
|
||||
client: {
|
||||
...client,
|
||||
|
||||
@@ -242,6 +242,7 @@ async function fetchGeminiEmbeddingPayload(params: {
|
||||
client: GeminiEmbeddingClient;
|
||||
endpoint: string;
|
||||
body: unknown;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return await executeWithApiKeyRotation({
|
||||
provider: "google",
|
||||
@@ -256,6 +257,7 @@ async function fetchGeminiEmbeddingPayload(params: {
|
||||
return await withRemoteHttpResponse({
|
||||
url: params.endpoint,
|
||||
ssrfPolicy: params.client.ssrfPolicy,
|
||||
signal: params.signal,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -316,7 +318,10 @@ export async function createGeminiEmbeddingProvider(
|
||||
const isV2 = isGeminiEmbedding2Model(client.model);
|
||||
const outputDimensionality = client.outputDimensionality;
|
||||
|
||||
const embedQuery = async (text: string): Promise<number[]> => {
|
||||
const embedQuery = async (
|
||||
text: string,
|
||||
callOptions?: { signal?: AbortSignal },
|
||||
): Promise<number[]> => {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
@@ -328,11 +333,15 @@ export async function createGeminiEmbeddingProvider(
|
||||
taskType: options.taskType ?? "RETRIEVAL_QUERY",
|
||||
outputDimensionality: isV2 ? outputDimensionality : undefined,
|
||||
}),
|
||||
signal: callOptions?.signal,
|
||||
});
|
||||
return sanitizeAndNormalizeEmbedding(readGeminiSingleEmbedding(payload));
|
||||
};
|
||||
|
||||
const embedBatchInputs = async (inputs: EmbeddingInput[]): Promise<number[][]> => {
|
||||
const embedBatchInputs = async (
|
||||
inputs: EmbeddingInput[],
|
||||
callOptions?: { signal?: AbortSignal },
|
||||
): Promise<number[][]> => {
|
||||
if (inputs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -349,16 +358,21 @@ export async function createGeminiEmbeddingProvider(
|
||||
}),
|
||||
),
|
||||
},
|
||||
signal: callOptions?.signal,
|
||||
});
|
||||
const embeddings = readGeminiBatchEmbeddings(payload, inputs.length);
|
||||
return embeddings.map((values) => sanitizeAndNormalizeEmbedding(values));
|
||||
};
|
||||
|
||||
const embedBatch = async (texts: string[]): Promise<number[][]> => {
|
||||
const embedBatch = async (
|
||||
texts: string[],
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<number[][]> => {
|
||||
return await embedBatchInputs(
|
||||
texts.map((text) => ({
|
||||
text,
|
||||
})),
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -111,6 +111,34 @@ export function resolveMemoryIndexConcurrency(params: {
|
||||
return params.providerId === "ollama" ? 1 : EMBEDDING_INDEX_CONCURRENCY;
|
||||
}
|
||||
|
||||
export async function runEmbeddingOperationWithTimeout<T>(params: {
|
||||
timeoutMs: number;
|
||||
message: string;
|
||||
run: (signal: AbortSignal) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
if (!Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0) {
|
||||
return await params.run(controller.signal);
|
||||
}
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error(params.message);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, params.timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
const operation = params.run(controller.signal);
|
||||
return (await Promise.race([operation, timeoutPromise])) as T;
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
protected abstract batchFailureCount: number;
|
||||
protected abstract batchFailureLastError?: string;
|
||||
@@ -304,11 +332,11 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
items: texts.length,
|
||||
timeoutMs,
|
||||
});
|
||||
return await this.withTimeout(
|
||||
provider.embedBatch(texts),
|
||||
return await runEmbeddingOperationWithTimeout({
|
||||
timeoutMs,
|
||||
`memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
);
|
||||
message: `memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
run: async (signal) => await provider.embedBatch(texts, { signal }),
|
||||
});
|
||||
},
|
||||
isRetryable: isRetryableMemoryEmbeddingError,
|
||||
waitForRetry: async (delayMs) => {
|
||||
@@ -336,11 +364,11 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
items: inputs.length,
|
||||
timeoutMs,
|
||||
});
|
||||
return await this.withTimeout(
|
||||
embedBatchInputs(inputs),
|
||||
return await runEmbeddingOperationWithTimeout({
|
||||
timeoutMs,
|
||||
`memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
);
|
||||
message: `memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
run: async (signal) => await embedBatchInputs(inputs, { signal }),
|
||||
});
|
||||
},
|
||||
isRetryable: isRetryableMemoryEmbeddingError,
|
||||
waitForRetry: async (delayMs) => {
|
||||
@@ -371,16 +399,17 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
}
|
||||
|
||||
protected async embedQueryWithTimeout(text: string): Promise<number[]> {
|
||||
if (!this.provider) {
|
||||
const provider = this.provider;
|
||||
if (!provider) {
|
||||
throw new Error("Cannot embed query in FTS-only mode (no embedding provider)");
|
||||
}
|
||||
const timeoutMs = this.resolveEmbeddingTimeout("query");
|
||||
log.debug("memory embeddings: query start", { provider: this.provider.id, timeoutMs });
|
||||
return await this.withTimeout(
|
||||
this.provider.embedQuery(text),
|
||||
log.debug("memory embeddings: query start", { provider: provider.id, timeoutMs });
|
||||
return await runEmbeddingOperationWithTimeout({
|
||||
timeoutMs,
|
||||
`memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
);
|
||||
message: `memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
run: async (signal) => await provider.embedQuery(text, { signal }),
|
||||
});
|
||||
}
|
||||
|
||||
protected async withTimeout<T>(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveEmbeddingTimeoutMs,
|
||||
resolveMemoryIndexConcurrency,
|
||||
runEmbeddingOperationWithTimeout,
|
||||
} from "./manager-embedding-ops.js";
|
||||
|
||||
describe("memory embedding timeout resolution", () => {
|
||||
@@ -37,6 +38,42 @@ describe("memory embedding timeout resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory embedding timeout abort", () => {
|
||||
it("aborts the provider operation when the timeout fires", async () => {
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
|
||||
await expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings query timed out after 0s",
|
||||
run: async (signal) => {
|
||||
signalSeen = signal;
|
||||
return await new Promise<number[]>((resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings query timed out after 0s");
|
||||
|
||||
expect(signalSeen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
|
||||
await expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings batch timed out after 0s",
|
||||
run: async (signal) =>
|
||||
await new Promise<number[]>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings batch timed out after 0s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory index concurrency resolution", () => {
|
||||
it("uses the default index concurrency when batch mode is disabled and unconfigured", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMemorySessionSyncPlan } from "./manager-session-sync-state.js";
|
||||
import {
|
||||
resolveMemorySessionStartupDirtyFiles,
|
||||
resolveMemorySessionSyncPlan,
|
||||
} from "./manager-session-sync-state.js";
|
||||
|
||||
describe("memory session sync state", () => {
|
||||
it("tracks active paths and bulk hashes for full scans", () => {
|
||||
@@ -61,4 +64,46 @@ describe("memory session sync state", () => {
|
||||
expect(plan.indexAll).toBe(false);
|
||||
expect(plan.activePaths).toEqual(new Set(["sessions/incremental.jsonl"]));
|
||||
});
|
||||
|
||||
it("marks missing and changed startup session files dirty", () => {
|
||||
const dirtyFiles = resolveMemorySessionStartupDirtyFiles({
|
||||
files: [
|
||||
{
|
||||
absPath: "/tmp/sessions/unchanged.jsonl",
|
||||
path: "sessions/unchanged.jsonl",
|
||||
mtimeMs: 100,
|
||||
size: 10,
|
||||
},
|
||||
{
|
||||
absPath: "/tmp/sessions/newer.jsonl",
|
||||
path: "sessions/newer.jsonl",
|
||||
mtimeMs: 250,
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
absPath: "/tmp/sessions/resized.jsonl",
|
||||
path: "sessions/resized.jsonl",
|
||||
mtimeMs: 300,
|
||||
size: 31,
|
||||
},
|
||||
{
|
||||
absPath: "/tmp/sessions/missing.jsonl",
|
||||
path: "sessions/missing.jsonl",
|
||||
mtimeMs: 400,
|
||||
size: 40,
|
||||
},
|
||||
],
|
||||
existingRows: [
|
||||
{ path: "sessions/unchanged.jsonl", hash: "hash-unchanged", mtime: 100, size: 10 },
|
||||
{ path: "sessions/newer.jsonl", hash: "hash-newer", mtime: 200, size: 20 },
|
||||
{ path: "sessions/resized.jsonl", hash: "hash-resized", mtime: 300, size: 30 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(dirtyFiles).toEqual([
|
||||
"/tmp/sessions/newer.jsonl",
|
||||
"/tmp/sessions/resized.jsonl",
|
||||
"/tmp/sessions/missing.jsonl",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import { type MemorySourceFileStateRow } from "./manager-source-state.js";
|
||||
|
||||
export type MemorySessionStartupFileState = {
|
||||
absPath: string;
|
||||
path: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function resolveMemorySessionStartupDirtyFiles(params: {
|
||||
files: MemorySessionStartupFileState[];
|
||||
existingRows?: MemorySourceFileStateRow[] | null;
|
||||
}): string[] {
|
||||
const indexedRows = new Map((params.existingRows ?? []).map((row) => [row.path, row]));
|
||||
const dirtyFiles: string[] = [];
|
||||
for (const file of params.files) {
|
||||
const existing = indexedRows.get(file.path);
|
||||
if (!existing) {
|
||||
dirtyFiles.push(file.absPath);
|
||||
continue;
|
||||
}
|
||||
const indexedMtimeMs = Number(existing.mtime);
|
||||
const indexedSize = Number(existing.size);
|
||||
if (!Number.isFinite(indexedMtimeMs) || !Number.isFinite(indexedSize)) {
|
||||
dirtyFiles.push(file.absPath);
|
||||
continue;
|
||||
}
|
||||
if (file.size !== indexedSize || file.mtimeMs > indexedMtimeMs) {
|
||||
dirtyFiles.push(file.absPath);
|
||||
}
|
||||
}
|
||||
return dirtyFiles;
|
||||
}
|
||||
|
||||
export function resolveMemorySessionSyncPlan(params: {
|
||||
needsFullReindex: boolean;
|
||||
files: string[];
|
||||
|
||||
@@ -15,8 +15,8 @@ describe("memory source state", () => {
|
||||
all: (...args) => {
|
||||
calls.push({ sql, args });
|
||||
return [
|
||||
{ path: "memory/one.md", hash: "hash-1" },
|
||||
{ path: "memory/two.md", hash: "hash-2" },
|
||||
{ path: "memory/one.md", hash: "hash-1", mtime: 100, size: 10 },
|
||||
{ path: "memory/two.md", hash: "hash-2", mtime: 200, size: 20 },
|
||||
];
|
||||
},
|
||||
get: () => undefined,
|
||||
@@ -27,8 +27,8 @@ describe("memory source state", () => {
|
||||
|
||||
expect(calls).toEqual([{ sql: MEMORY_SOURCE_FILE_STATE_SQL, args: ["memory"] }]);
|
||||
expect(state.rows).toEqual([
|
||||
{ path: "memory/one.md", hash: "hash-1" },
|
||||
{ path: "memory/two.md", hash: "hash-2" },
|
||||
{ path: "memory/one.md", hash: "hash-1", mtime: 100, size: 10 },
|
||||
{ path: "memory/two.md", hash: "hash-2", mtime: 200, size: 20 },
|
||||
]);
|
||||
expect(state.hashes).toEqual(
|
||||
new Map([
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-s
|
||||
export type MemorySourceFileStateRow = {
|
||||
path: string;
|
||||
hash: string;
|
||||
mtime?: number;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type MemorySourceStateDb = {
|
||||
@@ -13,7 +15,7 @@ type MemorySourceStateDb = {
|
||||
};
|
||||
};
|
||||
|
||||
export const MEMORY_SOURCE_FILE_STATE_SQL = `SELECT path, hash FROM files WHERE source = ?`;
|
||||
export const MEMORY_SOURCE_FILE_STATE_SQL = `SELECT path, hash, mtime, size FROM files WHERE source = ?`;
|
||||
export const MEMORY_SOURCE_FILE_HASH_SQL = `SELECT hash FROM files WHERE path = ? AND source = ?`;
|
||||
|
||||
export function loadMemorySourceFileState(params: {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
type OpenClawConfig,
|
||||
type ResolvedMemorySearchConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import type {
|
||||
MemorySource,
|
||||
MemorySyncProgressUpdate,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryManagerSyncOps } from "./manager-sync-ops.js";
|
||||
|
||||
type MemoryIndexEntry = {
|
||||
path: string;
|
||||
absPath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
hash: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type SyncParams = {
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
sessionFiles?: string[];
|
||||
progress?: (update: MemorySyncProgressUpdate) => void;
|
||||
};
|
||||
|
||||
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
|
||||
|
||||
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
|
||||
protected readonly cfg = {} as OpenClawConfig;
|
||||
protected readonly agentId = "main";
|
||||
protected readonly workspaceDir = "/tmp/openclaw-test-workspace";
|
||||
protected readonly settings = {
|
||||
sync: {
|
||||
sessions: {
|
||||
deltaBytes: 100_000,
|
||||
deltaMessages: 50,
|
||||
postCompactionForce: true,
|
||||
},
|
||||
},
|
||||
} as ResolvedMemorySearchConfig;
|
||||
protected readonly batch = {
|
||||
enabled: false,
|
||||
wait: false,
|
||||
concurrency: 1,
|
||||
pollIntervalMs: 0,
|
||||
timeoutMs: 0,
|
||||
};
|
||||
protected readonly vector = { enabled: false, available: false };
|
||||
protected readonly cache = { enabled: false };
|
||||
protected db: DatabaseSync;
|
||||
|
||||
readonly syncCalls: SyncParams[] = [];
|
||||
|
||||
constructor(sourceRows: SourceStateRow[]) {
|
||||
super();
|
||||
this.sources.add("sessions");
|
||||
this.db = {
|
||||
prepare: () => ({
|
||||
all: () => sourceRows,
|
||||
get: () => undefined,
|
||||
run: () => undefined,
|
||||
}),
|
||||
} as unknown as DatabaseSync;
|
||||
}
|
||||
|
||||
async catchUp(): Promise<string[]> {
|
||||
return await this.runSessionStartupCatchup();
|
||||
}
|
||||
|
||||
async markStartupDirtyFiles(): Promise<string[]> {
|
||||
return await this.markSessionStartupCatchupDirtyFiles();
|
||||
}
|
||||
|
||||
getDirtySessionFiles(): string[] {
|
||||
return Array.from(this.sessionsDirtyFiles);
|
||||
}
|
||||
|
||||
isSessionsDirty(): boolean {
|
||||
return this.sessionsDirty;
|
||||
}
|
||||
|
||||
protected computeProviderKey(): string {
|
||||
return "test";
|
||||
}
|
||||
|
||||
protected async sync(params?: SyncParams): Promise<void> {
|
||||
this.syncCalls.push(params ?? {});
|
||||
}
|
||||
|
||||
protected async withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
_timeoutMs: number,
|
||||
_message: string,
|
||||
): Promise<T> {
|
||||
return await promise;
|
||||
}
|
||||
|
||||
protected getIndexConcurrency(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected pruneEmbeddingCacheIfNeeded(): void {}
|
||||
|
||||
protected async indexFile(
|
||||
_entry: MemoryIndexEntry,
|
||||
_options: { source: MemorySource; content?: string },
|
||||
): Promise<void> {}
|
||||
}
|
||||
|
||||
describe("session startup catch-up", () => {
|
||||
let stateDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-startup-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeSessionFile(
|
||||
name: string,
|
||||
): Promise<{ filePath: string; size: number; mtimeMs: number }> {
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const filePath = path.join(sessionsDir, name);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: "startup catchup" } }) +
|
||||
"\n",
|
||||
"utf-8",
|
||||
);
|
||||
const stat = await fs.stat(filePath);
|
||||
return { filePath, size: stat.size, mtimeMs: stat.mtimeMs };
|
||||
}
|
||||
|
||||
it("marks stale indexed session files dirty and schedules catch-up sync", async () => {
|
||||
const session = await writeSessionFile("thread.jsonl");
|
||||
const harness = new SessionStartupCatchupHarness([
|
||||
{
|
||||
path: "sessions/main/thread.jsonl",
|
||||
hash: "old-hash",
|
||||
mtime: session.mtimeMs - 1000,
|
||||
size: session.size,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(harness.catchUp()).resolves.toEqual([session.filePath]);
|
||||
expect(harness.getDirtySessionFiles()).toEqual([session.filePath]);
|
||||
expect(harness.isSessionsDirty()).toBe(true);
|
||||
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
|
||||
});
|
||||
|
||||
it("can mark startup catch-up files without scheduling background sync", async () => {
|
||||
const session = await writeSessionFile("thread.jsonl");
|
||||
const harness = new SessionStartupCatchupHarness([
|
||||
{
|
||||
path: "sessions/main/thread.jsonl",
|
||||
hash: "old-hash",
|
||||
mtime: session.mtimeMs - 1000,
|
||||
size: session.size,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(harness.markStartupDirtyFiles()).resolves.toEqual([session.filePath]);
|
||||
expect(harness.getDirtySessionFiles()).toEqual([session.filePath]);
|
||||
expect(harness.isSessionsDirty()).toBe(true);
|
||||
expect(harness.syncCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves unchanged indexed session files clean", async () => {
|
||||
const session = await writeSessionFile("thread.jsonl");
|
||||
const harness = new SessionStartupCatchupHarness([
|
||||
{
|
||||
path: "sessions/main/thread.jsonl",
|
||||
hash: "current-hash",
|
||||
mtime: session.mtimeMs,
|
||||
size: session.size,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(harness.catchUp()).resolves.toEqual([]);
|
||||
expect(harness.getDirtySessionFiles()).toEqual([]);
|
||||
expect(harness.isSessionsDirty()).toBe(false);
|
||||
expect(harness.syncCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,11 @@ import {
|
||||
type MemoryIndexMeta,
|
||||
} from "./manager-reindex-state.js";
|
||||
import { shouldSyncSessionsForReindex } from "./manager-session-reindex.js";
|
||||
import { resolveMemorySessionSyncPlan } from "./manager-session-sync-state.js";
|
||||
import {
|
||||
resolveMemorySessionStartupDirtyFiles,
|
||||
resolveMemorySessionSyncPlan,
|
||||
type MemorySessionStartupFileState,
|
||||
} from "./manager-session-sync-state.js";
|
||||
import {
|
||||
loadMemorySourceFileState,
|
||||
resolveMemorySourceExistingHash,
|
||||
@@ -491,6 +495,73 @@ export abstract class MemoryManagerSyncOps {
|
||||
});
|
||||
}
|
||||
|
||||
protected ensureSessionStartupCatchup(): void {
|
||||
if (!this.sources.has("sessions")) {
|
||||
return;
|
||||
}
|
||||
void this.runSessionStartupCatchup().catch((err) => {
|
||||
log.warn("memory session startup catch-up failed: " + String(err));
|
||||
});
|
||||
}
|
||||
|
||||
protected async markSessionStartupCatchupDirtyFiles(): Promise<string[]> {
|
||||
if (!this.sources.has("sessions") || this.closed) {
|
||||
return [];
|
||||
}
|
||||
const files = await listSessionFilesForAgent(this.agentId);
|
||||
if (files.length === 0 || this.closed) {
|
||||
return [];
|
||||
}
|
||||
const existingRows = loadMemorySourceFileState({
|
||||
db: this.db,
|
||||
source: "sessions",
|
||||
}).rows;
|
||||
const fileStates = (
|
||||
await runWithConcurrency(
|
||||
files.map((file) => async (): Promise<MemorySessionStartupFileState | null> => {
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
absPath: file,
|
||||
path: sessionPathForFile(file),
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
};
|
||||
} catch (err) {
|
||||
if (isFileMissingError(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
this.getIndexConcurrency(),
|
||||
)
|
||||
).filter((file): file is MemorySessionStartupFileState => file !== null);
|
||||
const dirtyFiles = resolveMemorySessionStartupDirtyFiles({ files: fileStates, existingRows });
|
||||
if (dirtyFiles.length === 0 || this.closed) {
|
||||
return dirtyFiles;
|
||||
}
|
||||
for (const file of dirtyFiles) {
|
||||
this.sessionsDirtyFiles.add(file);
|
||||
}
|
||||
this.sessionsDirty = true;
|
||||
return dirtyFiles;
|
||||
}
|
||||
|
||||
protected async runSessionStartupCatchup(): Promise<string[]> {
|
||||
const dirtyFiles = await this.markSessionStartupCatchupDirtyFiles();
|
||||
if (dirtyFiles.length === 0 || this.closed) {
|
||||
return dirtyFiles;
|
||||
}
|
||||
void this.sync({ reason: "session-startup-catchup" }).catch((err) => {
|
||||
log.warn("memory sync failed (session-startup-catchup): " + String(err));
|
||||
});
|
||||
return dirtyFiles;
|
||||
}
|
||||
|
||||
private scheduleSessionDirty(sessionFile: string) {
|
||||
this.sessionPendingFiles.add(sessionFile);
|
||||
if (this.sessionWatchTimer) {
|
||||
@@ -1045,6 +1116,9 @@ export abstract class MemoryManagerSyncOps {
|
||||
});
|
||||
const targetSessionFiles = this.normalizeTargetSessionFiles(params?.sessionFiles);
|
||||
const hasTargetSessionFiles = targetSessionFiles !== null;
|
||||
if (params?.reason === "cli" && !params.force && !hasTargetSessionFiles) {
|
||||
await this.markSessionStartupCatchupDirtyFiles();
|
||||
}
|
||||
const targetedSessionSync = await runMemoryTargetedSessionSync({
|
||||
hasSessionSource: this.sources.has("sessions"),
|
||||
targetSessionFiles,
|
||||
|
||||
@@ -246,6 +246,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
hasIndexedMeta: Boolean(meta),
|
||||
});
|
||||
this.batch = this.resolveBatchConfig();
|
||||
if (!transient) {
|
||||
this.ensureSessionStartupCatchup();
|
||||
}
|
||||
}
|
||||
|
||||
private applyProviderResult(providerResult: EmbeddingProviderResult): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { setTimeout as scheduleNativeTimeout } from "node:timers";
|
||||
import { withMockedWindowsPlatform } from "openclaw/plugin-sdk/test-node-mocks";
|
||||
import type { Mock } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -1917,47 +1918,47 @@ describe("QmdMemoryManager", () => {
|
||||
});
|
||||
|
||||
it("resolves bare qmd command to a Windows-compatible spawn invocation", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const nodeModulesDir = path.join(tmpRoot, "node_modules");
|
||||
const shimDir = path.join(nodeModulesDir, ".bin");
|
||||
const packageDir = path.join(nodeModulesDir, "qmd");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "qmd.cmd"), "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
process.env.PATH = `${shimDir};${previousPath ?? ""}`;
|
||||
|
||||
const { manager } = await createManager({ mode: "status" });
|
||||
await manager.sync({ reason: "manual" });
|
||||
|
||||
const qmdCalls = spawnMock.mock.calls.filter((call: unknown[]) => {
|
||||
const args = call[1] as string[] | undefined;
|
||||
return (
|
||||
Array.isArray(args) &&
|
||||
args.some((token) => token === "update" || token === "search" || token === "query")
|
||||
await withMockedWindowsPlatform(async () => {
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const nodeModulesDir = path.join(tmpRoot, "node_modules");
|
||||
const shimDir = path.join(nodeModulesDir, ".bin");
|
||||
const packageDir = path.join(nodeModulesDir, "qmd");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "qmd.cmd"), "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
expect(qmdCalls.length).toBeGreaterThan(0);
|
||||
for (const call of qmdCalls) {
|
||||
const command = String(call[0]);
|
||||
const options = call[2] as { shell?: boolean } | undefined;
|
||||
expect(command).not.toMatch(/(^|[\\/])qmd\.cmd$/i);
|
||||
expect(options?.shell).not.toBe(true);
|
||||
}
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
process.env.PATH = `${shimDir};${previousPath ?? ""}`;
|
||||
|
||||
await manager.close();
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
const { manager } = await createManager({ mode: "status" });
|
||||
await manager.sync({ reason: "manual" });
|
||||
|
||||
const qmdCalls = spawnMock.mock.calls.filter((call: unknown[]) => {
|
||||
const args = call[1] as string[] | undefined;
|
||||
return (
|
||||
Array.isArray(args) &&
|
||||
args.some((token) => token === "update" || token === "search" || token === "query")
|
||||
);
|
||||
});
|
||||
expect(qmdCalls.length).toBeGreaterThan(0);
|
||||
for (const call of qmdCalls) {
|
||||
const command = String(call[0]);
|
||||
const options = call[2] as { shell?: boolean } | undefined;
|
||||
expect(command).not.toMatch(/(^|[\\/])qmd\.cmd$/i);
|
||||
expect(options?.shell).not.toBe(true);
|
||||
}
|
||||
|
||||
await manager.close();
|
||||
} finally {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mixed Han-script BM25 queries intact before qmd search", async () => {
|
||||
@@ -3210,125 +3211,125 @@ describe("QmdMemoryManager", () => {
|
||||
});
|
||||
|
||||
it("resolves mcporter to a direct Windows entrypoint without enabling shell mode", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const nodeModulesDir = path.join(tmpRoot, "node_modules");
|
||||
const shimDir = path.join(nodeModulesDir, ".bin");
|
||||
const packageDir = path.join(nodeModulesDir, "mcporter");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "mcporter", version: "0.0.0", bin: { mcporter: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
process.env.PATH = `${shimDir};${previousPath ?? ""}`;
|
||||
await withMockedWindowsPlatform(async () => {
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const nodeModulesDir = path.join(tmpRoot, "node_modules");
|
||||
const shimDir = path.join(nodeModulesDir, ".bin");
|
||||
const packageDir = path.join(nodeModulesDir, "mcporter");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "mcporter", version: "0.0.0", bin: { mcporter: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
process.env.PATH = `${shimDir};${previousPath ?? ""}`;
|
||||
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (args[0] === "call") {
|
||||
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (args[0] === "call") {
|
||||
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
|
||||
return child;
|
||||
}
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
});
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
const { manager } = await createManager();
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
|
||||
const mcporterCall = spawnMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[] | undefined)?.includes("call"),
|
||||
);
|
||||
const searchCall = requireValue(mcporterCall, "mcporter search call missing");
|
||||
const callCommand = searchCall[0];
|
||||
expect(typeof callCommand).toBe("string");
|
||||
const options = searchCall[2] as { shell?: boolean } | undefined;
|
||||
expect(callCommand).not.toBe("mcporter.cmd");
|
||||
expect(options?.shell).not.toBe(true);
|
||||
const mcporterCall = spawnMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[] | undefined)?.includes("call"),
|
||||
);
|
||||
const searchCall = requireValue(mcporterCall, "mcporter search call missing");
|
||||
const callCommand = searchCall[0];
|
||||
expect(typeof callCommand).toBe("string");
|
||||
const options = searchCall[2] as { shell?: boolean } | undefined;
|
||||
expect(callCommand).not.toBe("mcporter.cmd");
|
||||
expect(options?.shell).not.toBe(true);
|
||||
|
||||
await manager.close();
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
await manager.close();
|
||||
} finally {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on Windows EINVAL cmd-shim failures instead of retrying through the shell", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const shimDir = await fs.mkdtemp(path.join(tmpRoot, "mcporter-shim-"));
|
||||
await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\n");
|
||||
process.env.PATH = `${shimDir};${previousPath ?? ""}`;
|
||||
await withMockedWindowsPlatform(async () => {
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
const shimDir = await fs.mkdtemp(path.join(tmpRoot, "mcporter-shim-"));
|
||||
await fs.writeFile(path.join(shimDir, "mcporter.cmd"), "@echo off\n");
|
||||
process.env.PATH = `${shimDir};${previousPath ?? ""}`;
|
||||
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
} as OpenClawConfig;
|
||||
|
||||
let firstCallCommand: string | null = null;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (args[0] === "call" && firstCallCommand === null) {
|
||||
firstCallCommand = cmd;
|
||||
}
|
||||
if (args[0] === "call" && typeof cmd === "string" && cmd.toLowerCase().endsWith(".cmd")) {
|
||||
let firstCallCommand: string | null = null;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (args[0] === "call" && firstCallCommand === null) {
|
||||
firstCallCommand = cmd;
|
||||
}
|
||||
if (args[0] === "call" && typeof cmd === "string" && cmd.toLowerCase().endsWith(".cmd")) {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
queueMicrotask(() => {
|
||||
const err = Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" });
|
||||
child.emit("error", err);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
const child = createMockChild({ autoClose: false });
|
||||
queueMicrotask(() => {
|
||||
const err = Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" });
|
||||
child.emit("error", err);
|
||||
});
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
});
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
await expect(
|
||||
manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }),
|
||||
).rejects.toThrow(/without shell execution|EINVAL/);
|
||||
const attemptedCmdShim = (firstCallCommand ?? "").toLowerCase().endsWith(".cmd");
|
||||
if (attemptedCmdShim) {
|
||||
expect(
|
||||
spawnMock.mock.calls.some(
|
||||
(call: unknown[]) =>
|
||||
call[0] === "mcporter" &&
|
||||
(call[2] as { shell?: boolean } | undefined)?.shell === true,
|
||||
),
|
||||
).toBe(false);
|
||||
const { manager } = await createManager();
|
||||
await expect(
|
||||
manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }),
|
||||
).rejects.toThrow(/without shell execution|EINVAL/);
|
||||
const attemptedCmdShim = (firstCallCommand ?? "").toLowerCase().endsWith(".cmd");
|
||||
if (attemptedCmdShim) {
|
||||
expect(
|
||||
spawnMock.mock.calls.some(
|
||||
(call: unknown[]) =>
|
||||
call[0] === "mcporter" &&
|
||||
(call[2] as { shell?: boolean } | undefined)?.shell === true,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
await manager.close();
|
||||
} finally {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
await manager.close();
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("passes manager-scoped XDG env to mcporter commands", async () => {
|
||||
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
resolveLiveMusicAuthStore,
|
||||
} from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import falPlugin from "./fal/index.js";
|
||||
import googlePlugin from "./google/index.js";
|
||||
import minimaxPlugin from "./minimax/index.js";
|
||||
import openrouterPlugin from "./openrouter/index.js";
|
||||
import { maybeLoadShellEnvForGenerationProviders } from "./test-support/generation-live-test-helpers.js";
|
||||
|
||||
const LIVE = isLiveTestEnabled();
|
||||
@@ -49,6 +51,12 @@ type LiveProviderCase = {
|
||||
};
|
||||
|
||||
const CASES: LiveProviderCase[] = [
|
||||
{
|
||||
plugin: falPlugin,
|
||||
pluginId: "fal",
|
||||
pluginName: "fal Provider",
|
||||
providerId: "fal",
|
||||
},
|
||||
{
|
||||
plugin: googlePlugin,
|
||||
pluginId: "google",
|
||||
@@ -61,6 +69,12 @@ const CASES: LiveProviderCase[] = [
|
||||
pluginName: "MiniMax Provider",
|
||||
providerId: "minimax",
|
||||
},
|
||||
{
|
||||
plugin: openrouterPlugin,
|
||||
pluginId: "openrouter",
|
||||
pluginName: "OpenRouter Provider",
|
||||
providerId: "openrouter",
|
||||
},
|
||||
]
|
||||
.filter((entry) => (providerFilter ? providerFilter.has(entry.providerId) : true))
|
||||
.toSorted((left, right) => left.providerId.localeCompare(right.providerId));
|
||||
@@ -130,7 +144,7 @@ function resolveLiveLyrics(providerId: string): string | undefined {
|
||||
function resolveLiveMusicSkipReason(providerId: string, error: unknown): string | null {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
providerId === "google" &&
|
||||
(providerId === "google" || providerId === "openrouter") &&
|
||||
message.toLowerCase().includes("music generation response missing audio data")
|
||||
) {
|
||||
return "transient no-audio response";
|
||||
|
||||
@@ -25,8 +25,8 @@ export type OllamaEmbeddingProvider = {
|
||||
id: string;
|
||||
model: string;
|
||||
maxInputTokens?: number;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise<number[]>;
|
||||
embedBatch: (texts: string[], options?: { signal?: AbortSignal }) => Promise<number[][]>;
|
||||
};
|
||||
|
||||
type OllamaEmbeddingOptions = {
|
||||
@@ -90,12 +90,14 @@ function sanitizeAndNormalizeEmbedding(vec: unknown[]): number[] {
|
||||
async function withRemoteHttpResponse<T>(params: {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
signal?: AbortSignal;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
onResponse: (response: Response) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: params.url,
|
||||
init: params.init,
|
||||
signal: params.signal,
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "memory-remote",
|
||||
});
|
||||
@@ -322,10 +324,11 @@ export async function createOllamaEmbeddingProvider(
|
||||
const client = resolveOllamaEmbeddingClient(options);
|
||||
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
|
||||
|
||||
const embedMany = async (input: string | string[]): Promise<number[][]> => {
|
||||
const embedMany = async (input: string | string[], signal?: AbortSignal): Promise<number[][]> => {
|
||||
const json = await withRemoteHttpResponse({
|
||||
url: embedUrl,
|
||||
ssrfPolicy: client.ssrfPolicy,
|
||||
signal,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: client.headers,
|
||||
@@ -355,22 +358,23 @@ export async function createOllamaEmbeddingProvider(
|
||||
});
|
||||
};
|
||||
|
||||
const embedOne = async (text: string): Promise<number[]> => {
|
||||
const [embedding] = await embedMany(text);
|
||||
const embedOne = async (text: string, signal?: AbortSignal): Promise<number[]> => {
|
||||
const [embedding] = await embedMany(text, signal);
|
||||
if (!embedding) {
|
||||
throw new Error("Ollama embed response returned no embedding");
|
||||
}
|
||||
return embedding;
|
||||
};
|
||||
|
||||
const embedQuery = async (text: string): Promise<number[]> =>
|
||||
await embedOne(applyQueryInstructionTemplate(client.model, text));
|
||||
const embedQuery = async (text: string, options?: { signal?: AbortSignal }): Promise<number[]> =>
|
||||
await embedOne(applyQueryInstructionTemplate(client.model, text), options?.signal);
|
||||
|
||||
const provider: OllamaEmbeddingProvider = {
|
||||
id: "ollama",
|
||||
model: client.model,
|
||||
embedQuery,
|
||||
embedBatch: async (texts) => (texts.length === 0 ? [] : await embedMany(texts)),
|
||||
embedBatch: async (texts, options) =>
|
||||
texts.length === 0 ? [] : await embedMany(texts, options?.signal),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -35,6 +35,7 @@ function expectFetchRemoteEmbeddingVectorsBody(body: Record<string, unknown>) {
|
||||
headers: { Authorization: "Bearer test" },
|
||||
ssrfPolicy: undefined,
|
||||
fetchImpl: undefined,
|
||||
signal: undefined,
|
||||
body,
|
||||
errorPrefix: "openai embeddings failed",
|
||||
});
|
||||
|
||||
@@ -47,7 +47,11 @@ export async function createOpenAiEmbeddingProvider(
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
};
|
||||
|
||||
const embed = async (input: string[], kind: "query" | "document"): Promise<number[][]> => {
|
||||
const embed = async (
|
||||
input: string[],
|
||||
kind: "query" | "document",
|
||||
signal?: AbortSignal,
|
||||
): Promise<number[][]> => {
|
||||
if (input.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -57,6 +61,7 @@ export async function createOpenAiEmbeddingProvider(
|
||||
headers: client.headers,
|
||||
ssrfPolicy: client.ssrfPolicy,
|
||||
fetchImpl: client.fetchImpl,
|
||||
signal,
|
||||
body: {
|
||||
model: client.model,
|
||||
input,
|
||||
@@ -76,11 +81,11 @@ export async function createOpenAiEmbeddingProvider(
|
||||
...(typeof OPENAI_MAX_INPUT_TOKENS[client.model] === "number"
|
||||
? { maxInputTokens: OPENAI_MAX_INPUT_TOKENS[client.model] }
|
||||
: {}),
|
||||
embedQuery: async (text) => {
|
||||
const [vec] = await embed([text], "query");
|
||||
embedQuery: async (text, options) => {
|
||||
const [vec] = await embed([text], "query", options?.signal);
|
||||
return vec ?? [];
|
||||
},
|
||||
embedBatch: async (texts) => await embed(texts, "document"),
|
||||
embedBatch: async (texts, options) => await embed(texts, "document", options?.signal),
|
||||
},
|
||||
client,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
|
||||
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
export {
|
||||
buildOpenrouterProvider,
|
||||
isOpenRouterProxyReasoningUnsupportedModel,
|
||||
|
||||
@@ -16,12 +16,18 @@ import { resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
|
||||
describe("openrouter provider hooks", () => {
|
||||
it("registers OpenRouter speech alongside model, media, and catalog providers", async () => {
|
||||
const { providers, speechProviders, mediaProviders, imageProviders, videoProviders } =
|
||||
await registerProviderPlugin({
|
||||
plugin: openrouterPlugin,
|
||||
id: "openrouter",
|
||||
name: "OpenRouter Provider",
|
||||
});
|
||||
const {
|
||||
providers,
|
||||
speechProviders,
|
||||
mediaProviders,
|
||||
imageProviders,
|
||||
musicProviders,
|
||||
videoProviders,
|
||||
} = await registerProviderPlugin({
|
||||
plugin: openrouterPlugin,
|
||||
id: "openrouter",
|
||||
name: "OpenRouter Provider",
|
||||
});
|
||||
const modelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
|
||||
plugin: openrouterPlugin,
|
||||
pluginId: "openrouter",
|
||||
@@ -34,6 +40,7 @@ describe("openrouter provider hooks", () => {
|
||||
expect(speechProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(mediaProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(imageProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(musicProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(videoProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(modelCatalogProvider.liveCatalog).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-stream-family";
|
||||
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import {
|
||||
buildOpenrouterProvider,
|
||||
@@ -114,6 +115,7 @@ export default definePluginEntry({
|
||||
groupId: "openrouter",
|
||||
groupLabel: "OpenRouter",
|
||||
groupHint: "API key",
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -168,6 +170,7 @@ export default definePluginEntry({
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
|
||||
api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider());
|
||||
api.registerMusicGenerationProvider(buildOpenRouterMusicGenerationProvider());
|
||||
api.registerVideoGenerationProvider(buildOpenRouterVideoGenerationProvider());
|
||||
api.registerModelCatalogProvider({
|
||||
provider: PROVIDER_ID,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
|
||||
const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequestMock,
|
||||
resolveApiKeyForProviderMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({
|
||||
apiKey: "openrouter-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
})),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>();
|
||||
return {
|
||||
...original,
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
};
|
||||
});
|
||||
|
||||
function sseResponse(lines: string[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(line));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
|
||||
function stalledSseResponse(line: string): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(line));
|
||||
},
|
||||
cancel() {},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
|
||||
function postRequest(): Record<string, unknown> {
|
||||
const request = postJsonRequestMock.mock.calls[0]?.[0];
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
||||
throw new Error("expected OpenRouter music request");
|
||||
}
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("openrouter music generation provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
postJsonRequestMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
});
|
||||
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitMusicGenerationCapabilities(buildOpenRouterMusicGenerationProvider());
|
||||
});
|
||||
|
||||
it("streams OpenRouter audio chunks into a generated music asset", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const audioBase64 = Buffer.from("wav-bytes").toString("base64");
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: sseResponse([
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { transcript: "line " } } }] })}\n`,
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: audioBase64.slice(0, 4) } } }] })}\n`,
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: audioBase64.slice(4), transcript: "two" } } }] })}\n`,
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
release,
|
||||
});
|
||||
|
||||
const result = await buildOpenRouterMusicGenerationProvider().generateMusic({
|
||||
provider: "openrouter",
|
||||
model: "",
|
||||
prompt: "bright soundtrack",
|
||||
cfg: {},
|
||||
instrumental: true,
|
||||
format: "wav",
|
||||
});
|
||||
|
||||
expect(postRequest().url).toBe("https://openrouter.ai/api/v1/chat/completions");
|
||||
expect(postRequest().body).toEqual({
|
||||
model: "google/lyria-3-pro-preview",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"bright soundtrack\n\nInstrumental only. No vocals, no sung lyrics, no spoken word.",
|
||||
},
|
||||
],
|
||||
modalities: ["text", "audio"],
|
||||
audio: { format: "wav" },
|
||||
stream: true,
|
||||
});
|
||||
expect(result.tracks[0]?.mimeType).toBe("audio/wav");
|
||||
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("wav-bytes"));
|
||||
expect(result.lyrics).toEqual(["line two"]);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("decodes independently padded OpenRouter audio chunks", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: sseResponse([
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("a").toString("base64") } } }] })}\n`,
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("b").toString("base64") } } }] })}\n`,
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await buildOpenRouterMusicGenerationProvider().generateMusic({
|
||||
provider: "openrouter",
|
||||
model: "google/lyria-3-pro-preview",
|
||||
prompt: "chunked soundtrack",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("ab"));
|
||||
});
|
||||
|
||||
it("sends reference images as multimodal message content", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: sseResponse([
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("mp3").toString("base64") } } }] })}\n`,
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await buildOpenRouterMusicGenerationProvider().generateMusic({
|
||||
provider: "openrouter",
|
||||
model: "google/lyria-3-clip-preview",
|
||||
prompt: "score this image",
|
||||
cfg: {},
|
||||
format: "mp3",
|
||||
inputImages: [{ buffer: Buffer.from("png"), mimeType: "image/png" }],
|
||||
});
|
||||
|
||||
expect(postRequest().body).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "google/lyria-3-clip-preview",
|
||||
audio: { format: "mp3" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "score this image" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/png;base64,${Buffer.from("png").toString("base64")}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("times out stalled OpenRouter audio streams after headers", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: stalledSseResponse(
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { transcript: "start" } } }] })}\n`,
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildOpenRouterMusicGenerationProvider().generateMusic({
|
||||
provider: "openrouter",
|
||||
model: "google/lyria-3-clip-preview",
|
||||
prompt: "never finish",
|
||||
cfg: {},
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("OpenRouter music generation timed out after 1ms");
|
||||
});
|
||||
|
||||
it("rejects OpenRouter streams that end before completion", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: sseResponse([
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("partial").toString("base64") } } }] })}\n`,
|
||||
]),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildOpenRouterMusicGenerationProvider().generateMusic({
|
||||
provider: "openrouter",
|
||||
model: "google/lyria-3-clip-preview",
|
||||
prompt: "interrupted",
|
||||
cfg: {},
|
||||
}),
|
||||
).rejects.toThrow("OpenRouter music generation stream ended before completion");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import type {
|
||||
MusicGenerationProvider,
|
||||
MusicGenerationRequest,
|
||||
MusicGenerationSourceImage,
|
||||
} from "openclaw/plugin-sdk/music-generation";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
|
||||
|
||||
const DEFAULT_OPENROUTER_MUSIC_MODEL = "google/lyria-3-pro-preview";
|
||||
const OPENROUTER_CLIP_MUSIC_MODEL = "google/lyria-3-clip-preview";
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
const OPENROUTER_MUSIC_MODELS = [
|
||||
DEFAULT_OPENROUTER_MUSIC_MODEL,
|
||||
OPENROUTER_CLIP_MUSIC_MODEL,
|
||||
] as const;
|
||||
|
||||
type OpenRouterAudioStreamResult = {
|
||||
audioBuffer: Buffer;
|
||||
transcript: string;
|
||||
};
|
||||
|
||||
type OpenRouterStreamDeadline = {
|
||||
deadlineAtMs: number;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function resolveOpenRouterMusicModel(model: string | undefined): string {
|
||||
return normalizeOptionalString(model) ?? DEFAULT_OPENROUTER_MUSIC_MODEL;
|
||||
}
|
||||
|
||||
function outputFormatToMimeType(format: "mp3" | "wav" | undefined): string {
|
||||
return format === "mp3" ? "audio/mpeg" : "audio/wav";
|
||||
}
|
||||
|
||||
function imageToContentPart(image: MusicGenerationSourceImage): {
|
||||
type: "image_url";
|
||||
image_url: { url: string };
|
||||
} {
|
||||
const url =
|
||||
normalizeOptionalString(image.url) ??
|
||||
(image.buffer
|
||||
? `data:${normalizeOptionalString(image.mimeType) ?? "image/png"};base64,${image.buffer.toString("base64")}`
|
||||
: undefined);
|
||||
if (!url) {
|
||||
throw new Error("OpenRouter music generation reference image is missing data.");
|
||||
}
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: { url },
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenRouterMusicPrompt(req: MusicGenerationRequest): string {
|
||||
const parts = [req.prompt.trim()];
|
||||
const lyrics = normalizeOptionalString(req.lyrics);
|
||||
if (req.instrumental === true) {
|
||||
parts.push("Instrumental only. No vocals, no sung lyrics, no spoken word.");
|
||||
}
|
||||
if (lyrics) {
|
||||
parts.push(`Lyrics:\n${lyrics}`);
|
||||
}
|
||||
if (typeof req.durationSeconds === "number") {
|
||||
parts.push(`Target duration: about ${Math.round(req.durationSeconds)} seconds.`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function buildOpenRouterMessageContent(
|
||||
req: MusicGenerationRequest,
|
||||
):
|
||||
| string
|
||||
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> {
|
||||
const prompt = buildOpenRouterMusicPrompt(req);
|
||||
const images = req.inputImages ?? [];
|
||||
if (images.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
return [{ type: "text", text: prompt }, ...images.map((image) => imageToContentPart(image))];
|
||||
}
|
||||
|
||||
function readDeltaAudio(part: unknown): { data?: string; transcript?: string } | undefined {
|
||||
if (!isRecord(part)) {
|
||||
return undefined;
|
||||
}
|
||||
const choices = part.choices;
|
||||
if (!Array.isArray(choices)) {
|
||||
return undefined;
|
||||
}
|
||||
const first = choices[0];
|
||||
if (!isRecord(first)) {
|
||||
return undefined;
|
||||
}
|
||||
const delta = first.delta;
|
||||
if (!isRecord(delta)) {
|
||||
return undefined;
|
||||
}
|
||||
const audio = delta.audio;
|
||||
if (!isRecord(audio)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
data: normalizeOptionalString(audio.data),
|
||||
transcript: typeof audio.transcript === "string" ? audio.transcript : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function processOpenRouterSseLine(
|
||||
line: string,
|
||||
result: { audioBuffers: Buffer[]; transcriptChunks: string[] },
|
||||
): boolean {
|
||||
if (!line.startsWith("data:")) {
|
||||
return false;
|
||||
}
|
||||
const data = line.slice("data:".length).trim();
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
if (data === "[DONE]") {
|
||||
return true;
|
||||
}
|
||||
const audio = readDeltaAudio(JSON.parse(data));
|
||||
if (audio?.data) {
|
||||
result.audioBuffers.push(Buffer.from(audio.data, "base64"));
|
||||
}
|
||||
if (audio?.transcript) {
|
||||
result.transcriptChunks.push(audio.transcript);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createOpenRouterStreamDeadline(timeoutMs: number): OpenRouterStreamDeadline {
|
||||
return {
|
||||
deadlineAtMs: Date.now() + Math.max(1, Math.floor(timeoutMs)),
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenRouterStreamRemainingMs(deadline: OpenRouterStreamDeadline): number {
|
||||
const remainingMs = deadline.deadlineAtMs - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
throw new Error(`OpenRouter music generation timed out after ${deadline.timeoutMs}ms`);
|
||||
}
|
||||
return Math.max(1, remainingMs);
|
||||
}
|
||||
|
||||
async function readOpenRouterStreamChunk(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
deadline: OpenRouterStreamDeadline,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
const timeoutMs = resolveOpenRouterStreamRemainingMs(deadline);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`OpenRouter music generation timed out after ${deadline.timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readOpenRouterAudioStream(
|
||||
response: Response,
|
||||
deadline: OpenRouterStreamDeadline,
|
||||
): Promise<OpenRouterAudioStreamResult> {
|
||||
if (!response.body) {
|
||||
throw new Error("OpenRouter music generation response missing stream body");
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const result = { audioBuffers: [] as Buffer[], transcriptChunks: [] as string[] };
|
||||
let buffer = "";
|
||||
let doneSeen = false;
|
||||
for (;;) {
|
||||
const { value, done } = await readOpenRouterStreamChunk(reader, deadline);
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r?\n/u);
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (processOpenRouterSseLine(line.trim(), result)) {
|
||||
doneSeen = true;
|
||||
await reader.cancel();
|
||||
return {
|
||||
audioBuffer: Buffer.concat(result.audioBuffers),
|
||||
transcript: result.transcriptChunks.join(""),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
resolveOpenRouterStreamRemainingMs(deadline);
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
for (const line of buffer.split(/\r?\n/u)) {
|
||||
if (processOpenRouterSseLine(line.trim(), result)) {
|
||||
doneSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!doneSeen) {
|
||||
throw new Error("OpenRouter music generation stream ended before completion");
|
||||
}
|
||||
return {
|
||||
audioBuffer: Buffer.concat(result.audioBuffers),
|
||||
transcript: result.transcriptChunks.join(""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenRouterMusicGenerationProvider(): MusicGenerationProvider {
|
||||
return {
|
||||
id: "openrouter",
|
||||
label: "OpenRouter",
|
||||
defaultModel: DEFAULT_OPENROUTER_MUSIC_MODEL,
|
||||
models: [...OPENROUTER_MUSIC_MODELS],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "openrouter",
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: {
|
||||
generate: {
|
||||
maxTracks: 1,
|
||||
maxDurationSeconds: 180,
|
||||
supportsLyrics: true,
|
||||
supportsInstrumental: true,
|
||||
supportsDuration: true,
|
||||
supportsFormat: true,
|
||||
supportedFormats: ["mp3", "wav"],
|
||||
},
|
||||
edit: {
|
||||
enabled: true,
|
||||
maxTracks: 1,
|
||||
maxInputImages: 1,
|
||||
maxDurationSeconds: 180,
|
||||
supportsLyrics: true,
|
||||
supportsInstrumental: true,
|
||||
supportsDuration: true,
|
||||
supportsFormat: true,
|
||||
supportedFormats: ["mp3", "wav"],
|
||||
},
|
||||
},
|
||||
async generateMusic(req) {
|
||||
if ((req.inputImages?.length ?? 0) > 1) {
|
||||
throw new Error("OpenRouter music generation supports at most one reference image.");
|
||||
}
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "openrouter",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("OpenRouter API key missing");
|
||||
}
|
||||
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: req.cfg?.models?.providers?.openrouter?.baseUrl,
|
||||
defaultBaseUrl: OPENROUTER_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
provider: "openrouter",
|
||||
capability: "audio",
|
||||
transport: "http",
|
||||
});
|
||||
const model = resolveOpenRouterMusicModel(req.model);
|
||||
const format = req.format ?? "wav";
|
||||
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const streamDeadline = createOpenRouterStreamDeadline(timeoutMs);
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
headers,
|
||||
body: {
|
||||
model,
|
||||
messages: [{ role: "user", content: buildOpenRouterMessageContent(req) }],
|
||||
modalities: ["text", "audio"],
|
||||
audio: { format },
|
||||
stream: true,
|
||||
},
|
||||
timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenRouter music generation failed");
|
||||
const streamResult = await readOpenRouterAudioStream(response, streamDeadline);
|
||||
if (streamResult.audioBuffer.byteLength === 0) {
|
||||
throw new Error("OpenRouter music generation response missing audio data");
|
||||
}
|
||||
return {
|
||||
tracks: [
|
||||
{
|
||||
buffer: streamResult.audioBuffer,
|
||||
mimeType: outputFormatToMimeType(format),
|
||||
fileName: `track-1.${format}`,
|
||||
},
|
||||
],
|
||||
model,
|
||||
...(streamResult.transcript ? { lyrics: [streamResult.transcript] } : {}),
|
||||
metadata: {
|
||||
inputImageCount: req.inputImages?.length ?? 0,
|
||||
instrumental: req.instrumental === true,
|
||||
requestedFormat: format,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const _openRouterMusicTestInternals = {
|
||||
readOpenRouterAudioStream,
|
||||
};
|
||||
@@ -47,6 +47,7 @@
|
||||
"groupId": "openrouter",
|
||||
"groupLabel": "OpenRouter",
|
||||
"groupHint": "API key",
|
||||
"onboardingScopes": ["text-inference", "music-generation"],
|
||||
"optionKey": "openrouterApiKey",
|
||||
"cliFlag": "--openrouter-api-key",
|
||||
"cliOption": "--openrouter-api-key <key>",
|
||||
@@ -56,6 +57,7 @@
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["openrouter"],
|
||||
"imageGenerationProviders": ["openrouter"],
|
||||
"musicGenerationProviders": ["openrouter"],
|
||||
"videoGenerationProviders": ["openrouter"],
|
||||
"speechProviders": ["openrouter"]
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createOpenrouterProvider(): ProviderPlugin {
|
||||
groupId: "openrouter",
|
||||
groupLabel: "OpenRouter",
|
||||
groupHint: "API key",
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
|
||||
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
export { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
export { buildOpenRouterSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
@@ -761,6 +761,35 @@ describe("qa cli runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("expands the personal-agent pack onto the suite scenario list", async () => {
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
pack: "personal-agent",
|
||||
scenarioIds: ["channel-chat-baseline"],
|
||||
});
|
||||
|
||||
expectFields(mockFirstObjectArg(runQaSuiteFromRuntime), {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
scenarioIds: [
|
||||
"channel-chat-baseline",
|
||||
"personal-reminder-roundtrip",
|
||||
"personal-channel-thread-reply",
|
||||
"personal-memory-preference-recall",
|
||||
"personal-redaction-no-secret-leak",
|
||||
"personal-tool-safety-followthrough",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown suite packs", async () => {
|
||||
await expect(
|
||||
runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
pack: "personal-admin",
|
||||
}),
|
||||
).rejects.toThrow('--pack must be one of personal-agent, got "personal-admin"');
|
||||
});
|
||||
|
||||
it("rejects unknown suite CLI auth modes", async () => {
|
||||
await expect(
|
||||
runQaSuiteCommand({
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./run-config.js";
|
||||
import type { RuntimeId } from "./runtime-parity.js";
|
||||
import { readQaScenarioPack } from "./scenario-catalog.js";
|
||||
import { resolveQaScenarioPackScenarioIds } from "./scenario-packs.js";
|
||||
import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js";
|
||||
import { readQaSuiteFailedScenarioCountFromSummary } from "./suite-summary.js";
|
||||
|
||||
@@ -496,6 +497,7 @@ export async function runQaSuiteCommand(opts: {
|
||||
thinking?: string;
|
||||
cliAuthMode?: string;
|
||||
parityPack?: string;
|
||||
pack?: string;
|
||||
scenarioIds?: string[];
|
||||
concurrency?: number;
|
||||
allowFailures?: boolean;
|
||||
@@ -510,9 +512,12 @@ export async function runQaSuiteCommand(opts: {
|
||||
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
|
||||
const transportId = normalizeQaTransportId(opts.transportId);
|
||||
const runner = (opts.runner ?? "host").trim().toLowerCase();
|
||||
const scenarioIds = resolveQaParityPackScenarioIds({
|
||||
parityPack: opts.parityPack,
|
||||
scenarioIds: opts.scenarioIds,
|
||||
const scenarioIds = resolveQaScenarioPackScenarioIds({
|
||||
pack: opts.pack,
|
||||
scenarioIds: resolveQaParityPackScenarioIds({
|
||||
parityPack: opts.parityPack,
|
||||
scenarioIds: opts.scenarioIds,
|
||||
}),
|
||||
});
|
||||
const allowFailures = opts.allowFailures === true;
|
||||
if (runner !== "host" && runner !== "multipass") {
|
||||
|
||||
@@ -537,6 +537,13 @@ describe("qa cli registration", () => {
|
||||
expect(options.allowFailures).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards --pack for suite runs", async () => {
|
||||
await program.parseAsync(["node", "openclaw", "qa", "suite", "--pack", "personal-agent"]);
|
||||
|
||||
const options = requireQaSuiteOptions();
|
||||
expect(options.pack).toBe("personal-agent");
|
||||
});
|
||||
|
||||
it("routes credential add flags into the qa runtime command", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
|
||||
@@ -41,6 +41,7 @@ async function runQaSuite(opts: {
|
||||
enabledPluginIds?: string[];
|
||||
cliAuthMode?: string;
|
||||
parityPack?: string;
|
||||
pack?: string;
|
||||
scenarioIds?: string[];
|
||||
concurrency?: number;
|
||||
runner?: string;
|
||||
@@ -253,6 +254,7 @@ export function registerQaLabCli(program: Command) {
|
||||
"CLI backend auth mode for live Claude CLI runs: auto, api-key, or subscription",
|
||||
)
|
||||
.option("--parity-pack <name>", 'Preset scenario pack; currently only "agentic" is supported')
|
||||
.option("--pack <id>", 'Scenario pack id; currently only "personal-agent" is supported')
|
||||
.option("--scenario <id>", "Run only the named QA scenario (repeatable)", collectString, [])
|
||||
.option(
|
||||
"--enable-plugin <id>",
|
||||
@@ -290,6 +292,7 @@ export function registerQaLabCli(program: Command) {
|
||||
altModel?: string;
|
||||
cliAuthMode?: string;
|
||||
parityPack?: string;
|
||||
pack?: string;
|
||||
scenario?: string[];
|
||||
enablePlugin?: string[];
|
||||
concurrency?: number;
|
||||
@@ -315,6 +318,7 @@ export function registerQaLabCli(program: Command) {
|
||||
thinking: opts.thinking,
|
||||
cliAuthMode: opts.cliAuthMode,
|
||||
parityPack: opts.parityPack,
|
||||
pack: opts.pack,
|
||||
scenarioIds: opts.scenario,
|
||||
enabledPluginIds: opts.enablePlugin,
|
||||
concurrency: opts.concurrency,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertNoGatewayLogSentinels,
|
||||
formatGatewayLogSentinelSummary,
|
||||
scanDirectReplyTranscriptSentinels,
|
||||
scanGatewayLogSentinels,
|
||||
} from "./gateway-log-sentinel.js";
|
||||
|
||||
describe("gateway log sentinels", () => {
|
||||
it("classifies May 13 beta.5 operational failure signatures", () => {
|
||||
const findings = scanGatewayLogSentinels(
|
||||
[
|
||||
"2026-05-13T00:00:01Z plugin before_prompt_build hook failed: TypeError: boom",
|
||||
"2026-05-13T00:00:02Z plugin before_tool_call crashed while evaluating policy",
|
||||
"2026-05-13T00:00:03Z plugin manifest invalid: missing contracts.tools registration",
|
||||
"2026-05-13T00:00:04Z codex app-server attempt timed out after 180000ms",
|
||||
"2026-05-13T00:00:05Z codex_app_server progress stalled for run abc123",
|
||||
"2026-05-13T00:00:06Z cron payload model openai/gpt-5.4 is not in model allowlist",
|
||||
"2026-05-13T00:00:07Z OpenAI quota exceeded for live-frontier request",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(findings.map((finding) => finding.kind)).toEqual([
|
||||
"plugin-hook-failure",
|
||||
"plugin-hook-failure",
|
||||
"plugin-contract-error",
|
||||
"codex-app-server-timeout",
|
||||
"stalled-agent-run",
|
||||
"cron-model-allowlist",
|
||||
"live-quota-or-subscription",
|
||||
]);
|
||||
expect(findings.find((finding) => finding.kind === "plugin-hook-failure")).toMatchObject({
|
||||
verdict: "qa-harness-bug",
|
||||
owner: "plugin",
|
||||
productImpact: "P1",
|
||||
});
|
||||
expect(findings.find((finding) => finding.kind === "live-quota-or-subscription")).toMatchObject(
|
||||
{
|
||||
verdict: "environment-blocked",
|
||||
owner: "environment",
|
||||
productImpact: "P4",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("honors log cursors while preserving absolute line numbers", () => {
|
||||
const prefix = "safe line\n";
|
||||
const findings = scanGatewayLogSentinels(`${prefix}codex app-server attempt timed out`, {
|
||||
since: prefix.length,
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
kind: "codex-app-server-timeout",
|
||||
line: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws actionable summaries unless only environment blockers are allowed", () => {
|
||||
expect(() => assertNoGatewayLogSentinels("codex_app_server progress stalled")).toThrow(
|
||||
"stalled-agent-run",
|
||||
);
|
||||
expect(() =>
|
||||
assertNoGatewayLogSentinels("OpenAI quota exceeded", { allowEnvironmentBlocked: true }),
|
||||
).not.toThrow();
|
||||
expect(formatGatewayLogSentinelSummary(scanGatewayLogSentinels("OpenAI quota exceeded"))).toBe(
|
||||
"live-quota-or-subscription@1 environment-blocked owner=environment: OpenAI quota exceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects direct reply self-message transcripts separately from gateway logs", () => {
|
||||
const findings = scanDirectReplyTranscriptSentinels(
|
||||
[
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "message",
|
||||
input: { action: "send", conversationId: "qa-operator", text: "hello" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Sent." } }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
kind: "direct-reply-self-message",
|
||||
verdict: "product-bug",
|
||||
owner: "openclaw-routing",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects OpenAI function_call-shaped direct reply transcripts", () => {
|
||||
const findings = scanDirectReplyTranscriptSentinels(
|
||||
[
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "function_call",
|
||||
name: "message",
|
||||
arguments: JSON.stringify({
|
||||
action: "send",
|
||||
target: "current",
|
||||
text: "hello",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Sent." } }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(findings.map((finding) => finding.kind)).toEqual(["direct-reply-self-message"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
export type GatewayLogSentinelKind =
|
||||
| "plugin-hook-failure"
|
||||
| "plugin-contract-error"
|
||||
| "direct-reply-self-message"
|
||||
| "codex-app-server-timeout"
|
||||
| "stalled-agent-run"
|
||||
| "cron-model-allowlist"
|
||||
| "live-quota-or-subscription";
|
||||
|
||||
export type GatewayLogSentinelVerdict =
|
||||
| "product-bug"
|
||||
| "qa-harness-bug"
|
||||
| "fixture-bug"
|
||||
| "environment-blocked";
|
||||
|
||||
export type GatewayLogSentinelOwner =
|
||||
| "plugin"
|
||||
| "openclaw-routing"
|
||||
| "codex-runtime"
|
||||
| "openclaw-cron"
|
||||
| "environment";
|
||||
|
||||
export type GatewayLogSentinelFinding = {
|
||||
kind: GatewayLogSentinelKind;
|
||||
verdict: GatewayLogSentinelVerdict;
|
||||
owner: GatewayLogSentinelOwner;
|
||||
productImpact: "P0" | "P1" | "P2" | "P3" | "P4";
|
||||
qaImpact: "P0" | "P1" | "P2" | "P3" | "P4";
|
||||
line: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type GatewayLogSentinelScanOptions = {
|
||||
since?: number;
|
||||
kinds?: readonly GatewayLogSentinelKind[];
|
||||
ignoreKinds?: readonly GatewayLogSentinelKind[];
|
||||
};
|
||||
|
||||
export type GatewayLogSentinelAssertOptions = GatewayLogSentinelScanOptions & {
|
||||
allowEnvironmentBlocked?: boolean;
|
||||
};
|
||||
|
||||
type GatewayLogSentinelRule = Omit<GatewayLogSentinelFinding, "line" | "text"> & {
|
||||
test: (line: string) => boolean;
|
||||
};
|
||||
|
||||
type GatewayLogSentinelToolCall = {
|
||||
name: string;
|
||||
args: unknown;
|
||||
};
|
||||
|
||||
const GATEWAY_LOG_SENTINEL_RULES: GatewayLogSentinelRule[] = [
|
||||
{
|
||||
kind: "plugin-hook-failure",
|
||||
verdict: "qa-harness-bug",
|
||||
owner: "plugin",
|
||||
productImpact: "P1",
|
||||
qaImpact: "P0",
|
||||
test: (line) =>
|
||||
/\bbefore_(?:prompt_build|tool_call)\b/iu.test(line) &&
|
||||
/\b(?:crash(?:ed)?|exception|failed|failure|error)\b/iu.test(line),
|
||||
},
|
||||
{
|
||||
kind: "plugin-contract-error",
|
||||
verdict: "qa-harness-bug",
|
||||
owner: "plugin",
|
||||
productImpact: "P1",
|
||||
qaImpact: "P0",
|
||||
test: (line) =>
|
||||
/\bcontracts\.tools\b/iu.test(line) &&
|
||||
/\b(?:missing|invalid|registration|register|manifest|contract|schema|error)\b/iu.test(line),
|
||||
},
|
||||
{
|
||||
kind: "codex-app-server-timeout",
|
||||
verdict: "product-bug",
|
||||
owner: "codex-runtime",
|
||||
productImpact: "P1",
|
||||
qaImpact: "P0",
|
||||
test: (line) =>
|
||||
/\bcodex app-server\b.*\btimed out\b|\btimed out\b.*\bcodex app-server\b/iu.test(line),
|
||||
},
|
||||
{
|
||||
kind: "stalled-agent-run",
|
||||
verdict: "product-bug",
|
||||
owner: "codex-runtime",
|
||||
productImpact: "P1",
|
||||
qaImpact: "P0",
|
||||
test: (line) =>
|
||||
/\bcodex_app_server\b.*\b(?:stalled|no progress|progress stalled)\b|\b(?:stalled|no progress|progress stalled)\b.*\bcodex_app_server\b/iu.test(
|
||||
line,
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: "cron-model-allowlist",
|
||||
verdict: "product-bug",
|
||||
owner: "openclaw-cron",
|
||||
productImpact: "P2",
|
||||
qaImpact: "P0",
|
||||
test: (line) =>
|
||||
/\bcron\b/iu.test(line) &&
|
||||
(/\bmodel allowlist\b/iu.test(line) ||
|
||||
/\ballowlist\b.*\bmodel\b/iu.test(line) ||
|
||||
/\bmodel\b.*\b(?:not in|outside|blocked by)\b.*\ballowlist\b/iu.test(line)),
|
||||
},
|
||||
{
|
||||
kind: "live-quota-or-subscription",
|
||||
verdict: "environment-blocked",
|
||||
owner: "environment",
|
||||
productImpact: "P4",
|
||||
qaImpact: "P0",
|
||||
test: (line) =>
|
||||
/\b(?:quota exceeded|insufficient_quota|subscription exhausted|no active subscription|billing hard limit|usage limit)\b/iu.test(
|
||||
line,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function filterGatewayLogSentinelFindings(
|
||||
findings: GatewayLogSentinelFinding[],
|
||||
options: GatewayLogSentinelScanOptions | undefined,
|
||||
) {
|
||||
const kinds = new Set(options?.kinds ?? []);
|
||||
const ignoreKinds = new Set(options?.ignoreKinds ?? []);
|
||||
return findings.filter((finding) => {
|
||||
if (kinds.size > 0 && !kinds.has(finding.kind)) {
|
||||
return false;
|
||||
}
|
||||
return !ignoreKinds.has(finding.kind);
|
||||
});
|
||||
}
|
||||
|
||||
function lineNumberForOffset(logs: string, offset: number) {
|
||||
if (offset <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return logs.slice(0, offset).split(/\r?\n/u).length;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractMessageText(message: Record<string, unknown>) {
|
||||
const rawContent = message.content;
|
||||
if (typeof rawContent === "string") {
|
||||
return rawContent.trim();
|
||||
}
|
||||
if (!Array.isArray(rawContent)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const block of rawContent) {
|
||||
if (typeof block === "string") {
|
||||
if (block.trim()) {
|
||||
parts.push(block.trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(block)) {
|
||||
continue;
|
||||
}
|
||||
const text = readNonEmptyString(block.text);
|
||||
if (text) {
|
||||
parts.push(text);
|
||||
continue;
|
||||
}
|
||||
const nestedText = readNonEmptyString(block.content);
|
||||
if (
|
||||
nestedText &&
|
||||
(block.type === "output_text" || block.type === "text" || block.type === "message")
|
||||
) {
|
||||
parts.push(nestedText);
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function parseJsonArguments(value: unknown): unknown {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractAssistantToolCalls(message: Record<string, unknown>): GatewayLogSentinelToolCall[] {
|
||||
const calls: GatewayLogSentinelToolCall[] = [];
|
||||
const rawContent = message.content;
|
||||
if (Array.isArray(rawContent)) {
|
||||
for (const block of rawContent) {
|
||||
if (!isRecord(block)) {
|
||||
continue;
|
||||
}
|
||||
const type = readNonEmptyString(block.type)?.toLowerCase();
|
||||
if (
|
||||
type !== "tool_use" &&
|
||||
type !== "toolcall" &&
|
||||
type !== "tool_call" &&
|
||||
type !== "function_call"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
calls.push({
|
||||
name: readNonEmptyString(block.name) ?? "unknown",
|
||||
args: parseJsonArguments(block.input ?? block.arguments ?? block.args ?? null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rawToolCalls =
|
||||
message.tool_calls ?? message.toolCalls ?? message.function_call ?? message.functionCall;
|
||||
const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls : rawToolCalls ? [rawToolCalls] : [];
|
||||
for (const call of toolCalls) {
|
||||
if (!isRecord(call)) {
|
||||
continue;
|
||||
}
|
||||
const functionRecord = isRecord(call.function) ? call.function : undefined;
|
||||
calls.push({
|
||||
name: readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name) ?? "unknown",
|
||||
args: parseJsonArguments(
|
||||
call.arguments ?? functionRecord?.arguments ?? call.input ?? functionRecord?.input ?? null,
|
||||
),
|
||||
});
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isCurrentChatMessageSend(call: GatewayLogSentinelToolCall) {
|
||||
if (call.name !== "message") {
|
||||
return false;
|
||||
}
|
||||
if (!isRecord(call.args) || readNonEmptyString(call.args.action)?.toLowerCase() !== "send") {
|
||||
return false;
|
||||
}
|
||||
const explicitTarget =
|
||||
readNonEmptyString(call.args.conversationId) ??
|
||||
readNonEmptyString(call.args.conversation) ??
|
||||
readNonEmptyString(call.args.to) ??
|
||||
readNonEmptyString(call.args.target);
|
||||
if (!explicitTarget) {
|
||||
return true;
|
||||
}
|
||||
return /\b(?:current|same-chat|qa-operator|dm:qa-operator)\b/iu.test(explicitTarget);
|
||||
}
|
||||
|
||||
function normalizeTranscriptText(text: string) {
|
||||
return text.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function transcriptHasDirectReplySelfMessage(transcriptBytes: string) {
|
||||
let lastAssistantText = "";
|
||||
const toolCalls: GatewayLogSentinelToolCall[] = [];
|
||||
for (const line of transcriptBytes.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
const message = isRecord(parsed) && isRecord(parsed.message) ? parsed.message : undefined;
|
||||
if (!message || message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const text = extractMessageText(message);
|
||||
if (text) {
|
||||
lastAssistantText = text;
|
||||
}
|
||||
toolCalls.push(...extractAssistantToolCalls(message));
|
||||
} catch {
|
||||
// Ignore malformed QA transcript rows and keep sentinel scans deterministic.
|
||||
}
|
||||
}
|
||||
return (
|
||||
toolCalls.some(isCurrentChatMessageSend) &&
|
||||
normalizeTranscriptText(lastAssistantText).toLowerCase() === "sent."
|
||||
);
|
||||
}
|
||||
|
||||
export function scanGatewayLogSentinels(
|
||||
logs: string | undefined,
|
||||
options?: GatewayLogSentinelScanOptions,
|
||||
): GatewayLogSentinelFinding[] {
|
||||
if (!logs) {
|
||||
return [];
|
||||
}
|
||||
const startOffset = Math.max(0, Math.min(logs.length, Math.floor(options?.since ?? 0)));
|
||||
const lineOffset = lineNumberForOffset(logs, startOffset) - 1;
|
||||
const findings: GatewayLogSentinelFinding[] = [];
|
||||
for (const [index, rawLine] of logs.slice(startOffset).split(/\r?\n/u).entries()) {
|
||||
const text = rawLine.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
for (const rule of GATEWAY_LOG_SENTINEL_RULES) {
|
||||
if (!rule.test(text)) {
|
||||
continue;
|
||||
}
|
||||
findings.push({
|
||||
kind: rule.kind,
|
||||
verdict: rule.verdict,
|
||||
owner: rule.owner,
|
||||
productImpact: rule.productImpact,
|
||||
qaImpact: rule.qaImpact,
|
||||
line: lineOffset + index + 1,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
return filterGatewayLogSentinelFindings(findings, options);
|
||||
}
|
||||
|
||||
export function scanDirectReplyTranscriptSentinels(
|
||||
transcriptBytes: string,
|
||||
): GatewayLogSentinelFinding[] {
|
||||
if (!transcriptHasDirectReplySelfMessage(transcriptBytes)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "direct-reply-self-message",
|
||||
verdict: "product-bug",
|
||||
owner: "openclaw-routing",
|
||||
productImpact: "P1",
|
||||
qaImpact: "P0",
|
||||
line: 1,
|
||||
text: "assistant called message(action=send) and then produced final text Sent.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function formatGatewayLogSentinelSummary(findings: readonly GatewayLogSentinelFinding[]) {
|
||||
if (findings.length === 0) {
|
||||
return "no gateway log sentinels";
|
||||
}
|
||||
return findings
|
||||
.map(
|
||||
(finding) =>
|
||||
`${finding.kind}@${finding.line} ${finding.verdict} owner=${finding.owner}: ${finding.text}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function assertNoGatewayLogSentinels(
|
||||
logs: string | undefined,
|
||||
options?: GatewayLogSentinelAssertOptions,
|
||||
) {
|
||||
const findings = scanGatewayLogSentinels(logs, options);
|
||||
if (findings.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
if (
|
||||
options?.allowEnvironmentBlocked === true &&
|
||||
findings.every((finding) => finding.verdict === "environment-blocked")
|
||||
) {
|
||||
return findings;
|
||||
}
|
||||
throw new Error(
|
||||
`Gateway log sentinel(s) detected:\n${formatGatewayLogSentinelSummary(findings)}`,
|
||||
);
|
||||
}
|
||||
@@ -1526,6 +1526,48 @@ describe("qa mock openai server", () => {
|
||||
"Protocol note: I checked memory and the current Project Nebula codename is ORBIT-10.",
|
||||
);
|
||||
|
||||
const memoryFollowupPrefersSessionResult = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Session memory ranking check: what is the current Project Nebula codename? Use memory tools first.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
output: JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
},
|
||||
{
|
||||
path: "sessions/qa-session-memory-ranking.jsonl",
|
||||
startLine: 2,
|
||||
endLine: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(memoryFollowupPrefersSessionResult.status).toBe(200);
|
||||
expect(await memoryFollowupPrefersSessionResult.text()).toContain(
|
||||
"Protocol note: I checked memory and the current Project Nebula codename is ORBIT-10.",
|
||||
);
|
||||
|
||||
const activeMemorySearch = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -1834,13 +1834,16 @@ async function buildResponsesPayload(
|
||||
const results = Array.isArray(toolJson?.results)
|
||||
? (toolJson.results as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const first = results[0];
|
||||
const firstPath = typeof first?.path === "string" ? first.path : undefined;
|
||||
if (first?.source === "sessions" || firstPath?.startsWith("sessions/")) {
|
||||
const preferredSessionResult = results.find((result) => {
|
||||
const resultPath = typeof result.path === "string" ? result.path : undefined;
|
||||
return result.source === "sessions" || resultPath?.startsWith("sessions/");
|
||||
});
|
||||
if (preferredSessionResult) {
|
||||
return buildAssistantEvents(
|
||||
"Protocol note: I checked memory and the current Project Nebula codename is ORBIT-10.",
|
||||
);
|
||||
}
|
||||
const first = results[0];
|
||||
if (
|
||||
typeof first?.path === "string" &&
|
||||
(typeof first.startLine === "number" || typeof first.endLine === "number")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user