Merge branch 'main' into fix-usage-tooltip-clipping

This commit is contained in:
sandypockets
2026-05-16 23:21:33 -04:00
committed by GitHub
435 changed files with 17364 additions and 3851 deletions
+10 -2
View File
@@ -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
+1
View File
@@ -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.
+55
View File
@@ -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.
+4 -4
View File
@@ -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
+2 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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`
+2 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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>
+29
View File
@@ -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
+2 -2
View File
@@ -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 | | | | | ✓ | | |
+33 -16
View File
@@ -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:
+2
View File
@@ -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.
+20 -10
View File
@@ -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);
+17 -3
View File
@@ -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 () => {
+4 -2
View File
@@ -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
View File
@@ -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",
+21 -1
View File
@@ -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,
+10 -1
View File
@@ -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");
+99 -16
View File
@@ -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> = [];
+124 -39
View File
@@ -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(
+6 -3
View File
@@ -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: {
+16 -7
View File
@@ -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",
+7 -1
View File
@@ -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;
+3 -1
View File
@@ -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,
});
});
});
+219
View File
@@ -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();
}
},
};
}
+3 -2
View File
@@ -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,
+4 -4
View File
@@ -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"],
},
},
],
+4 -4
View File
@@ -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
View File
@@ -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";
+5 -4
View File
@@ -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,
+17 -3
View File
@@ -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";
+12 -8
View File
@@ -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",
});
+9 -4
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
export {
buildOpenrouterProvider,
isOpenRouterProxyReasoningUnsupportedModel,
+13 -6
View File
@@ -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");
});
+3
View File
@@ -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
View File
@@ -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";
+29
View File
@@ -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({
+8 -3
View File
@@ -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") {
+7
View File
@@ -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",
+4
View File
@@ -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