diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 8e243186504..e7df3eb8c0d 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -599,7 +599,7 @@ jobs: published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }} published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }} telegram_mode: mock-openai - telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating + telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f3d480319..6c0f91de33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ Docs: https://docs.openclaw.ai ### Changes +- Active Memory: support concrete `plugins.entries.active-memory.config.toolsAllow` recall tool names for custom memory plugins while keeping the built-in memory-core default on `memory_search`/`memory_get` and preserving `memory_recall` automatically for `plugins.slots.memory: "memory-lancedb"`. +- Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia. - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. +- Google/Gemini: emit canonical `google/gemini-3.1-pro-preview` ids from configured provider catalog rows so model list and selection paths can test Gemini 3.1 instead of retired Gemini 3 Pro. +- Google/Gemini: normalize nested proxy-provider catalog ids like `google/gemini-3-pro-preview` to `google/gemini-3.1-pro-preview`, so Kilo-style configured catalogs test Gemini 3.1 instead of the retired Gemini 3 Pro id. - Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. @@ -20,10 +24,12 @@ Docs: https://docs.openclaw.ai - Agents/compaction: keep contributor diagnostics to a bounded top-three selection without sorting the full history. Thanks @shakkernerd. - Sessions/UI: avoid full-array sorting while selecting ACPX leases, Google Meet calendar events, and latest chat sessions. Thanks @shakkernerd. - Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus. +- Telegram/streaming: continue over-limit draft previews in a new message instead of stopping when rendered preview text crosses Telegram's message limit. (#74508) Thanks @anagnorisis2peripeteia. - Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip. - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. - Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921) - Discord/voice: make duplicate same-guild auto-join entries resolve to the last configured channel so moving an agent between voice channels does not keep joining the stale channel. +- Discord/voice: add realtime `/vc` modes so Discord voice channels can run as STT/TTS, a realtime talk buffer with the OpenClaw agent brain, or a bidi realtime session with `openclaw_agent_consult`. - Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply. - Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`. - Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes. @@ -50,6 +56,7 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. - Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash. - Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd. +- ACP bridge: relay Gateway exec approval prompts from active ACP turns to the ACP client's `session/request_permission` handler before resolving the Gateway approval. Thanks @amknight. - Codex/plugins: enable migrated source-installed `openai-curated` Codex plugins in the same Codex harness thread with explicit `codexPlugins` config, cached app readiness, and fail-closed destructive-action policy. Thanks @kevinslin. - Codex/plugins: enforce native plugin destructive-action policy with Codex app-level `destructive_enabled` config instead of OpenClaw-maintained per-tool deny lists, leave plugin app `open_world_enabled` on by default, and invalidate existing plugin app thread bindings so old generated app config is rebuilt. Thanks @kevinslin. - PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement. @@ -84,6 +91,7 @@ Docs: https://docs.openclaw.ai - Slack/performance: reduce message preparation, stream recipient lookup, and thread-context allocation overhead on Slack reply hot paths. Thanks @vincentkoc. - Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines. - Control UI/chat: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, and hide that row while scrolling down the transcript. Thanks @BunsDev. +- Control UI/chat: strip untrusted sender metadata from live streams and transcript display, preserve canvas preview anchors, and stop operator UI clients from injecting their internal client id as sender identity. Fixes #78739. Thanks @tmimmanuel, @guguangxin-eng, @hclsys, and @BunsDev. - Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so repeated text-only messages stay compact without hiding nearby context. - Control UI/chat and Sessions: label inherited thinking defaults separately from explicit overrides while preserving provider-supplied option labels. Fixes #77581. Thanks @BunsDev and @Beandon13. - Agents/runtime: add prepared runtime foundation contracts for carrying provider, model, tool, TTS, and outbound runtime facts through later reply-path migrations. Thanks @mcaxtr. @@ -184,16 +192,25 @@ Docs: https://docs.openclaw.ai ### Fixes +- Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd. +- Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. +- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD. - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. +- Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. +- Plugins/Nix: allow externally configured plugin roots under `/nix/store` to load in `OPENCLAW_NIX_MODE=1` while keeping normal external plugin hardlink rejection unchanged. Thanks @joshp123. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. +- Infra/fetch-timeout: pass `operation` and `url` context to `buildTimeoutAbortSignal` from the music-generate reference fetch and the Matrix guarded redirect transport, so the `fetch timeout reached; aborting operation` warning carries actionable structured fields instead of a bare line. Fixes #79195. Thanks @pandadev66. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. +- macOS/config: reject stale or destructive app fallback config writes before direct replacement and keep rejected payloads as private audit artifacts, so `gateway.mode`, metadata, and auth are not silently clobbered. Fixes #64973 and #74890. Thanks @BunsDev. +- Gateway/macOS: include Apple Silicon Homebrew bin and sbin directories in generated LaunchAgent service PATHs so `openclaw gateway restart` keeps Homebrew Node installs reachable. Fixes #79232. Thanks @BunsDev. - Doctor/OpenAI: stop pinning migrated `openai-codex/*` routes to the Codex runtime so mixed-provider agents keep automatic PI routing for MiniMax, Anthropic, and other non-OpenAI model switches. - Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022. - Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash. - Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16. - CLI/status: keep lean `openclaw status --json` off manifest-backed channel discovery so configured-channel checks do not repeatedly rescan plugin metadata. Fixes #79129. - Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev. +- CLI/infer: canonicalize case-only catalog model refs in `infer model run --model` so mixed-case provider/model strings resolve to the canonical catalog entry instead of failing with `Unknown model`. (#78940) Thanks @ai-hpc. - CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago. - Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments. - Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy. @@ -224,6 +241,7 @@ Docs: https://docs.openclaw.ai - Docs/Docker: document a local Compose override for Docker Desktop DNS failures in the shared-network `openclaw-cli` sidecar, keeping the default compose setup hardened while unblocking `openclaw plugins install` when users opt in. Fixes #79018. Thanks @Jason-Vaughan. - Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762. - Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews. +- Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590. Refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481. - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987. - Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987. @@ -302,6 +320,7 @@ Docs: https://docs.openclaw.ai - Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight. - LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316. - Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent. +- Telegram: keep duplicate message-tool-only Codex turns from posting generic silent-reply fallback text, so private finals stay private after inbound dedupe. Thanks @rubencu. - Telegram/sessions: gap-fill delivered embedded final replies into the session JSONL even when the runner trace is missing, so Telegram answers after tool calls do not vanish from the durable transcript. Fixes #77814. (#78426) Thanks @obviyus, @ChushulSuri, and @DougButdorf. - Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`. - Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models. @@ -638,6 +657,10 @@ Docs: https://docs.openclaw.ai - Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong. - Browser/downloads: route explicit and managed browser download output directories through `fs-safe` validation before staging final files, so symlinked output roots are rejected before writes. (#78780) Thanks @jesse-merhi. - Agents/PI: skip the idle wait during aborted embedded-run cleanup, so stopped or timed-out runs clear pending tool state and release the session lock promptly. (#74919) Thanks @medns. +- Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. +- Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns. +- Agents/failover: rotate auth profiles before deferred cooldown marking on rate-limit failures, so file-lock contention cannot stall profile failover. Fixes #57281. (#57283) Thanks @jeremyknows. +- Gateway/sessions: when `session.dmScope: "main"` is configured, route a bare webchat `/new` against the agent's main session (`sessions.create` with `emitCommandHooks=true`) to an in-place reset instead of creating a parallel `dashboard:` child, matching `/new` behavior on Telegram/Discord. Fixes #77434. (#71170) Thanks @statxc. ## 2026.5.3-1 @@ -861,6 +884,7 @@ Docs: https://docs.openclaw.ai - Agents/idle-timeout: add a cost-runaway breaker to the outer embedded-run retry loop that halts further attempts after 5 consecutive idle timeouts without completed model progress, so a wedged provider can no longer fan paid model calls out across the same run; completed text or tool-call progress resets the breaker, but partial tool-argument token dribbles do not. Fixes #76293. Thanks @ThePuma312. - Heartbeats/Codex: align structured heartbeat prompts with actual `heartbeat_respond` tool availability, stop sending legacy `HEARTBEAT_OK` when the tool exists, and keep tool-disabled commitment check-ins on the legacy ack path. Thanks @pashpashpash and @vincentkoc. - Agent runtimes: fail explicit plugin runtime selections honestly when the requested harness is unavailable instead of silently falling back to the embedded PI runtime. Thanks @pashpashpash. +- Telegram: log inbound gateway watch messages before dispatch so watch-mode diagnostics include incoming message summaries. Thanks @rubencu. - Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev. - Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79. - Gateway/responses: emit every client tool call from `/v1/responses` JSON and SSE responses when the agent invokes multiple client tools in a single turn, so multi-tool plans, graph orchestration calls, and similar batched flows no longer drop every call but the last. Fixes #52288. Thanks @CharZhou and @bonelli. diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 7c6ab0e8acd..6ab59ae1a15 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -8,6 +8,8 @@ import SwiftUI @MainActor @Observable final class AppState { + private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state") + private let isPreview: Bool private var isInitializing = true private var isApplyingRemoteTokenConfig = false @@ -696,7 +698,10 @@ final class AppState { remoteToken: self.remoteToken, remoteTokenDirty: self.remoteTokenDirty)) guard synced.changed else { return } - OpenClawConfigFile.saveDict(synced.root) + guard OpenClawConfigFile.saveDict(synced.root) else { + Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode") + return + } } func triggerVoiceEars(ttl: TimeInterval? = 5) { diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift index ecc96205067..b16fef293e6 100644 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -8,6 +8,7 @@ enum ConfigStore { var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)? var loadRemote: (@MainActor @Sendable () async -> [String: Any])? var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)? + var saveGateway: (@MainActor @Sendable ([String: Any]) async throws -> Void)? } private actor OverrideStore { @@ -66,10 +67,19 @@ enum ConfigStore { do { try await self.saveToGateway(root) } catch { - OpenClawConfigFile.saveDict( + guard self.shouldFallbackToLocalWrite(afterGatewaySaveError: error) else { + self.lastHash = nil + throw error + } + guard OpenClawConfigFile.saveDict( root, preserveExistingKeys: true, allowGatewayAuthMutation: allowGatewayAuthMutation) + else { + throw NSError(domain: "ConfigStore", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Local config write rejected to protect gateway auth/mode.", + ]) + } } } } @@ -89,8 +99,30 @@ enum ConfigStore { } } + private static func shouldFallbackToLocalWrite(afterGatewaySaveError error: Error) -> Bool { + let nsError = error as NSError + let message = "\(nsError.domain) \(nsError.localizedDescription)".lowercased() + let blockedFragments = [ + "invalid_request", + "invalid request", + "invalid config", + "config changed since last load", + "base hash", + "basehash", + "unauthorized", + "token mismatch", + "auth", + ] + return !blockedFragments.contains { message.contains($0) } + } + @MainActor private static func saveToGateway(_ root: [String: Any]) async throws { + let overrides = await self.overrideStore.overrides + if let saveGateway = overrides.saveGateway { + try await saveGateway(root) + return + } if self.lastHash == nil { _ = await self.loadFromGateway() } diff --git a/apps/macos/Sources/OpenClaw/DebugSettings.swift b/apps/macos/Sources/OpenClaw/DebugSettings.swift index 97ce692696c..11be1c4b1e7 100644 --- a/apps/macos/Sources/OpenClaw/DebugSettings.swift +++ b/apps/macos/Sources/OpenClaw/DebugSettings.swift @@ -779,7 +779,10 @@ struct DebugSettings: View { session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed root["session"] = session - OpenClawConfigFile.saveDict(root) + guard OpenClawConfigFile.saveDict(root) else { + self.sessionStoreSaveError = "Config write rejected to protect gateway auth/mode." + return + } self.sessionStoreSaveError = nil } diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 7b80a34bff7..bd3e321f780 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -52,14 +52,16 @@ enum OpenClawConfigFile { } } + @discardableResult static func saveDict( _ dict: [String: Any], preserveExistingKeys: Bool = false, allowGatewayAuthMutation: Bool = false) + -> Bool { self.withFileLock { // Nix mode disables config writes in production, but tests rely on saving temp configs. - if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return false } let url = self.url() let previousData = try? Data(contentsOf: url) let previousRoot = previousData.flatMap { self.parseConfigData($0) } @@ -81,12 +83,7 @@ enum OpenClawConfigFile { do { let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) let nextBytes = data.count - let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) let gatewayModeAfter = self.gatewayMode(output) var suspicious = self.configWriteSuspiciousReasons( existsBefore: previousData != nil, @@ -98,6 +95,44 @@ enum OpenClawConfigFile { if preservedGatewayAuth { suspicious.append("gateway-auth-preserved") } + let blocking = self.configWriteBlockingReasons(suspicious) + if !blocking.isEmpty { + let rejectedPath = self.persistRejectedConfigWrite(data: data, configURL: url) + self.logger.warning("config write rejected (\(blocking.joined(separator: ", "))) at \(url.path)") + self.appendConfigWriteAudit([ + "result": "rejected", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": nextBytes, + "previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(), + "nextDev": NSNull(), + "previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(), + "nextIno": NSNull(), + "previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(), + "nextMode": NSNull(), + "previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(), + "nextNlink": NSNull(), + "previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(), + "nextUid": NSNull(), + "previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(), + "nextGid": NSNull(), + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "preservedGatewayAuth": preservedGatewayAuth, + "suspicious": suspicious, + "blocking": blocking, + "rejectedPath": rejectedPath ?? NSNull(), + ]) + return false + } + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) if !suspicious.isEmpty { self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") } @@ -123,9 +158,11 @@ enum OpenClawConfigFile { "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "preservedGatewayAuth": preservedGatewayAuth, "suspicious": suspicious, ]) self.observeConfigRead(data: data, root: output, configURL: url, valid: true) + return true } catch { self.logger.error("config save failed: \(error.localizedDescription)") self.appendConfigWriteAudit([ @@ -138,9 +175,11 @@ enum OpenClawConfigFile { "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), + "preservedGatewayAuth": preservedGatewayAuth, "suspicious": preservedGatewayAuth ? ["gateway-auth-preserved"] : [], "error": error.localizedDescription, ]) + return false } } } @@ -416,6 +455,12 @@ enum OpenClawConfigFile { return reasons } + private static func configWriteBlockingReasons(_ suspicious: [String]) -> [String] { + suspicious.filter { reason in + reason.hasPrefix("size-drop:") || reason == "gateway-mode-removed" + } + } + private static func configAuditLogURL() -> URL { self.stateDirURL() .appendingPathComponent("logs", isDirectory: true) @@ -594,6 +639,26 @@ enum OpenClawConfigFile { } } + private static func persistRejectedConfigWrite(data: Data, configURL: URL) -> String? { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let url = configURL.deletingLastPathComponent() + .appendingPathComponent("\(configURL.lastPathComponent).rejected.\(self.configTimestampToken(timestamp))") + let fileManager = FileManager() + let privatePermissions: NSNumber = 0o600 + if fileManager.fileExists(atPath: url.path) { + try? fileManager.setAttributes([.posixPermissions: privatePermissions], ofItemAtPath: url.path) + return url.path + } + guard fileManager.createFile( + atPath: url.path, + contents: data, + attributes: [.posixPermissions: privatePermissions]) + else { + return nil + } + return url.path + } + private static func observeConfigRead(data: Data, root: [String: Any]?, configURL: URL, valid: Bool) { let observedAt = ISO8601DateFormatter().string(from: Date()) let current = self.configFingerprint(data: data, root: root, configURL: configURL, observedAt: observedAt) diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index d96f3871809..589f383255f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -259,4 +259,37 @@ struct AppStateRemoteConfigTests { remoteTokenDirty: true)) #expect((cleared["token"] as? String) == nil) } + + @Test + func `synced gateway root preserves gateway auth across mode changes`() { + let initialRoot: [String: Any] = [ + "gateway": [ + "mode": "remote", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + "remote": [ + "transport": "direct", + "url": "wss://old-gateway.example", + ], + ], + ] + + let localRoot = AppState._testSyncedGatewayRoot( + currentRoot: initialRoot, + draft: .init( + connectionMode: .local, + remoteTransport: .ssh, + remoteTarget: "", + remoteIdentity: "", + remoteUrl: "", + remoteToken: "", + remoteTokenDirty: false)) + let localGateway = localRoot["gateway"] as? [String: Any] + let auth = localGateway?["auth"] as? [String: Any] + #expect(localGateway?["mode"] as? String == "local") + #expect(auth?["mode"] as? String == "token") + #expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift index b3ad56d71a1..586c06c4217 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import OpenClaw @@ -65,4 +66,76 @@ struct ConfigStoreTests { #expect(localHit) #expect(!remoteHit) } + + @Test func `local save does not fall back to direct write after stale gateway rejection`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + ], + ]) + let before = try String(contentsOf: configPath, encoding: .utf8) + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { false }, + saveGateway: { _ in + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "config changed since last load; re-run config.get and retry", + ]) + })) + + var didThrow = false + do { + try await ConfigStore.save(["browser": ["enabled": false]]) + } catch { + didThrow = true + } + await ConfigStore._testClearOverrides() + + #expect(didThrow) + let after = try String(contentsOf: configPath, encoding: .utf8) + #expect(after == before) + } + } + + @Test func `local save can fall back to protected direct write when gateway is unavailable`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { false }, + saveGateway: { _ in + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "gateway not configured", + ]) + })) + try await ConfigStore.save([ + "gateway": ["mode": "local"], + "browser": ["enabled": false], + ]) + await ConfigStore._testClearOverrides() + + let data = try Data(contentsOf: configPath) + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(((root?["browser"] as? [String: Any])?["enabled"] as? Bool) == false) + #expect((root?["meta"] as? [String: Any]) != nil) + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 018626be884..1b384b37954 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -336,4 +336,118 @@ struct OpenClawConfigFileTests { } } } + + @MainActor + @Test + func `save dict records preserved gateway auth in audit`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") + + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + ], + ]) + + let saved = OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + ], + "browser": [ + "enabled": false, + ], + ]) + + #expect(saved) + let data = try Data(contentsOf: configPath) + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let gateway = root?["gateway"] as? [String: Any] + let auth = gateway?["auth"] as? [String: Any] + #expect(gateway?["mode"] as? String == "local") + #expect(auth?["mode"] as? String == "token") + #expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret + #expect((root?["meta"] as? [String: Any]) != nil) + + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let last = rawAudit.split(whereSeparator: \.isNewline).map(String.init).last + let auditRoot = try JSONSerialization.jsonObject(with: Data((last ?? "{}").utf8)) as? [String: Any] + #expect(auditRoot?["result"] as? String == "success") + #expect(auditRoot?["preservedGatewayAuth"] as? Bool == true) + let suspicious = auditRoot?["suspicious"] as? [String] ?? [] + #expect(suspicious.contains("gateway-auth-preserved")) + } + } + + @MainActor + @Test + func `save dict rejects gateway mode removal and keeps previous config`() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") + + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + "auth": [ + "mode": "token", + "token": "test-token", // pragma: allowlist secret + ], + ], + "browser": [ + "enabled": true, + ], + ]) + let before = try String(contentsOf: configPath, encoding: .utf8) + + let saved = OpenClawConfigFile.saveDict([ + "browser": [ + "enabled": false, + ], + ]) + + #expect(!saved) + let after = try String(contentsOf: configPath, encoding: .utf8) + #expect(after == before) + + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let lines = rawAudit.split(whereSeparator: \.isNewline).map(String.init) + guard let last = lines.last else { + Issue.record("Missing rejected config audit line") + return + } + let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any] + #expect(auditRoot?["result"] as? String == "rejected") + let suspicious = auditRoot?["suspicious"] as? [String] ?? [] + let blocking = auditRoot?["blocking"] as? [String] ?? [] + #expect(suspicious.contains("gateway-mode-removed")) + #expect(blocking.contains("gateway-mode-removed")) + if let rejectedPath = auditRoot?["rejectedPath"] as? String { + #expect(FileManager().fileExists(atPath: rejectedPath)) + let attributes = try FileManager().attributesOfItem(atPath: rejectedPath) + let mode = attributes[.posixPermissions] as? NSNumber + #expect(mode?.intValue == 0o600) + } else { + Issue.record("Missing rejected payload path") + } + } + } } diff --git a/docs/channels/discord.md b/docs/channels/discord.md index aec9b12eaba..e27675fa46a 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1172,6 +1172,7 @@ Auto-join example: discord: { voice: { enabled: true, + mode: "stt-tts", model: "openai/gpt-5.4-mini", autoJoin: [ { @@ -1199,8 +1200,10 @@ Auto-join example: Notes: - `voice.tts` overrides `messages.tts` for voice playback only. -- `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model. Do not set this to `gpt-realtime-2`; Discord voice channels use STT plus TTS playback, not the OpenAI Realtime session transport. -- STT uses `tools.media.audio`; `voice.model` does not affect transcription. +- `voice.mode` controls the conversation path: `stt-tts` keeps the existing batch STT plus TTS flow, `talk-buffer` uses a realtime voice shell for turn timing/transcription/playback while the OpenClaw agent produces the answer, and `bidi` lets the realtime model converse directly while exposing `openclaw_agent_consult` for the OpenClaw brain. +- `voice.model` overrides the OpenClaw agent brain for Discord voice responses and realtime consults. Leave it unset to inherit the routed agent model. It is separate from `voice.realtime.model`. +- In `stt-tts` mode, STT uses `tools.media.audio`; `voice.model` does not affect transcription. +- In realtime modes, `voice.realtime.provider`, `voice.realtime.model`, and `voice.realtime.voice` configure the realtime audio session. For OpenAI Realtime 2 plus the Codex brain, use `voice.realtime.model: "gpt-realtime-2"` and `voice.model: "openai-codex/gpt-5.5"`. - For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.openai.voice` or `voice.tts.providers.openai.voice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model. - Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel. - Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`). @@ -1211,7 +1214,7 @@ Notes: - `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. - `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`. - `voice.reconnectGraceMs` controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: `15000`. -- Voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn. +- In `stt-tts` mode, voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn. Realtime modes forward speaker starts as barge-in signals to the realtime provider. - `voice.captureSilenceGraceMs` controls how long OpenClaw waits after Discord reports a speaker has stopped before finalizing that audio segment for STT. Default: `2500`; raise this if Discord splits normal pauses into choppy partial transcripts. - When ElevenLabs is the selected TTS provider, Discord voice playback uses streaming TTS and starts from the provider response stream. Providers without streaming support fall back to the synthesized temp-file path. - OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. @@ -1219,7 +1222,7 @@ Notes: - `The operation was aborted` receive events are expected when OpenClaw finalizes a captured speaker segment; they are verbose diagnostics, not warnings. - Verbose Discord voice logs include a bounded one-line STT transcript preview for each accepted speaker segment, so debugging shows both the user side and the agent reply side without dumping unbounded transcript text. -Voice channel pipeline: +STT plus TTS pipeline: - Discord PCM capture is converted to a WAV temp file. - `tools.media.audio` handles STT, for example `openai/gpt-4o-mini-transcribe`. @@ -1227,7 +1230,51 @@ Voice channel pipeline: - `voice.model`, when set, overrides only the response LLM for this voice-channel turn. - `voice.tts` is merged over `messages.tts`; streaming-capable providers feed the player directly, otherwise the resulting audio file is played in the joined channel. -Credentials are resolved per component: LLM route auth for `voice.model`, STT auth for `tools.media.audio`, and TTS auth for `messages.tts`/`voice.tts`. +Realtime talk-buffer example: + +```json5 +{ + channels: { + discord: { + voice: { + enabled: true, + mode: "talk-buffer", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + }, + }, + }, + }, +} +``` + +Realtime bidi example: + +```json5 +{ + channels: { + discord: { + voice: { + enabled: true, + mode: "bidi", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }, + }, +} +``` + +Credentials are resolved per component: LLM route auth for `voice.model`, STT auth for `tools.media.audio`, TTS auth for `messages.tts`/`voice.tts`, and realtime provider auth for `voice.realtime.providers` or the provider's normal auth config. ### Voice messages diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md index de5d8005bca..c16cb3f42a0 100644 --- a/docs/channels/imessage-from-bluebubbles.md +++ b/docs/channels/imessage-from-bluebubbles.md @@ -92,7 +92,7 @@ iMessage and BlueBubbles share a lot of channel-level config. The keys that chan | `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. | | `channels.bluebubbles.groups` | `channels.imessage.groups` | **Copy this verbatim, including any `groups: { "*": { ... } }` wildcard entry.** Per-group `requireMention`, `tools`, `toolsBySender` carry over. With `groupPolicy: "allowlist"`, an empty or missing `groups` block silently drops every group message — see "Group registry footgun" below. | | `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. | -| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same. | +| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same shape, **same off-by-default**. If you had attachments flowing on BlueBubbles you must re-set this explicitly on the iMessage block — it does not carry over implicitly, and inbound photos/media will be silently dropped with no `Inbound message` log line until you do. | | `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. | | _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. | | `channels.bluebubbles.mediaMaxMb` | `channels.imessage.mediaMaxMb` | Default 16 MB on iMessage (BlueBubbles default was 8 MB). Set explicitly if you want to keep the lower cap. | diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 5071b8132c3..6a59f0b37d7 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -403,7 +403,7 @@ See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior. - - inbound attachment ingestion is optional: `channels.imessage.includeAttachments` + - inbound attachment ingestion is **off by default** — set `channels.imessage.includeAttachments: true` to forward photos, voice memos, video, and other attachments to the agent. With it disabled, attachment-only iMessages are dropped before reaching the agent and may produce no `Inbound message` log line at all. - remote attachment paths can be fetched via SCP when `remoteHost` is set - attachment paths must match allowed roots: - `channels.imessage.attachmentRoots` (local) diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 09f1ad3006d..70044eaa411 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -49,6 +49,7 @@ Quick rule: | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | | Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | +| Exec approvals | Partial | Gateway exec approval prompts during active ACP prompt turns are relayed to the ACP client with `session/request_permission`. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | @@ -76,6 +77,8 @@ Quick rule: - Tool follow-along data is best-effort. The bridge can surface file paths that appear in known tool args/results, but it does not yet emit ACP terminals or structured file diffs. +- Exec approval relay is scoped to the active ACP prompt turn; approvals from + other Gateway sessions are ignored. ## Usage diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index ded9c3bb3aa..8d3844e32cd 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -332,12 +332,16 @@ flowchart LR I --> M["Main Reply"] ``` -The blocking memory sub-agent can use only the available memory recall tools: +The blocking memory sub-agent can use only the configured memory recall tools. +By default that is: -- `memory_recall` - `memory_search` - `memory_get` +When `plugins.slots.memory` is `memory-lancedb`, the default is `memory_recall` +instead. Set `config.toolsAllow` when another memory provider exposes a +different recall tool contract. + If the connection is weak, it should return `NONE`. ## Query modes @@ -462,6 +466,110 @@ skips recall for that turn. `config.modelFallbackPolicy` is retained only as a deprecated compatibility field for older configs. It no longer changes runtime behavior. +## Memory tools + +By default Active Memory lets the blocking recall sub-agent call +`memory_search` and `memory_get`. That matches the built-in `memory-core` +contract. When `plugins.slots.memory` selects `memory-lancedb` and +`config.toolsAllow` is unset, Active Memory keeps the existing LanceDB behavior +and uses `memory_recall` instead. + +If you use another memory plugin, set `config.toolsAllow` to the exact tool +names that plugin registers. Active Memory lists those tools in the recall +prompt and passes the same list to the embedded sub-agent. If none of the +configured tools are available, or the memory sub-agent fails, Active Memory +skips recall for that turn and the main reply continues without memory context. +`toolsAllow` only accepts concrete memory tool names. Wildcards, `group:*` +entries, and core agent tools such as `read`, `exec`, `message`, and +`web_search` are ignored before the hidden memory sub-agent starts. + +Default-behavior note: Active Memory no longer includes `memory_recall` in the +memory-core default allowlist. Existing `memory-lancedb` setups keep working +when `plugins.slots.memory` is set to `memory-lancedb`. Explicit `toolsAllow` +always overrides the automatic default. + +### Built-in memory-core + +The default setup does not need an explicit `toolsAllow`: + +```json5 +{ + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { + agents: ["main"], + // Default: ["memory_search", "memory_get"] + }, + }, + }, + }, +} +``` + +### LanceDB memory + +The bundled `memory-lancedb` plugin exposes `memory_recall`. Selecting the +memory slot is enough for Active Memory to use that recall tool: + +```json5 +{ + plugins: { + slots: { + memory: "memory-lancedb", + }, + entries: { + "memory-lancedb": { + enabled: true, + config: { + embedding: { + provider: "openai", + model: "text-embedding-3-small", + }, + }, + }, + "active-memory": { + enabled: true, + config: { + agents: ["main"], + promptAppend: "Use memory_recall for long-term user preferences, past decisions, and previously discussed topics. If recall finds nothing useful, return NONE.", + }, + }, + }, + }, +} +``` + +### Lossless Claw + +Lossless Claw is a context-engine plugin with its own recall tools. Install and +configure it as a context engine first; see [Context engine](/concepts/context-engine). +Then let Active Memory use the Lossless Claw recall tools: + +```json5 +{ + plugins: { + entries: { + "lossless-claw": { + enabled: true, + }, + "active-memory": { + enabled: true, + config: { + agents: ["main"], + toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + promptAppend: "Use lcm_grep first for compacted conversation recall. Use lcm_describe to inspect a specific summary. Use lcm_expand_query only when the latest user message needs exact details that may have been compacted away. Return NONE if the retrieved context is not clearly useful.", + }, + }, + }, + }, +} +``` + +Do not include `lcm_expand` in `toolsAllow` for the main Active Memory sub-agent. +Lossless Claw uses that as a lower-level delegated expansion tool. + ## Advanced escape hatches These options are intentionally not part of the recommended setup. @@ -488,6 +596,9 @@ Memory prompt and before the conversation context: promptAppend: "Prefer stable long-term preferences over one-off events." ``` +Use `promptAppend` with custom `toolsAllow` when a non-core memory plugin needs +provider-specific tool order or query-shaping instructions. + `config.promptOverride` replaces the default Active Memory prompt. OpenClaw still appends the conversation context afterward: @@ -558,25 +669,26 @@ plugins.entries.active-memory The most important fields are: -| Key | Type | Meaning | -| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Enables the plugin itself | -| `config.agents` | `string[]` | Agent ids that may use active memory | -| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model | -| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions | -| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed | -| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids | -| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees | -| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory | -| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed | -| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use | -| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt | -| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms | -| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance | -| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary | -| `config.logging` | `boolean` | Emits active memory logs while tuning | -| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files | -| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder | +| Key | Type | Meaning | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Enables the plugin itself | +| `config.agents` | `string[]` | Agent ids that may use active memory | +| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model | +| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions | +| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed | +| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids | +| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees | +| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory | +| `config.toolsAllow` | `string[]` | Concrete memory tool names the blocking memory sub-agent may call; defaults to `["memory_search", "memory_get"]`, or `["memory_recall"]` when `plugins.slots.memory` is `memory-lancedb`; wildcards, `group:*` entries, and core agent tools are ignored | +| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed | +| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use | +| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt | +| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms | +| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance | +| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary | +| `config.logging` | `boolean` | Emits active memory logs while tuning | +| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files | +| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder | Useful tuning fields: @@ -692,8 +804,9 @@ If active memory is too slow: Active Memory rides on the configured memory plugin's recall pipeline, so most recall surprises are embedding-provider problems, not Active Memory bugs. The -default `memory-core` path uses `memory_search`; `memory-lancedb` uses -`memory_recall`. +default `memory-core` path uses `memory_search` and `memory_get`; the +`memory-lancedb` slot uses `memory_recall`. If you use another memory plugin, +confirm `config.toolsAllow` names the tools that plugin actually registers. diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index 56757fde9ee..3565c7f50be 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -278,7 +278,7 @@ Optional: - `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1` keeps message bodies in observed-message artifacts (default redacts). -Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts:44`): +Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts`): - `telegram-canary` - `telegram-mention-gating` @@ -287,10 +287,17 @@ Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime - `telegram-commands-command` - `telegram-tools-compact-command` - `telegram-whoami-command` +- `telegram-status-command` +- `telegram-other-bot-command-gating` - `telegram-context-command` +- `telegram-current-session-status-tool` +- `telegram-reply-chain-exact-marker` +- `telegram-stream-final-single-message` - `telegram-long-final-reuses-preview` - `telegram-long-final-three-chunks` +The implicit default set always covers canary, mention gating, native command replies, command addressing, and bot-to-bot group replies. `mock-openai` defaults also include deterministic reply-chain and final-message streaming checks. `telegram-current-session-status-tool` remains opt-in because it is only stable when threaded directly after canary, not after arbitrary native command replies. Use `pnpm openclaw qa telegram --list-scenarios --provider-mode mock-openai` to print the current default/optional split with regression refs. + Output artifacts: - `telegram-qa-report.md` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b2c722d8ca7..e5d869b3a05 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -510,6 +510,10 @@ See [Inferred commitments](/concepts/commitments). value, so repeated failures from one localhost origin do not automatically lock out a different origin. - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). +- `tailscale.preserveFunnel`: when `true` and `tailscale.mode = "serve"`, OpenClaw + checks `tailscale funnel status` before re-applying Serve at startup and skips + it if an externally configured Funnel route already covers the gateway port. + Default `false`. - `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. - `controlUi.chatMessageMaxWidth`: optional max-width for grouped Control UI chat messages. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index a1a3a69240a..93c58ed5270 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -116,6 +116,11 @@ openclaw gateway --tailscale funnel --auth password - `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure. - Set `gateway.tailscale.resetOnExit` if you want OpenClaw to undo `tailscale serve` or `tailscale funnel` configuration on shutdown. +- Set `gateway.tailscale.preserveFunnel: true` to keep an externally configured + `tailscale funnel` route alive across gateway restarts. When enabled and the + gateway runs in `mode: "serve"`, OpenClaw checks `tailscale funnel status` + before re-applying Serve and skips it when a Funnel route already covers the + gateway port. The OpenClaw-managed Funnel password-only policy is unchanged. - `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel). - `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only. - Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over diff --git a/docs/help/testing.md b/docs/help/testing.md index 9c5df8fe205..6c43e7a004f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -311,6 +311,7 @@ gh workflow run package-acceptance.yml --ref main \ - Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env. - Requires `OPENCLAW_QA_TELEGRAM_GROUP_ID`, `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`, and `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`. The group id must be the numeric Telegram chat id. - Supports `--credential-source convex` for shared pooled credentials. Use env mode by default, or set `OPENCLAW_QA_CREDENTIAL_SOURCE=convex` to opt into pooled leases. + - Defaults cover canary, mention gating, command addressing, `/status`, bot-to-bot mentioned replies, and core native command replies. `mock-openai` defaults also cover deterministic reply-chain and Telegram final-message streaming regressions. Use `--list-scenarios` for optional probes such as `session_status`. - Exits non-zero when any scenario fails. Use `--allow-failures` when you want artifacts without a failing exit code. - Requires two distinct bots in the same private group, with the SUT bot exposing a Telegram username. diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index 217750c342c..f62225610c2 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -60,13 +60,13 @@ OpenClaw dynamically discovers available models from the Kilo Gateway at startup Any model available on the gateway can be used with the `kilocode/` prefix: -| Model ref | Notes | -| -------------------------------------- | ---------------------------------- | -| `kilocode/kilo/auto` | Default — smart routing | -| `kilocode/anthropic/claude-sonnet-4` | Anthropic via Kilo | -| `kilocode/openai/gpt-5.5` | OpenAI via Kilo | -| `kilocode/google/gemini-3-pro-preview` | Google via Kilo | -| ...and many more | Use `/models kilocode` to list all | +| Model ref | Notes | +| ---------------------------------------- | ---------------------------------- | +| `kilocode/kilo/auto` | Default — smart routing | +| `kilocode/anthropic/claude-sonnet-4` | Anthropic via Kilo | +| `kilocode/openai/gpt-5.5` | OpenAI via Kilo | +| `kilocode/google/gemini-3.1-pro-preview` | Google via Kilo | +| ...and many more | Use `/models kilocode` to list all | At startup, OpenClaw queries `GET https://api.kilo.ai/api/gateway/models` and merges diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 07735a3a15b..f0a32073f1e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -125,7 +125,7 @@ Current source-of-truth: - `/new [model]` starts a new session; `/reset` is the reset alias. - - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session; typed `/reset` still runs the Gateway's in-place reset. + - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case `/new` resets the main session in place. Typed `/reset` still runs the Gateway's in-place reset. - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). - `/stop` aborts the current run. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 1ad5e01eac3..0968bf25ea4 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -106,7 +106,7 @@ title: "Thinking levels" - `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning. - Alias: `/reason`. - Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level. -- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then fallback (`off`). +- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then global default (`agents.defaults.reasoningDefault`), then fallback (`off`). Malformed local-model reasoning tags are handled conservatively. Closed `...` blocks stay hidden on normal replies, and unclosed reasoning after already visible text is also hidden. If a reply is fully wrapped in a single unclosed opening tag and would otherwise deliver as empty text, OpenClaw removes the malformed opening tag and delivers the remaining text. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 8a9edd50643..ce014cb893b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -165,7 +165,7 @@ Imported themes are stored only in the current browser profile. They are not wri - Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model. - - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. + - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. - When fresh Gateway session usage reports include current context tokens, the chat composer area shows a compact context usage indicator. It switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again. diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 18176d39259..f2d926a93f8 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -69,7 +69,11 @@ function expectWrapperToContainPathSuffix(wrapper: string, pathSuffix: string[]) const nativeSuffix = pathSuffix.join(path.sep); const escapedNativeSuffix = JSON.stringify(nativeSuffix).slice(1, -1); const posixSuffix = pathSuffix.join("/"); - expect(wrapper.includes(escapedNativeSuffix) || wrapper.includes(posixSuffix)).toBe(true); + if (wrapper.includes(escapedNativeSuffix)) { + expect(wrapper).toContain(escapedNativeSuffix); + } else { + expect(wrapper).toContain(posixSuffix); + } } afterEach(async () => { diff --git a/extensions/acpx/src/manifest.test.ts b/extensions/acpx/src/manifest.test.ts index 3e4a84ca107..57a1051714d 100644 --- a/extensions/acpx/src/manifest.test.ts +++ b/extensions/acpx/src/manifest.test.ts @@ -12,7 +12,8 @@ describe("acpx package manifest", () => { fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as AcpxPackageManifest; - expect(packageJson.dependencies?.acpx).toEqual(expect.any(String)); + expect(packageJson.dependencies?.acpx).toBeTypeOf("string"); + expect(packageJson.dependencies?.acpx).not.toBe(""); expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.13.0"); expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.32.0"); expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined(); diff --git a/extensions/acpx/src/process-reaper.test.ts b/extensions/acpx/src/process-reaper.test.ts index 80e6775e1be..84164ba3b67 100644 --- a/extensions/acpx/src/process-reaper.test.ts +++ b/extensions/acpx/src/process-reaper.test.ts @@ -28,6 +28,20 @@ function cleanupDeps(processes: AcpxProcessInfo[]) { }; } +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + describe("process reaper", () => { it("recognizes generated Codex and Claude wrappers only under the configured root", () => { expect( @@ -237,9 +251,13 @@ describe("process reaper", () => { expect(result.skippedReason).toBeUndefined(); expect(result.inspectedPids).toEqual([400, 401, 402, 403, 404, 405]); - expect(killed.filter((entry) => entry.signal === "SIGTERM").map((entry) => entry.pid)).toEqual([ - 402, 401, 400, 404, 403, 405, - ]); + expect( + collectMatching( + killed, + (entry) => entry.signal === "SIGTERM", + (entry) => entry.pid, + ), + ).toEqual([402, 401, 400, 404, 403, 405]); }); it("keeps startup scans quiet when process listing is unavailable", async () => { diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index ec5e37d9429..6374610f02f 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -143,8 +143,8 @@ describe("AcpxRuntime fresh reset wrapper", () => { }); it("exposes assertSupportedRuntimeSessionMode as a typed guard", () => { - expect(() => __testing.assertSupportedRuntimeSessionMode("persistent")).not.toThrow(); - expect(() => __testing.assertSupportedRuntimeSessionMode("oneshot")).not.toThrow(); + expect(__testing.assertSupportedRuntimeSessionMode("persistent")).toBeUndefined(); + expect(__testing.assertSupportedRuntimeSessionMode("oneshot")).toBeUndefined(); expect(() => __testing.assertSupportedRuntimeSessionMode("run" as never)).toThrow( AcpRuntimeError, ); diff --git a/extensions/active-memory/config.test.ts b/extensions/active-memory/config.test.ts index 1b9aa512ebd..eabbb7e42f7 100644 --- a/extensions/active-memory/config.test.ts +++ b/extensions/active-memory/config.test.ts @@ -22,6 +22,34 @@ describe("active-memory manifest config schema", () => { expect(result.ok).toBe(true); }); + it("accepts custom toolsAllow entries", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.tools-allow", + value: { + enabled: true, + agents: ["main"], + toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects wildcard and group toolsAllow entries", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.tools-allow.reserved", + value: { + enabled: true, + agents: ["main"], + toolsAllow: ["*", "group:plugins"], + }, + }); + + expect(result.ok).toBe(false); + }); + it("accepts timeoutMs values at the runtime ceiling", () => { const result = validateJsonSchemaValue({ schema: manifest.configSchema, diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index d729e018b46..07ba5d7213f 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -67,6 +67,19 @@ describe("active-memory plugin", () => { }, }; }; + const setMemorySlot = (memory: string) => { + const plugins = configFile.plugins as Record | undefined; + configFile = { + ...configFile, + plugins: { + ...plugins, + slots: { + ...(plugins?.slots as Record | undefined), + memory, + }, + }, + }; + }; const api: any = { get pluginConfig() { return pluginConfig; @@ -117,6 +130,12 @@ describe("active-memory plugin", () => { | undefined; return entries?.find((entry) => entry.pluginId === "active-memory")?.lines ?? []; }; + const expectLinesToContain = (lines: string[], text: string) => { + expect(lines).toEqual(expect.arrayContaining([expect.stringContaining(text)])); + }; + const expectLinesNotToContain = (lines: string[], text: string) => { + expect(lines).not.toEqual(expect.arrayContaining([expect.stringContaining(text)])); + }; const writeTranscriptJsonl = async (sessionFile: string, records: unknown[], suffix = "\n") => { await fs.mkdir(path.dirname(sessionFile), { recursive: true }); await fs.writeFile( @@ -141,7 +160,7 @@ describe("active-memory plugin", () => { }; const makeMemoryToolAllowlistError = ( reason: string, - sources = "runtime toolsAllow: memory_recall, memory_search, memory_get", + sources = "runtime toolsAllow: memory_search, memory_get", ) => new Error( `No callable tools remain after resolving explicit tool allowlist ` + @@ -1279,16 +1298,17 @@ describe("active-memory plugin", () => { ); expect(runParams?.prompt).toContain("Use only the available memory tools."); expect(runParams?.prompt).toContain( - "Use the bounded search query as the memory_search or memory_recall query.", + "Use the bounded search query with the configured memory tools.", ); - expect(runParams?.prompt).toContain("Prefer memory_recall when available."); + expect(runParams?.prompt).toContain("Configured memory tools: memory_search, memory_get."); expect(runParams?.prompt).toContain( - "If memory_recall is unavailable, use memory_search and memory_get.", + "If the available memory tools find nothing useful, reply with NONE.", ); - expect(runParams?.toolsAllow).toEqual(["memory_recall", "memory_search", "memory_get"]); + expect(runParams?.prompt).not.toContain("memory_recall"); + expect(runParams?.toolsAllow).toEqual(["memory_search", "memory_get"]); expect(runParams?.allowGatewaySubagentBinding).toBe(true); expect(runParams?.prompt).toContain( - "When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.", + "When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.", ); expect(runParams?.prompt).toContain( "If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.", @@ -1312,6 +1332,187 @@ describe("active-memory plugin", () => { ); }); + it("passes custom configured memory tools and reflects them in the default prompt", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: [" lcm_grep ", "lcm_describe", "", "lcm_expand_query", "lcm_grep"], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["lcm_grep", "lcm_describe", "lcm_expand_query"]); + expect(runParams?.prompt).toContain( + "Configured memory tools: lcm_grep, lcm_describe, lcm_expand_query.", + ); + expect(runParams?.prompt).not.toContain("Prefer memory_recall"); + expect(runParams?.prompt).not.toContain("If memory_recall is unavailable"); + }); + + it("uses memory_recall by default when the memory slot selects LanceDB", async () => { + setMemorySlot("memory-lancedb"); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["memory_recall"]); + expect(runParams?.prompt).toContain("Configured memory tools: memory_recall."); + }); + + it("keeps explicit custom memory tools authoritative when the memory slot selects LanceDB", async () => { + setMemorySlot("memory-lancedb"); + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["lcm_grep"], + }; + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["lcm_grep"]); + expect(runParams?.prompt).toContain("Configured memory tools: lcm_grep."); + }); + + it("drops wildcard group and core tools from custom memory tools", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: [ + "*", + "agents_list", + "apply_patch", + "canvas", + "cron", + "edit", + "gateway", + "heartbeat_respond", + "heartbeat_response", + "image", + "image_generate", + "music_generate", + "nodes", + "pdf", + "process", + "session_status", + "sessions_history", + "sessions_list", + "sessions_send", + "sessions_spawn", + "sessions_yield", + "tts", + "video_generate", + "group:plugins", + "read", + "exec", + "message", + "lcm_grep", + "web_search", + "lcm_describe", + ], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["lcm_grep", "lcm_describe"]); + expect(runParams?.prompt).toContain("Configured memory tools: lcm_grep, lcm_describe."); + }); + + it("falls back to default memory tools when custom memory tools only contain reserved entries", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["memory_search", "memory_get"]); + expect(runParams?.prompt).toContain("Configured memory tools: memory_search, memory_get."); + }); + + it("falls back to LanceDB compat tools when custom memory tools only contain reserved entries", async () => { + setMemorySlot("memory-lancedb"); + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"], + }; + + await hooks.before_prompt_build( + { + prompt: "What did we decide about active memory?", + messages: [], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]; + expect(runParams?.toolsAllow).toEqual(["memory_recall"]); + expect(runParams?.prompt).toContain("Configured memory tools: memory_recall."); + }); + it("defaults prompt style by query mode when no promptStyle is configured", async () => { api.pluginConfig = { agents: ["main"], @@ -1865,7 +2066,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasDebugLine("no configured memory tools available")).toBe(true); expect(hasWarnLine("No callable tools remain")).toBe(false); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=empty")]); @@ -1880,7 +2081,7 @@ describe("active-memory plugin", () => { }; const error = makeMemoryToolAllowlistError( "no registered tools matched", - "tools.allow: *, lobster; runtime toolsAllow: memory_recall, memory_search, memory_get", + "tools.allow: *, lobster; runtime toolsAllow: memory_search, memory_get", ); expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); runEmbeddedPiAgent.mockRejectedValueOnce(error); @@ -1891,14 +2092,46 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasDebugLine("no configured memory tools available")).toBe(true); expect(hasWarnLine("No callable tools remain")).toBe(false); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=empty"), ]); }); - it("keeps memory-tool allowlist errors visible when upstream policy can filter memory tools", async () => { + it("skips missing custom memory tools using the resolved custom allowlist", async () => { + api.pluginConfig = { + agents: ["main"], + toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + logging: true, + }; + plugin.register(api as unknown as OpenClawPluginApi); + const sessionKey = "agent:main:missing-custom-memory-tools"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-custom-memory-tools", + updatedAt: 0, + }; + const toolsAllow = ["lcm_grep", "lcm_describe", "lcm_expand_query"]; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + `runtime toolsAllow: ${toolsAllow.join(", ")}`, + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error, toolsAllow)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what did we decide? missing custom memory tools", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no configured memory tools available")).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=empty"), + ]); + }); + + it("skips memory-tool allowlist errors when upstream policy filters memory tools", async () => { const sessionKey = "agent:main:memory-tools-filtered-by-policy"; hoisted.sessionStore[sessionKey] = { sessionId: "s-memory-tools-filtered-by-policy", @@ -1906,9 +2139,9 @@ describe("active-memory plugin", () => { }; const error = makeMemoryToolAllowlistError( "no registered tools matched", - "tools.allow: read, exec; runtime toolsAllow: memory_recall, memory_search, memory_get", + "tools.allow: read, exec; runtime toolsAllow: memory_search, memory_get", ); - expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); runEmbeddedPiAgent.mockRejectedValueOnce(error); const result = await hooks.before_prompt_build( @@ -1917,38 +2150,41 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(false); - expect(hasWarnLine("No callable tools remain")).toBe(true); + expect(hasDebugLine("no configured memory tools available")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=unavailable"), + expect.stringContaining("🧩 Active Memory: status=empty"), ]); }); it.each([ ["disabled tools", "tools are disabled for this run"], ["models without tool support", "the selected model does not support tools"], - ])("keeps allowlist errors for %s visible", async (_label, reason) => { - const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; - hoisted.sessionStore[sessionKey] = { - sessionId: `s-${reason.replace(/\W+/g, "-")}`, - updatedAt: 0, - }; - const error = makeMemoryToolAllowlistError(reason); - expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); - runEmbeddedPiAgent.mockRejectedValueOnce(error); + ])( + "skips allowlist errors for %s without surfacing to the main thread", + async (_label, reason) => { + const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; + hoisted.sessionStore[sessionKey] = { + sessionId: `s-${reason.replace(/\W+/g, "-")}`, + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError(reason); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); - const result = await hooks.before_prompt_build( - { prompt: `what wings should i order? ${reason}`, messages: [] }, - { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, - ); + const result = await hooks.before_prompt_build( + { prompt: `what wings should i order? ${reason}`, messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); - expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(false); - expect(hasWarnLine(reason)).toBe(true); - expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=unavailable"), - ]); - }); + expect(result).toBeUndefined(); + expect(hasDebugLine("no configured memory tools available")).toBe(false); + expect(hasWarnLine(reason)).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=empty"), + ]); + }, + ); it("does not skip missing memory-tool allowlist errors after abort", async () => { const sessionKey = "agent:main:missing-memory-tools-after-abort"; @@ -1970,7 +2206,7 @@ describe("active-memory plugin", () => { ); expect(result).toBeUndefined(); - expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasDebugLine("no configured memory tools available")).toBe(false); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=timeout"), ]); @@ -2122,7 +2358,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]); - expect(lines.some((line) => line.includes("timeout_partial"))).toBe(false); + expectLinesNotToContain(lines, "timeout_partial"); }); it("keeps timeout status when the timeout transcript path does not exist", async () => { @@ -2152,7 +2388,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]); - expect(lines.some((line) => line.includes("timeout_partial"))).toBe(false); + expectLinesNotToContain(lines, "timeout_partial"); }); it("does not inject embedded timeout boilerplate from partial transcripts", async () => { @@ -2197,8 +2433,8 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]); - expect(lines.some((line) => line.includes("timeout_partial"))).toBe(false); - expect(lines.some((line) => line.includes("LLM request timed out"))).toBe(false); + expectLinesNotToContain(lines, "timeout_partial"); + expectLinesNotToContain(lines, "LLM request timed out"); }); it("returns partial transcript text when an aborted subagent rejects before the race timeout wins", async () => { @@ -2252,7 +2488,7 @@ describe("active-memory plugin", () => { expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain("partial abort summary"); }); - it("keeps generic subagent errors unavailable without using partial transcript output", async () => { + it("skips generic subagent errors without using partial transcript output", async () => { api.pluginConfig = { agents: ["main"], persistTranscripts: true, @@ -2281,7 +2517,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=unavailable"), + expect.stringContaining("🧩 Active Memory: status=empty"), ]); expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain( "must not be surfaced from generic errors", @@ -2521,7 +2757,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false); + expectLinesNotToContain(infoLines, " cached "); }); it("does not share cached recall results across session-id-only contexts", async () => { @@ -2554,7 +2790,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false); + expectLinesNotToContain(infoLines, " cached "); }); it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => { @@ -2589,7 +2825,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true); + expectLinesToContain(infoLines, "status=timeout"); expect( infoLines.some( (line: string) => @@ -2632,7 +2868,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(false); + expectLinesNotToContain(infoLines, "status=timeout"); }); it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => { @@ -2669,7 +2905,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true); + expectLinesToContain(infoLines, "status=timeout"); // Hard deadline: wall-clock time must be near timeoutMs, not 30s. expect(wallClockMs).toBeLessThan(CONFIGURED_TIMEOUT_MS + HARD_DEADLINE_MARGIN_MS); }); @@ -2710,8 +2946,8 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("done status=empty"))).toBe(true); - expect(infoLines.some((line: string) => line.includes("done status=timeout"))).toBe(false); + expectLinesToContain(infoLines, "done status=empty"); + expectLinesNotToContain(infoLines, "done status=timeout"); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=empty"), expect.stringContaining("🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"), @@ -2802,8 +3038,8 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("done status=empty"))).toBe(true); - expect(infoLines.some((line: string) => line.includes("done status=timeout"))).toBe(false); + expectLinesToContain(infoLines, "done status=empty"); + expectLinesNotToContain(infoLines, "done status=timeout"); expect(getActiveMemoryLines(sessionKey)).toEqual([ expect.stringContaining("🧩 Active Memory: status=empty"), expect.stringContaining( @@ -2862,7 +3098,7 @@ describe("active-memory plugin", () => { const warnLines = vi .mocked(api.logger.warn) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(warnLines.some((line: string) => line.includes("before_prompt_build"))).toBe(true); + expectLinesToContain(warnLines, "before_prompt_build"); }); it("honors configured timeoutMs values above the former 60 000 ms ceiling", async () => { @@ -3622,13 +3858,11 @@ describe("active-memory plugin", () => { ), ); expect( - vi - .mocked(api.logger.info) - .mock.calls.some((call: unknown[]) => - String(call[0]).includes(`transcript=${expectedDir}${path.sep}`), - ), - ).toBe(true); - expect(rmSpy.mock.calls.some(([target]) => String(target).startsWith(expectedDir))).toBe(false); + vi.mocked(api.logger.info).mock.calls.map((call: unknown[]) => String(call[0])), + ).toContainEqual(expect.stringContaining(`transcript=${expectedDir}${path.sep}`)); + expect(rmSpy.mock.calls.filter(([target]) => String(target).startsWith(expectedDir))).toEqual( + [], + ); }); it("falls back to the default transcript directory when transcriptDir is unsafe", async () => { @@ -3733,8 +3967,8 @@ describe("active-memory plugin", () => { const lines = (store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0] ?.lines ?? []; - expect(lines.some((line) => line.includes("\u001b"))).toBe(false); - expect(lines.some((line) => line.includes("\r"))).toBe(false); + expectLinesNotToContain(lines, "\u001b"); + expectLinesNotToContain(lines, "\r"); }); it("caps the active-memory cache size and evicts the oldest entries", () => { @@ -3829,7 +4063,7 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect(infoLines.some((line: string) => line.includes("circuit breaker open"))).toBe(true); + expectLinesToContain(infoLines, "circuit breaker open"); }); it("resets circuit breaker after a successful recall", async () => { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index ec14a051546..8bb40fcc138 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -42,7 +42,43 @@ const DEFAULT_QMD_SEARCH_MODE = "search" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3; const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000; -const ACTIVE_MEMORY_TOOL_ALLOWLIST = ["memory_recall", "memory_search", "memory_get"] as const; +const DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW = ["memory_search", "memory_get"] as const; +const LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW = ["memory_recall"] as const; +const MAX_ACTIVE_MEMORY_TOOLS_ALLOW = 32; +const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([ + "*", + "agents_list", + "apply_patch", + "browser", + "canvas", + "cron", + "edit", + "exec", + "gateway", + "heartbeat_respond", + "heartbeat_response", + "image", + "image_generate", + "message", + "music_generate", + "nodes", + "pdf", + "process", + "read", + "session_status", + "sessions_history", + "sessions_list", + "sessions_send", + "sessions_spawn", + "sessions_yield", + "subagents", + "tts", + "update_plan", + "video_generate", + "web_fetch", + "web_search", + "write", +]); const TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; @@ -101,6 +137,7 @@ type ActiveRecallPluginConfig = { | "recall-heavy" | "precision-heavy" | "preference-only"; + toolsAllow?: string[]; promptOverride?: string; promptAppend?: string; timeoutMs?: number; @@ -141,6 +178,7 @@ type ResolvedActiveRecallPluginConfig = { | "recall-heavy" | "precision-heavy" | "preference-only"; + toolsAllow: string[]; promptOverride?: string; promptAppend?: string; timeoutMs: number; @@ -399,6 +437,46 @@ function normalizeChatIdList(value: unknown): string[] { return out; } +function normalizeConfiguredToolsAllow(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const seen = new Set(); + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed || isReservedActiveMemoryToolsAllowEntry(trimmed) || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + out.push(trimmed); + if (out.length >= MAX_ACTIVE_MEMORY_TOOLS_ALLOW) { + break; + } + } + return out.length > 0 ? out : undefined; +} + +function isReservedActiveMemoryToolsAllowEntry(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized.startsWith("group:") || ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW.has(normalized); +} + +function resolveDefaultToolsAllow(cfg: OpenClawConfig | undefined): string[] { + return cfg?.plugins?.slots?.memory === "memory-lancedb" + ? [...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW] + : [...DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW]; +} + +function resolveToolsAllow(params: { pluginToolsAllow: unknown; cfg?: OpenClawConfig }): string[] { + return ( + normalizeConfiguredToolsAllow(params.pluginToolsAllow) ?? resolveDefaultToolsAllow(params.cfg) + ); +} + function normalizePromptConfigText(value: unknown): string | undefined { const text = typeof value === "string" ? value.trim() : ""; return text ? text : undefined; @@ -445,6 +523,13 @@ function resolvePersistentTranscriptBaseDir(api: OpenClawPluginApi, agentId: str ); } +function requireTransientWorkspaceDir(tempDir: string | undefined): string { + if (!tempDir) { + throw new Error("Active memory transient workspace was not initialized."); + } + return tempDir; +} + function resolveCanonicalSessionKeyFromSessionId(params: { api: OpenClawPluginApi; agentId: string; @@ -497,7 +582,14 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } -function isMissingRegisteredMemoryToolsError(error: unknown): boolean { +function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string { + return `runtime toolsAllow: ${toolsAllow.join(", ")}`; +} + +function isMissingRegisteredMemoryToolsError( + error: unknown, + toolsAllow: readonly string[] = DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW, +): boolean { if (!(error instanceof Error)) { return false; } @@ -509,24 +601,12 @@ function isMissingRegisteredMemoryToolsError(error: unknown): boolean { return false; } const sources = message.slice(prefix.length, -suffix.length); - const runtimeSource = `runtime toolsAllow: ${ACTIVE_MEMORY_TOOL_ALLOWLIST.join(", ")}`; + const runtimeSource = formatRuntimeToolsAllowSource(toolsAllow); const sourceParts = sources .split(";") .map((source) => source.trim()) .filter(Boolean); - if (!sourceParts.includes(runtimeSource)) { - return false; - } - return sourceParts.every((source) => { - if (source === runtimeSource) { - return true; - } - const entries = source - .slice(source.indexOf(":") + 1) - .split(",") - .map((entry) => entry.trim()); - return entries.includes("*"); - }); + return sourceParts.includes(runtimeSource); } function resolveRecallRunChannelContext(params: { @@ -791,7 +871,10 @@ function requiresAdminToMutateActiveMemoryGlobal(gatewayClientScopes?: readonly const ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT = "⚠️ /active-memory global enable/disable changes require operator.admin for gateway clients."; -function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig { +function normalizePluginConfig( + pluginConfig: unknown, + cfg?: OpenClawConfig, +): ResolvedActiveRecallPluginConfig { const raw = ( pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {} ) as ActiveRecallPluginConfig; @@ -819,6 +902,7 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi deniedChatIds: normalizeChatIdList(raw.deniedChatIds), thinking: resolveThinkingLevel(raw.thinking), promptStyle: resolvePromptStyle(raw.promptStyle, raw.queryMode), + toolsAllow: resolveToolsAllow({ pluginToolsAllow: raw.toolsAllow, cfg }), promptOverride: normalizePromptConfigText(raw.promptOverride), promptAppend: normalizePromptConfigText(raw.promptAppend), timeoutMs: clampInt( @@ -990,11 +1074,11 @@ function buildRecallPrompt(params: { "Your job is to search memory and return only the most relevant memory context for that model.", "You receive a bounded search query plus conversation context, including the user's latest message.", "Use only the available memory tools.", - "Use the bounded search query as the memory_search or memory_recall query.", + "Use the bounded search query with the configured memory tools.", + `Configured memory tools: ${params.config.toolsAllow.join(", ")}.`, "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", - "Prefer memory_recall when available.", - "If memory_recall is unavailable, use memory_search and memory_get.", - "When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.", + "If the available memory tools find nothing useful, reply with NONE.", + "When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.", "Do not answer the user directly.", `Prompt style: ${params.config.promptStyle}.`, ...buildPromptStyleLines(params.config.promptStyle), @@ -2398,9 +2482,10 @@ async function runRecallSubagent(params: { params.config.transcriptDir, ) : undefined; - const sessionFile = params.config.persistTranscripts - ? path.join(persistedDir!, `${subagentSessionId}.jsonl`) - : path.join(tempDir!, "session.jsonl"); + const sessionFile = + persistedDir !== undefined + ? path.join(persistedDir, `${subagentSessionId}.jsonl`) + : path.join(requireTransientWorkspaceDir(tempDir), "session.jsonl"); params.onSessionFile?.(sessionFile); if (persistedDir) { await fs.mkdir(persistedDir, { recursive: true, mode: 0o700 }); @@ -2439,7 +2524,7 @@ async function runRecallSubagent(params: { timeoutMs: embeddedTimeoutMs, runId: subagentSessionId, trigger: "manual", - toolsAllow: [...ACTIVE_MEMORY_TOOL_ALLOWLIST], + toolsAllow: [...params.config.toolsAllow], disableMessageTool: true, allowGatewaySubagentBinding: true, bootstrapContextMode: "lightweight", @@ -2482,9 +2567,19 @@ async function runRecallSubagent(params: { const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined; attachPartialTimeoutData(error, partialReply, searchDebug); } - if (!params.abortSignal?.aborted && isMissingRegisteredMemoryToolsError(error)) { + if ( + !params.abortSignal?.aborted && + isMissingRegisteredMemoryToolsError(error, params.config.toolsAllow) + ) { params.api.logger.debug?.( - `active-memory: no memory tools registered (memory-core or memory-lancedb required); skipping sub-agent`, + `active-memory: no configured memory tools available; skipping sub-agent`, + ); + return { rawReply: "NONE" }; + } + if (!params.abortSignal?.aborted) { + const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error)); + params.api.logger.warn?.( + `active-memory: memory sub-agent failed, skipping recall: ${message}`, ); return { rawReply: "NONE" }; } @@ -2751,10 +2846,10 @@ async function maybeResolveActiveRecall(params: { } const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error)); if (params.config.logging) { - params.api.logger.warn?.(`${logPrefix} failed error=${message}`); + params.api.logger.warn?.(`${logPrefix} failed error=${message}; skipping recall`); } const result: ActiveRecallResult = { - status: "unavailable", + status: "empty", elapsedMs: Date.now() - startedAt, summary: null, }; @@ -2777,7 +2872,17 @@ export default definePluginEntry({ name: "Active Memory", description: "Proactively surfaces relevant memory before eligible conversational replies.", register(api: OpenClawPluginApi) { - let config = normalizePluginConfig(api.pluginConfig); + const readCurrentConfig = (): OpenClawConfig | undefined => { + try { + return ( + (api.runtime.config?.current?.() as OpenClawConfig | undefined) ?? + (api.config as OpenClawConfig | undefined) + ); + } catch { + return api.config as OpenClawConfig | undefined; + } + }; + let config = normalizePluginConfig(api.pluginConfig, readCurrentConfig()); const warnDeprecatedModelFallbackPolicy = (pluginConfig: unknown) => { if (hasDeprecatedModelFallbackPolicy(pluginConfig)) { // Wording matters here: the previous text ("set config.modelFallback @@ -2805,7 +2910,7 @@ export default definePluginEntry({ "active-memory", api.pluginConfig as Record, ); - config = normalizePluginConfig(livePluginConfig ?? { enabled: false }); + config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig()); if (livePluginConfig) { warnDeprecatedModelFallbackPolicy(livePluginConfig); } diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index a19b28a9820..cfcc47b1de3 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -56,6 +56,14 @@ "preference-only" ] }, + "toolsAllow": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!\\*$)(?![Gg][Rr][Oo][Uu][Pp]:).+" + }, + "maxItems": 32 + }, "promptOverride": { "type": "string" }, "promptAppend": { "type": "string" }, "maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 }, @@ -129,6 +137,10 @@ "label": "Prompt Style", "help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory." }, + "toolsAllow": { + "label": "Allowed Memory Tools", + "help": "Advanced: tool names the blocking memory sub-agent may use. Defaults to memory_search and memory_get, or memory_recall when plugins.slots.memory selects memory-lancedb; configure this for other non-core memory providers. Wildcards, group entries, and core agent tools are ignored." + }, "thinking": { "label": "Thinking Override", "help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed." diff --git a/extensions/amazon-bedrock-mantle/discovery.test.ts b/extensions/amazon-bedrock-mantle/discovery.test.ts index 484028b2e30..dbb1371ccdd 100644 --- a/extensions/amazon-bedrock-mantle/discovery.test.ts +++ b/extensions/amazon-bedrock-mantle/discovery.test.ts @@ -398,11 +398,12 @@ describe("bedrock mantle discovery", () => { fetchFn: mockFetch as unknown as typeof fetch, }); - expect(provider).not.toBeNull(); - expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1"); - expect(provider?.api).toBe("openai-completions"); - expect(provider?.auth).toBe("api-key"); - expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK"); + expect(provider).toMatchObject({ + baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "env:AWS_BEARER_TOKEN_BEDROCK", + }); expect(provider?.models).toHaveLength(2); expect( provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"), @@ -447,8 +448,7 @@ describe("bedrock mantle discovery", () => { tokenProviderFactory, }); - expect(provider).not.toBeNull(); - expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER); + expect(provider).toMatchObject({ apiKey: MANTLE_IAM_TOKEN_MARKER }); expect(tokenProvider).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith( "https://bedrock-mantle.us-east-1.api.aws/v1/models", diff --git a/extensions/anthropic/provider-policy-api.test.ts b/extensions/anthropic/provider-policy-api.test.ts index 49e6fd8ec79..3b6ff1873b3 100644 --- a/extensions/anthropic/provider-policy-api.test.ts +++ b/extensions/anthropic/provider-policy-api.test.ts @@ -23,6 +23,16 @@ function createModel(id: string, name: string): ModelDefinitionConfig { }; } +function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] { + const ids: string[] = []; + for (const level of levels ?? []) { + if (level.id === "xhigh" || level.id === "max") { + ids.push(level.id); + } + } + return ids; +} + describe("anthropic provider policy public artifact", () => { it("normalizes Anthropic provider config", () => { expect( @@ -117,9 +127,7 @@ describe("anthropic provider policy public artifact", () => { if (!profile) { throw new Error("Expected Anthropic policy profile"); } - expect( - profile.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), - ).toEqual([]); + expect(collectLegacyExtendedLevelIds(profile.levels)).toEqual([]); }); it("does not expose Anthropic thinking profiles for unrelated providers", () => { diff --git a/extensions/arcee/index.test.ts b/extensions/arcee/index.test.ts index 234f3aabfc1..c29d6937873 100644 --- a/extensions/arcee/index.test.ts +++ b/extensions/arcee/index.test.ts @@ -20,17 +20,19 @@ describe("arcee provider plugin", () => { providers: [provider], choice: "arceeai-api-key", }); - expect(directChoice).not.toBeNull(); - expect(directChoice?.provider.id).toBe("arcee"); - expect(directChoice?.method.id).toBe("arcee-platform"); + expect(directChoice).toMatchObject({ + provider: { id: "arcee" }, + method: { id: "arcee-platform" }, + }); const orChoice = resolveProviderPluginChoice({ providers: [provider], choice: "arceeai-openrouter", }); - expect(orChoice).not.toBeNull(); - expect(orChoice?.provider.id).toBe("arcee"); - expect(orChoice?.method.id).toBe("openrouter"); + expect(orChoice).toMatchObject({ + provider: { id: "arcee" }, + method: { id: "openrouter" }, + }); }); it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => { diff --git a/extensions/browser/src/browser/cdp-proxy-bypass.test.ts b/extensions/browser/src/browser/cdp-proxy-bypass.test.ts index 4ebe20093ad..bbcd4bd82af 100644 --- a/extensions/browser/src/browser/cdp-proxy-bypass.test.ts +++ b/extensions/browser/src/browser/cdp-proxy-bypass.test.ts @@ -13,12 +13,15 @@ beforeEach(() => { }); function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts b/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts index 94236a67756..dcd687fe819 100644 --- a/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.fuzz.test.ts @@ -200,7 +200,7 @@ describe("fuzz: isDirectCdpWebSocketEndpoint", () => { } }); - it("never throws on random input (including invalid URLs)", () => { + it("returns booleans for random input including invalid URLs", () => { const rng = makeRng(0x2004); const junkPool = [ "", @@ -215,7 +215,6 @@ describe("fuzz: isDirectCdpWebSocketEndpoint", () => { ]; for (let i = 0; i < ITERATIONS; i += 1) { const input = rng() < 0.5 ? pick(rng, junkPool) : String.fromCharCode(randInt(rng, 0, 0x7f)); - expect(() => isDirectCdpWebSocketEndpoint(input)).not.toThrow(); expect(typeof isDirectCdpWebSocketEndpoint(input)).toBe("boolean"); } }); @@ -271,12 +270,12 @@ describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => { } }); - it("falls back safely for non-URL-ish inputs (never throws)", () => { + it("returns normalized strings for non-URL-ish inputs", () => { const rng = makeRng(0x3003); // These inputs either trigger the catch branch (empty / "garbage" / // bare "ws://" / "wss://") or are accepted by WHATWG URL as // special-scheme absolute URLs (e.g. "ws:host/path" becomes - // "ws://host/path"). Either way the helper must never throw. + // "ws://host/path"). Both paths must return strings. const junk = [ "ws:/devtools/browser/abc", "wss:/devtools/browser/abc", @@ -289,7 +288,6 @@ describe("fuzz: normalizeCdpHttpBaseForJsonEndpoints", () => { ]; for (let i = 0; i < ITERATIONS; i += 1) { const input = pick(rng, junk); - expect(() => normalizeCdpHttpBaseForJsonEndpoints(input)).not.toThrow(); const out = normalizeCdpHttpBaseForJsonEndpoints(input); expect(typeof out).toBe("string"); // Scheme swap invariant: whatever branch ran, ws:/wss: never @@ -377,11 +375,10 @@ describe("fuzz: redactCdpUrl", () => { expect(redactCdpUrl(" ")).toBe(""); }); - it("falls back to redactSensitiveText for non-URL-ish inputs (never throws)", () => { + it("falls back to redactSensitiveText for non-URL-ish inputs", () => { const rng = makeRng(0x5002); for (let i = 0; i < ITERATIONS; i += 1) { const junk = pick(rng, ["not-a-url", "http://", "ws://", "::::", "Bearer ey.SECRET.xyz"]); - expect(() => redactCdpUrl(junk)).not.toThrow(); const out = redactCdpUrl(junk); expect(typeof out).toBe("string"); } @@ -405,7 +402,7 @@ describe("fuzz: appendCdpPath", () => { }); describe("fuzz: getHeadersWithAuth", () => { - it("never throws and always returns a mergedHeaders object", () => { + it("always returns a mergedHeaders object", () => { const rng = makeRng(0x7001); for (let i = 0; i < ITERATIONS; i += 1) { const withAuth = rng() < 0.3; diff --git a/extensions/browser/src/browser/cdp.internal.test.ts b/extensions/browser/src/browser/cdp.internal.test.ts index d920c726f96..b0fe0177cc3 100644 --- a/extensions/browser/src/browser/cdp.internal.test.ts +++ b/extensions/browser/src/browser/cdp.internal.test.ts @@ -35,6 +35,16 @@ function sendCdpResult(socket: WebSocket, id: number | undefined, result: Record socket.send(JSON.stringify({ id, result })); } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function replyToPageEnable(msg: CdpMockMessage, socket: WebSocket): boolean { if (msg.method !== "Page.enable") { return false; @@ -197,7 +207,7 @@ describe("cdp internal", () => { } if (msg.method === "Runtime.evaluate") { // Pre-capture viewport probe + post-capture probe. - const isPre = events.filter((m) => m === "Runtime.evaluate").length === 1; + const isPre = countMatching(events, (m) => m === "Runtime.evaluate") === 1; socket.send( JSON.stringify({ id: msg.id, diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index ddcce2cc8cb..b3f52de0939 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -349,10 +349,13 @@ describe("chrome MCP page parsing", () => { it("reuses a single pending session for concurrent requests", async () => { let factoryCalls = 0; - let releaseFactory!: () => void; + let releaseFactory: (() => void) | undefined; const factoryGate = new Promise((resolve) => { releaseFactory = resolve; }); + if (!releaseFactory) { + throw new Error("Expected Chrome MCP factory release callback to be initialized"); + } const factory: ChromeMcpSessionFactory = async () => { factoryCalls += 1; diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 59005ed7ef1..149c8a6c957 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -706,14 +706,15 @@ describe("browser config", () => { }, }); const profile = resolveProfile(resolved, "chrome-live"); - expect(profile).not.toBeNull(); - expect(profile?.driver).toBe("existing-session"); - expect(profile?.attachOnly).toBe(true); - expect(profile?.cdpPort).toBe(0); - expect(profile?.cdpUrl).toBe(""); - expect(profile?.cdpIsLoopback).toBe(true); + expect(profile).toMatchObject({ + driver: "existing-session", + attachOnly: true, + cdpPort: 0, + cdpUrl: "", + cdpIsLoopback: true, + color: "#00AA00", + }); expect(profile?.userDataDir).toBeUndefined(); - expect(profile?.color).toBe("#00AA00"); }); it("expands tilde-prefixed userDataDir for existing-session profiles", () => { diff --git a/extensions/browser/src/browser/doctor.test.ts b/extensions/browser/src/browser/doctor.test.ts index 791804de327..04aa769e01b 100644 --- a/extensions/browser/src/browser/doctor.test.ts +++ b/extensions/browser/src/browser/doctor.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { buildBrowserDoctorReport } from "./doctor.js"; +function collectWarningCheckIds(checks: readonly { id: string; status: string }[]): string[] { + const ids: string[] = []; + for (const check of checks) { + if (check.status === "warn") { + ids.push(check.id); + } + } + return ids; +} + describe("buildBrowserDoctorReport", () => { it("reports stopped managed browsers as launchable diagnostics", () => { const report = buildBrowserDoctorReport({ @@ -101,9 +111,11 @@ describe("buildBrowserDoctorReport", () => { }); expect(report.ok).toBe(true); - expect( - report.checks.filter((check) => check.status === "warn").map((check) => check.id), - ).toEqual(["managed-executable", "display", "linux-sandbox"]); + expect(collectWarningCheckIds(report.checks)).toEqual([ + "managed-executable", + "display", + "linux-sandbox", + ]); expect(report.checks.find((check) => check.id === "display")).toMatchObject({ summary: "No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (config)", }); diff --git a/extensions/browser/src/browser/output-files.ts b/extensions/browser/src/browser/output-files.ts index 442236f860e..9b5436de0e1 100644 --- a/extensions/browser/src/browser/output-files.ts +++ b/extensions/browser/src/browser/output-files.ts @@ -21,6 +21,11 @@ export async function writeExternalFileWithinOutputRoot(params: { rootDir, path: outputPath, write: params.write, + }).catch((err: unknown) => { + if (err instanceof Error && /file not found/i.test(err.message)) { + throw new Error("output directory changed while writing file"); + } + throw err; }); return result.path; } diff --git a/extensions/browser/src/browser/pw-session.browserless.live.test.ts b/extensions/browser/src/browser/pw-session.browserless.live.test.ts index abd2d71bf73..19030387c1f 100644 --- a/extensions/browser/src/browser/pw-session.browserless.live.test.ts +++ b/extensions/browser/src/browser/pw-session.browserless.live.test.ts @@ -18,7 +18,8 @@ describeLive("browser (live): remote CDP tab persistence", () => { await pw.closePlaywrightBrowserConnection().catch(() => {}); const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" }); - expect(created.targetId).toEqual(expect.any(String)); + expect(created.targetId).toBeTypeOf("string"); + expect(created.targetId).not.toBe(""); try { await waitFor( async () => { diff --git a/extensions/browser/src/browser/pw-session.test.ts b/extensions/browser/src/browser/pw-session.test.ts index 2d41cf97d08..9ee78332825 100644 --- a/extensions/browser/src/browser/pw-session.test.ts +++ b/extensions/browser/src/browser/pw-session.test.ts @@ -168,6 +168,18 @@ describe("pw-session ensurePageState", () => { expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/); expect(saveAsA.mock.calls[0]?.[0]).not.toBe(managedPathA); expect(saveAsB.mock.calls[0]?.[0]).not.toBe(managedPathB); + for (const call of [saveAsA.mock.calls[0], saveAsB.mock.calls[0]]) { + const savedPath = call?.[0]; + expect(savedPath).toEqual(expect.any(String)); + if (typeof savedPath !== "string") { + throw new Error("Expected saved download path"); + } + const savedParentName = path.basename(path.dirname(savedPath)); + expect( + savedParentName.includes("fs-safe-output") || + savedParentName === path.basename(DEFAULT_DOWNLOAD_DIR), + ).toBe(true); + } await expect(fs.readFile(managedPathA ?? "", "utf8")).resolves.toBe("download-a"); await expect(fs.readFile(managedPathB ?? "", "utf8")).resolves.toBe("download-b"); }); diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts index e619aab3783..18430643db0 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts @@ -34,10 +34,13 @@ vi.mock("./pw-session.js", () => { const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"); function createPendingEval() { - let evalCalled!: () => void; + let evalCalled: (() => void) | undefined; const evalCalledPromise = new Promise((resolve) => { evalCalled = resolve; }); + if (!evalCalled) { + throw new Error("Expected evaluate callback to be initialized"); + } return { evalCalledPromise, resolveEvalCalled: evalCalled, diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index e1f583fef1c..17b4efeded8 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -137,7 +137,11 @@ describe("pw-tools-core", () => { const savedPath = params.saveAs.mock.calls[0]?.[0]; expect(typeof savedPath).toBe("string"); expect(savedPath).not.toBe(params.targetPath); - expect(path.basename(path.dirname(String(savedPath)))).toContain("fs-safe-output"); + const savedParentName = path.basename(path.dirname(String(savedPath))); + expect( + savedParentName.includes("fs-safe-output") || + savedParentName === path.basename(path.dirname(params.targetPath)), + ).toBe(true); expect(path.basename(String(savedPath))).toContain(path.basename(params.targetPath)); expect(path.basename(String(savedPath))).toMatch(/\.part$/); expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content); @@ -208,6 +212,57 @@ describe("pw-tools-core", () => { }); }); + it.runIf(process.platform !== "win32")( + "does not write outside the output root when a download parent is swapped after save", + async () => { + await withTempDir(async (tempDir) => { + const rootDir = path.join(tempDir, "downloads"); + const targetParent = path.join(rootDir, "race"); + const outsideDir = path.join(tempDir, "outside"); + const targetPath = path.join(targetParent, "file.bin"); + const outsideTargetPath = path.join(outsideDir, "file.bin"); + await fs.mkdir(targetParent, { recursive: true }); + await fs.mkdir(outsideDir); + + const harness = createDownloadEventHarness(); + let parentSwappedBeforeFinalize = false; + const saveAs = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "race-content", "utf8"); + const beforeSwap = await fs.lstat(targetParent); + expect(beforeSwap.isDirectory()).toBe(true); + expect(beforeSwap.isSymbolicLink()).toBe(false); + await fs.rm(targetParent, { recursive: true, force: true }); + await fs.symlink(outsideDir, targetParent); + const afterSwap = await fs.lstat(targetParent); + expect(afterSwap.isSymbolicLink()).toBe(true); + parentSwappedBeforeFinalize = true; + }); + + const p = mod.waitForDownloadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + path: targetPath, + rootDir, + timeoutMs: 1000, + }); + + await Promise.resolve(); + harness.expectArmed(); + harness.trigger({ + url: () => "https://example.com/file.bin", + suggestedFilename: () => "file.bin", + saveAs, + }); + + await expect(p).rejects.toThrow(/path alias|outside workspace|directory changed/i); + expect(parentSwappedBeforeFinalize).toBe(true); + expect(saveAs).toHaveBeenCalledOnce(); + await expect(fs.access(outsideTargetPath)).rejects.toThrow(); + await expect(fs.readdir(outsideDir)).resolves.toEqual([]); + }); + }, + ); + it("marks explicit download waiters as owning the next download until cleanup", async () => { const harness = createDownloadEventHarness(); const state = sessionMocks.ensurePageState(); diff --git a/extensions/browser/src/browser/server-middleware.ts b/extensions/browser/src/browser/server-middleware.ts index 3efd67e352e..d245069fccb 100644 --- a/extensions/browser/src/browser/server-middleware.ts +++ b/extensions/browser/src/browser/server-middleware.ts @@ -27,8 +27,15 @@ export function installBrowserCommonMiddleware(app: Express) { abort(); } }); - // Make the signal available to browser route handlers (best-effort). - (req as unknown as { signal?: AbortSignal }).signal = ctrl.signal; + // Make the signal available to browser route handlers on Node versions + // whose IncomingMessage does not already expose a native read-only signal. + const requestWithSignal = req as Request & { signal?: AbortSignal }; + if (!(requestWithSignal.signal instanceof AbortSignal)) { + Object.defineProperty(req, "signal", { + value: ctrl.signal, + configurable: true, + }); + } next(); }); app.use(express.json({ limit: "1mb" })); diff --git a/extensions/browser/src/browser/server.agent-contract-core.test.ts b/extensions/browser/src/browser/server.agent-contract-core.test.ts index 957c1d634bf..0050894a935 100644 --- a/extensions/browser/src/browser/server.agent-contract-core.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-core.test.ts @@ -51,7 +51,7 @@ const pwMocks = getPwMocks(); describe("browser control server", () => { installAgentContractHooks(); - const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000; + const slowTimeoutMs = 60_000; it( "returns ACT_KIND_REQUIRED when kind is missing", diff --git a/extensions/browser/src/sdk-security-runtime.ts b/extensions/browser/src/sdk-security-runtime.ts index f64a1d4b641..d636d657cb2 100644 --- a/extensions/browser/src/sdk-security-runtime.ts +++ b/extensions/browser/src/sdk-security-runtime.ts @@ -1,9 +1,15 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + findExistingAncestor, + pathScope as sdkPathScope, +} from "openclaw/plugin-sdk/security-runtime"; + export { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; export { ensurePortAvailable, extractErrorCode, formatErrorMessage, - ensureAbsoluteDirectory, hasProxyEnvConfigured, isNotFoundPathError, isPathInside, @@ -28,3 +34,33 @@ export { wrapExternalContent, } from "openclaw/plugin-sdk/security-runtime"; export type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/security-runtime"; + +export async function ensureAbsoluteDirectory( + dirPath: string, + options?: { scopeLabel?: string; mode?: number }, +): Promise<{ ok: true; path: string } | { ok: false; error: Error }> { + const absolutePath = path.resolve(dirPath); + const scopeLabel = options?.scopeLabel ?? "directory"; + const existingAncestor = await findExistingAncestor(absolutePath); + if (!existingAncestor) { + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + if (existingAncestor === absolutePath) { + try { + const stat = await fs.lstat(absolutePath); + if (!stat.isSymbolicLink() && stat.isDirectory()) { + return { ok: true, path: absolutePath }; + } + } catch { + // Fall through to the uniform invalid-path result below. + } + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + const result = await sdkPathScope(existingAncestor, { + label: options?.scopeLabel ?? "directory", + }).ensureDir(path.relative(existingAncestor, absolutePath), { mode: options?.mode }); + if (result.ok) { + return result; + } + return { ok: false, error: new Error(result.error) }; +} diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index a2659acaa98..c8d318f90be 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -41,6 +41,19 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) { }); } +function requireBytePlusPostBody(): Record { + const request = postJsonRequestMock.mock.calls[0]?.[0] as + | { body?: Record } + | undefined; + if (!request) { + throw new Error("expected BytePlus video request"); + } + if (!request.body) { + throw new Error("expected BytePlus video request body"); + } + return request.body; +} + describe("byteplus video generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider()); @@ -63,7 +76,11 @@ describe("byteplus video generation provider", () => { }), ); expect(result.videos).toHaveLength(1); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated BytePlus video"); + } + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ taskId: "task_123", @@ -84,8 +101,7 @@ describe("byteplus video generation provider", () => { cfg: {}, }); - const request = postJsonRequestMock.mock.calls[0]?.[0] as { body?: Record }; - expect(request.body).toMatchObject({ + expect(requireBytePlusPostBody()).toMatchObject({ model: "seedance-1-0-lite-i2v-250428", resolution: "720p", content: [ @@ -115,8 +131,7 @@ describe("byteplus video generation provider", () => { cfg: {}, }); - const request = postJsonRequestMock.mock.calls[0]?.[0] as { body?: Record }; - expect(request.body).toMatchObject({ + expect(requireBytePlusPostBody()).toMatchObject({ model: "seedance-1-0-pro-250528", seed: 42, resolution: "480p", diff --git a/extensions/canvas/src/cli.test.ts b/extensions/canvas/src/cli.test.ts index 2dd089a91a1..32cb87b2555 100644 --- a/extensions/canvas/src/cli.test.ts +++ b/extensions/canvas/src/cli.test.ts @@ -68,8 +68,12 @@ describe("canvas CLI", () => { }), ); expect(writtenFiles).toHaveLength(1); - expect(writtenFiles[0]?.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/); - expect(writtenFiles[0]?.base64).toBe("aGk="); + const [writtenFile] = writtenFiles; + if (!writtenFile) { + throw new Error("Expected canvas snapshot file"); + } + expect(writtenFile.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/); + expect(writtenFile.base64).toBe("aGk="); expect(runtime.log).toHaveBeenCalledWith(expect.stringMatching(/^MEDIA:.*\.png$/)); }); }); diff --git a/extensions/canvas/src/config-migration.test.ts b/extensions/canvas/src/config-migration.test.ts index 7040eb04af5..bd336d996fa 100644 --- a/extensions/canvas/src/config-migration.test.ts +++ b/extensions/canvas/src/config-migration.test.ts @@ -12,8 +12,11 @@ describe("migrateLegacyCanvasHostConfig", () => { }, } as OpenClawConfig); - expect(result?.changes).toEqual(["migrated canvasHost to plugins.entries.canvas.config.host"]); - expect(result?.config).toEqual({ + if (!result) { + throw new Error("expected Canvas config migration result"); + } + expect(result.changes).toEqual(["migrated canvasHost to plugins.entries.canvas.config.host"]); + expect(result.config).toEqual({ plugins: { entries: { canvas: { @@ -51,7 +54,10 @@ describe("migrateLegacyCanvasHostConfig", () => { }, } as OpenClawConfig); - expect(result?.config).toEqual({ + if (!result) { + throw new Error("expected Canvas config migration result"); + } + expect(result.config).toEqual({ plugins: { entries: { canvas: { diff --git a/extensions/canvas/src/host/file-resolver.test.ts b/extensions/canvas/src/host/file-resolver.test.ts index 800a189b318..819d62306ff 100644 --- a/extensions/canvas/src/host/file-resolver.test.ts +++ b/extensions/canvas/src/host/file-resolver.test.ts @@ -4,6 +4,8 @@ import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plug import { describe, expect, it } from "vitest"; import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js"; +type ResolvedFile = NonNullable>>; + async function withCanvasTemp(prefix: string, run: (dir: string) => Promise): Promise { return await withTempWorkspace( { rootDir: resolvePreferredOpenClawTmpDir(), prefix }, @@ -11,6 +13,16 @@ async function withCanvasTemp(prefix: string, run: (dir: string) => Promise>, +): ResolvedFile { + expect(result).toEqual(expect.objectContaining({ handle: expect.any(Object) })); + if (result === null) { + throw new Error("Expected resolved file within root"); + } + return result; +} + describe("resolveFileWithinRoot", () => { it("normalizes URL paths", () => { expect(normalizeUrlPath("/nested/../file.txt")).toBe("/file.txt"); @@ -23,11 +35,11 @@ describe("resolveFileWithinRoot", () => { await fs.writeFile(path.join(root, "docs", "index.html"), "

docs

"); const result = await resolveFileWithinRoot(root, "/docs"); - expect(result).not.toBeNull(); + const resolved = expectResolvedFile(result); try { - await expect(result?.handle.readFile({ encoding: "utf8" })).resolves.toBe("

docs

"); + await expect(resolved.handle.readFile({ encoding: "utf8" })).resolves.toBe("

docs

"); } finally { - await result?.handle.close().catch(() => {}); + await resolved.handle.close().catch(() => {}); } }); }); diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index d3dc5572840..c939ad048f8 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -260,7 +260,7 @@ describe("canvas host", () => { const dir = await createCaseDir(); const index = path.join(dir, "index.html"); await fs.writeFile(index, "v1", "utf8"); - let resolveReload!: () => void; + let resolveReload: (() => void) | undefined; const reloadSent = new Promise((resolve) => { resolveReload = resolve; }); @@ -306,6 +306,9 @@ describe("canvas host", () => { send: (message: string) => { ws.sent.push(message); if (message === "reload") { + if (!resolveReload) { + throw new Error("Expected Canvas reload resolver to be initialized"); + } resolveReload(); } }, @@ -342,7 +345,11 @@ describe("canvas host", () => { Buffer.alloc(0), ); expect(upgraded).toBe(true); - expect(TrackingWebSocketServerClass.latestInstance?.connectionCount).toBe(1); + const latestServer = TrackingWebSocketServerClass.latestInstance; + if (!latestServer) { + throw new Error("expected Canvas host websocket server"); + } + expect(latestServer.connectionCount).toBe(1); const ws = TrackingWebSocketServerClass.latestSocket; if (!ws) { throw new Error("expected Canvas host websocket"); diff --git a/extensions/chutes/models.test.ts b/extensions/chutes/models.test.ts index be80d1e76e1..381dd2e1afb 100644 --- a/extensions/chutes/models.test.ts +++ b/extensions/chutes/models.test.ts @@ -53,6 +53,17 @@ function createAuthEchoFetchMock() { }); } +function requireChutesModel( + models: Awaited>, + index: number, +): Awaited>[number] { + const model = models[index]; + if (!model) { + throw new Error(`expected Chutes model at index ${index}`); + } + return model; +} + describe("chutes-models", () => { beforeEach(() => { clearChutesModelCacheForTests(); @@ -68,7 +79,10 @@ describe("chutes-models", () => { expect(def.cost).toEqual(entry.cost); expect(def.contextWindow).toBe(entry.contextWindow); expect(def.maxTokens).toBe(entry.maxTokens); - expect(def.compat?.supportsUsageInStreaming).toBe(false); + if (!def.compat) { + throw new Error("expected Chutes model compat"); + } + expect(def.compat.supportsUsageInStreaming).toBe(false); }); it("discoverChutesModels returns static catalog when accessToken is empty", async () => { @@ -80,7 +94,7 @@ describe("chutes-models", () => { it("discoverChutesModels returns static catalog in test env by default", async () => { const models = await discoverChutesModels("test-token"); expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); - expect(models[0]?.id).toBe("Qwen/Qwen3-32B"); + expect(requireChutesModel(models, 0).id).toBe("Qwen/Qwen3-32B"); }); it("discoverChutesModels correctly maps API response when not in test env", async () => { @@ -105,9 +119,14 @@ describe("chutes-models", () => { const models = await discoverChutesModels("test-token-real-fetch"); expect(models.length).toBeGreaterThan(0); if (models.length === 3) { - expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE"); - expect(models[1]?.reasoning).toBe(true); - expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false); + const firstModel = requireChutesModel(models, 0); + const secondModel = requireChutesModel(models, 1); + expect(firstModel.id).toBe("zai-org/GLM-4.7-TEE"); + expect(secondModel.reasoning).toBe(true); + if (!secondModel.compat) { + throw new Error("expected Chutes API model compat"); + } + expect(secondModel.compat.supportsUsageInStreaming).toBe(false); } }); }); @@ -208,9 +227,9 @@ describe("chutes-models", () => { const modelsA = await discoverChutesModels("chutes-token-a"); const modelsB = await discoverChutesModels("chutes-token-b"); const modelsASecond = await discoverChutesModels("chutes-token-a"); - expect(modelsA[0]?.id).toBe("private/model-a"); - expect(modelsB[0]?.id).toBe("private/model-b"); - expect(modelsASecond[0]?.id).toBe("private/model-a"); + expect(requireChutesModel(modelsA, 0).id).toBe("private/model-a"); + expect(requireChutesModel(modelsB, 0).id).toBe("private/model-b"); + expect(requireChutesModel(modelsASecond, 0).id).toBe("private/model-a"); expect(mockFetch).toHaveBeenCalledTimes(2); }); }); diff --git a/extensions/cloudflare-ai-gateway/index.test.ts b/extensions/cloudflare-ai-gateway/index.test.ts index 3c8b46b97de..2c7064b39f5 100644 --- a/extensions/cloudflare-ai-gateway/index.test.ts +++ b/extensions/cloudflare-ai-gateway/index.test.ts @@ -6,14 +6,20 @@ import plugin from "./index.js"; function registerProvider() { const captured = capturePluginRegistration(plugin); const provider = captured.providers[0]; - expect(provider?.id).toBe("cloudflare-ai-gateway"); + if (!provider) { + throw new Error("expected Cloudflare AI Gateway provider"); + } + expect(provider.id).toBe("cloudflare-ai-gateway"); return provider; } describe("cloudflare-ai-gateway plugin", () => { it("registers a stream wrapper that strips Anthropic thinking assistant prefill", () => { const provider = registerProvider(); - expect(provider?.wrapStreamFn).toBeTypeOf("function"); + expect(provider.wrapStreamFn).toBeTypeOf("function"); + if (!provider.wrapStreamFn) { + throw new Error("expected Cloudflare AI Gateway stream wrapper"); + } let capturedPayload: Record | undefined; const baseStreamFn: StreamFn = (_model, _context, options) => { @@ -29,19 +35,26 @@ describe("cloudflare-ai-gateway plugin", () => { return {} as ReturnType; }; - const wrapped = provider?.wrapStreamFn?.({ + const wrapped = provider.wrapStreamFn({ provider: "cloudflare-ai-gateway", modelId: "claude-sonnet-4-6", model: { api: "anthropic-messages" }, streamFn: baseStreamFn, } as never); + expect(wrapped).toBeTypeOf("function"); + if (!wrapped) { + throw new Error("expected Cloudflare AI Gateway wrapped stream function"); + } - void wrapped?.( + void wrapped( { provider: "cloudflare-ai-gateway", api: "anthropic-messages" } as never, {} as never, {}, ); - expect(capturedPayload?.messages).toEqual([{ role: "user", content: "Return JSON." }]); + if (!capturedPayload) { + throw new Error("expected Cloudflare AI Gateway payload capture"); + } + expect(capturedPayload.messages).toEqual([{ role: "user", content: "Return JSON." }]); }); }); diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 989dbbf3e41..68505805576 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -84,7 +84,8 @@ describe("codex plugin", () => { }; delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved; - expect(() => plugin.register(api)).not.toThrow(); + plugin.register(api); + expect(api.registerProvider).toHaveBeenCalledWith(expect.objectContaining({ id: "codex" })); }); it("only claims the codex provider by default", () => { diff --git a/extensions/codex/media-understanding-provider.test.ts b/extensions/codex/media-understanding-provider.test.ts index 8939d9f3e71..af051c4a630 100644 --- a/extensions/codex/media-understanding-provider.test.ts +++ b/extensions/codex/media-understanding-provider.test.ts @@ -26,6 +26,7 @@ function threadStartResult() { return { thread: { id: "thread-1", + sessionId: "session-1", forkedFromId: null, preview: "", ephemeral: true, diff --git a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts index 6269bc432f9..672a34e605b 100644 --- a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts @@ -112,7 +112,7 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | " seenAuthProfileIds, seenAgentDirs, async waitForMethod(method: string) { - await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), { + await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), { interval: 1, }); }, diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index 77fbb82c345..08fe1bf8e7c 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -261,7 +261,9 @@ describe("Codex plugin thread config", () => { pluginName: "google-calendar", }); expect(config.diagnostics).toEqual([]); - expect(request.mock.calls.filter(([method]) => method === "app/list")).toHaveLength(1); + expect( + request.mock.calls.reduce((count, [method]) => count + (method === "app/list" ? 1 : 0), 0), + ).toBe(1); }); it("does not expose plugin apps missing from the app inventory snapshot", async () => { @@ -391,10 +393,8 @@ describe("Codex plugin thread config", () => { }); expect(config.diagnostics).toEqual([]); expect(request.mock.calls.map(([method]) => method)).toContain("plugin/install"); - expect(request.mock.calls.filter(([method]) => method === "app/list").length).toBeGreaterThan( - 0, - ); - expect(appListParams.some((params) => params.forceRefetch)).toBe(true); + expect(request.mock.calls.some(([method]) => method === "app/list")).toBe(true); + expect(appListParams.map((params) => params.forceRefetch)).toContain(true); }); it("surfaces critical post-install refresh failures and keeps plugin apps disabled", async () => { diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index b7e3d8cce08..f637da74130 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -147,7 +147,7 @@ function createStartedThreadHarness( return { requests, async waitForMethod(method: string) { - await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), { + await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), { interval: 1, }); }, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index e2c6d8f8ee4..d6858e6a355 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1749,16 +1749,20 @@ describe("runCodexAppServerAttempt", () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const resetsAt = Math.ceil(Date.now() / 1000) + 120; - let harness!: ReturnType; - harness = createStartedThreadHarness(async (method) => { + const harnessRef: { current?: ReturnType } = {}; + const harness = createStartedThreadHarness(async (method) => { if (method === "turn/start") { - await harness.notify(rateLimitsUpdated(resetsAt)); + if (!harnessRef.current) { + throw new Error("Expected Codex app-server harness to be initialized"); + } + await harnessRef.current.notify(rateLimitsUpdated(resetsAt)); throw Object.assign(new Error("You've reached your usage limit."), { data: { codexErrorInfo: "usageLimitExceeded" }, }); } return undefined; }); + harnessRef.current = harness; const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch( (error: unknown) => error, @@ -1982,13 +1986,12 @@ describe("runCodexAppServerAttempt", () => { await waitForMethod("turn/start"); expect(queueAgentHarnessMessage("session-1", "more context", { debounceMs: 1 })).toBe(true); - await vi.waitFor( - () => expect(requests.some((entry) => entry.method === "turn/steer")).toBe(true), - { interval: 1 }, - ); + await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"), { + interval: 1, + }); expect(abortAgentHarnessRun("session-1")).toBe(true); await vi.waitFor( - () => expect(requests.some((entry) => entry.method === "turn/interrupt")).toBe(true), + () => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"), { interval: 1 }, ); @@ -2164,7 +2167,7 @@ describe("runCodexAppServerAttempt", () => { params.onBlockReply = vi.fn(); const run = runCodexAppServerAttempt(params); await vi.waitFor( - () => expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(true), + () => expect(request.mock.calls.map(([method]) => method)).toContain("turn/start"), { interval: 1 }, ); await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), { interval: 1 }); @@ -2409,7 +2412,7 @@ describe("runCodexAppServerAttempt", () => { }; const run = runCodexAppServerAttempt(params); await vi.waitFor(() => - expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(true), + expect(request.mock.calls.map(([method]) => method)).toContain("turn/start"), ); await notify({ method: "turn/completed", diff --git a/extensions/codex/src/app-server/trajectory.test.ts b/extensions/codex/src/app-server/trajectory.test.ts index 91b0e3a3076..ab611db3a73 100644 --- a/extensions/codex/src/app-server/trajectory.test.ts +++ b/extensions/codex/src/app-server/trajectory.test.ts @@ -8,6 +8,8 @@ import { resolveCodexTrajectoryPointerFlags, } from "./trajectory.js"; +type CodexTrajectoryRecorder = NonNullable>; + const tempDirs: string[] = []; function makeTempDir(): string { @@ -22,6 +24,16 @@ afterEach(() => { } }); +function expectTrajectoryRecorder( + recorder: ReturnType, +): CodexTrajectoryRecorder { + expect(recorder).toEqual(expect.objectContaining({ recordEvent: expect.any(Function) })); + if (recorder === null) { + throw new Error("Expected Codex trajectory recorder"); + } + return recorder; +} + describe("Codex trajectory recorder", () => { it("keeps write flags usable when O_NOFOLLOW is unavailable", () => { const constants = { @@ -52,13 +64,13 @@ describe("Codex trajectory recorder", () => { env: {}, }); - expect(recorder).not.toBeNull(); - recorder?.recordEvent("session.started", { + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("session.started", { apiKey: "secret", headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], command: "curl -H 'Authorization: Bearer sk-other-secret-token'", }); - await recorder?.flush(); + await trajectoryRecorder.flush(); const filePath = path.join(tmpDir, "session.trajectory.jsonl"); const content = fs.readFileSync(filePath, "utf8"); @@ -82,8 +94,9 @@ describe("Codex trajectory recorder", () => { env: { OPENCLAW_TRAJECTORY_DIR: tmpDir }, }); - recorder?.recordEvent("session.started"); - await recorder?.flush(); + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("session.started"); + await trajectoryRecorder.flush(); expect(fs.existsSync(path.join(tmpDir, "___evil_session.jsonl"))).toBe(true); }); @@ -119,8 +132,9 @@ describe("Codex trajectory recorder", () => { env: {}, }); - recorder?.recordEvent("session.started"); - await recorder?.flush(); + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("session.started"); + await trajectoryRecorder.flush(); expect(fs.existsSync(path.join(targetDir, "session.trajectory.jsonl"))).toBe(false); }); @@ -137,12 +151,13 @@ describe("Codex trajectory recorder", () => { env: {}, }); - recorder?.recordEvent("context.compiled", { + const trajectoryRecorder = expectTrajectoryRecorder(recorder); + trajectoryRecorder.recordEvent("context.compiled", { fields: Object.fromEntries( Array.from({ length: 100 }, (_, index) => [`field-${index}`, "x".repeat(3_000)]), ), }); - await recorder?.flush(); + await trajectoryRecorder.flush(); const parsed = JSON.parse( fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"), diff --git a/extensions/codex/src/app-server/user-input-bridge.test.ts b/extensions/codex/src/app-server/user-input-bridge.test.ts index 7ef88c1cc93..1b2ca9e39ed 100644 --- a/extensions/codex/src/app-server/user-input-bridge.test.ts +++ b/extensions/codex/src/app-server/user-input-bridge.test.ts @@ -10,6 +10,18 @@ function createParams(): EmbeddedRunAttemptParams { } as unknown as EmbeddedRunAttemptParams; } +function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string { + const onBlockReply = params.onBlockReply; + if (onBlockReply === undefined) { + throw new Error("Expected onBlockReply callback"); + } + const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0]; + if (typeof payload?.text !== "string") { + throw new Error("Expected first block reply text"); + } + return payload.text; +} + describe("Codex app-server user input bridge", () => { it("prompts the originating chat and resolves request_user_input from the next queued message", async () => { const params = createParams(); @@ -161,9 +173,7 @@ describe("Codex app-server user input bridge", () => { }); await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); - const payload = vi.mocked(params.onBlockReply!).mock.calls[0]?.[0]; - expect(payload).toEqual(expect.objectContaining({ text: expect.any(String) })); - const text = payload?.text ?? ""; + const text = expectFirstBlockReplyText(params); expect(text).toContain("Mode <\uff20U123>"); expect(text).toContain("Pick \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here"); expect(text).toContain( diff --git a/extensions/deepgram/audio.test.ts b/extensions/deepgram/audio.test.ts index 657d2308fa1..92f424d0cee 100644 --- a/extensions/deepgram/audio.test.ts +++ b/extensions/deepgram/audio.test.ts @@ -55,14 +55,17 @@ describe("transcribeDeepgramAudio", () => { expect(seenUrl).toBe( "https://api.example.com/v1/listen?model=nova-3&language=en&punctuate=false&smart_format=true", ); - expect(seenInit?.method).toBe("POST"); - expect(seenInit?.signal).toBeInstanceOf(AbortSignal); + if (!seenInit) { + throw new Error("Expected Deepgram fetch request init"); + } + expect(seenInit.method).toBe("POST"); + expect(seenInit.signal).toBeInstanceOf(AbortSignal); - const headers = new Headers(seenInit?.headers); + const headers = new Headers(seenInit.headers); expect(headers.get("authorization")).toBe("Token test-key"); expect(headers.get("x-custom")).toBe("1"); expect(headers.get("content-type")).toBe("audio/wav"); - expect(seenInit?.body).toBeInstanceOf(Uint8Array); + expect(seenInit.body).toBeInstanceOf(Uint8Array); }); it("throws when the provider response omits transcript", async () => { diff --git a/extensions/deepinfra/image-generation-provider.test.ts b/extensions/deepinfra/image-generation-provider.test.ts index f485233c323..c2b6a66e91f 100644 --- a/extensions/deepinfra/image-generation-provider.test.ts +++ b/extensions/deepinfra/image-generation-provider.test.ts @@ -116,9 +116,14 @@ describe("deepinfra image generation provider", () => { }, }), ); - expect(result.images[0]?.mimeType).toBe("image/jpeg"); - expect(result.images[0]?.fileName).toBe("image-1.jpg"); - expect(result.images[0]?.revisedPrompt).toBe("red square"); + expect(result.images).toHaveLength(1); + const [firstImage] = result.images; + if (!firstImage) { + throw new Error("Expected generated DeepInfra image"); + } + expect(firstImage.mimeType).toBe("image/jpeg"); + expect(firstImage.fileName).toBe("image-1.jpg"); + expect(firstImage.revisedPrompt).toBe("red square"); expect(release).toHaveBeenCalledOnce(); }); @@ -152,11 +157,20 @@ describe("deepinfra image generation provider", () => { url: "https://api.deepinfra.com/v1/openai/images/edits", }), ); - const form = postMultipartRequestMock.mock.calls[0]?.[0].body as FormData; + const firstCall = postMultipartRequestMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected DeepInfra multipart request"); + } + const form = firstCall[0].body as FormData; expect(form.get("model")).toBe("black-forest-labs/FLUX-1-schnell"); expect(form.get("prompt")).toBe("make it neon"); expect(form.get("response_format")).toBe("b64_json"); expect(form.get("image")).toBeInstanceOf(File); - expect(result.images[0]?.mimeType).toBe("image/png"); + expect(result.images).toHaveLength(1); + const [image] = result.images; + if (!image) { + throw new Error("Expected edited DeepInfra image"); + } + expect(image.mimeType).toBe("image/png"); }); }); diff --git a/extensions/deepinfra/onboard.test.ts b/extensions/deepinfra/onboard.test.ts index 8eea909031d..46ebe2a586c 100644 --- a/extensions/deepinfra/onboard.test.ts +++ b/extensions/deepinfra/onboard.test.ts @@ -115,9 +115,10 @@ describe("DeepInfra provider config", () => { try { const result = resolveEnvApiKey("deepinfra"); - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("test-deepinfra-key"); - expect(result?.source).toContain("DEEPINFRA_API_KEY"); + expect(result).toMatchObject({ + apiKey: "test-deepinfra-key", + source: expect.stringContaining("DEEPINFRA_API_KEY"), + }); } finally { envSnapshot.restore(); } diff --git a/extensions/deepinfra/video-generation-provider.test.ts b/extensions/deepinfra/video-generation-provider.test.ts index 9daea7292fc..be9af300939 100644 --- a/extensions/deepinfra/video-generation-provider.test.ts +++ b/extensions/deepinfra/video-generation-provider.test.ts @@ -104,10 +104,15 @@ describe("deepinfra video generation provider", () => { cfg: {}, }); - expect(result.videos[0]).toMatchObject({ + expect(result.videos).toHaveLength(1); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated DeepInfra video"); + } + expect(video).toMatchObject({ mimeType: "video/webm", fileName: "video-1.webm", }); - expect(result.videos[0]?.buffer).toEqual(Buffer.from("webm-data")); + expect(video.buffer).toEqual(Buffer.from("webm-data")); }); }); diff --git a/extensions/deepseek/index.test.ts b/extensions/deepseek/index.test.ts index 85b21538bb5..bd41e7eb650 100644 --- a/extensions/deepseek/index.test.ts +++ b/extensions/deepseek/index.test.ts @@ -16,6 +16,8 @@ type PayloadCapture = { payload?: Record; }; +type RegisteredProvider = Awaited>; + const emptyUsage = { input: 0, output: 0, @@ -25,6 +27,15 @@ const emptyUsage = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; +function requireThinkingProfileResolver( + provider: RegisteredProvider, +): NonNullable { + if (!provider.resolveThinkingProfile) { + throw new Error("DeepSeek provider did not register a thinking profile resolver"); + } + return provider.resolveThinkingProfile; +} + const readToolCall = { type: "toolCall", id: "call_1", name: "read", arguments: {} }; const readToolResult = { role: "toolResult", @@ -141,9 +152,10 @@ describe("deepseek provider plugin", () => { expect(provider.label).toBe("DeepSeek"); expect(provider.envVars).toEqual(["DEEPSEEK_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved).not.toBeNull(); - expect(resolved?.provider.id).toBe("deepseek"); - expect(resolved?.method.id).toBe("api-key"); + expect(resolved).toMatchObject({ + provider: { id: "deepseek" }, + method: { id: "api-key" }, + }); }); it("builds the static DeepSeek model catalog", async () => { @@ -189,7 +201,7 @@ describe("deepseek provider plugin", () => { it("advertises max thinking levels for DeepSeek V4 models only", async () => { const provider = await registerSingleProviderPlugin(deepseekPlugin); - const resolveThinkingProfile = provider.resolveThinkingProfile!; + const resolveThinkingProfile = requireThinkingProfileResolver(provider); const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; expect( diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 52fa36265d0..9cbbe0399d3 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -133,6 +133,13 @@ function requireText(result: { text?: unknown } | null | undefined): string { return result.text; } +function requireMediaUrl(opts: { mediaUrl?: string }): string { + if (!opts.mediaUrl) { + throw new Error("pair command did not send a media URL"); + } + return opts.mediaUrl; +} + function createChannelRuntime( runtimeKey: string, sendKey: string, @@ -479,11 +486,12 @@ describe("device-pair /pair qr", () => { expect(caption).toContain("Scan this QR code with the OpenClaw iOS app:"); expect(caption).toContain("IMPORTANT: After pairing finishes, run /pair cleanup."); expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately."); - expect(opts.mediaUrl).toMatch(/pair-qr\.png$/); - expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]); + const mediaUrl = requireMediaUrl(opts); + expect(mediaUrl).toMatch(/pair-qr\.png$/); + expect(opts.mediaLocalRoots).toEqual([path.dirname(mediaUrl)]); expect(opts).toMatchObject(testCase.expectedOpts); expect(sentPng).toBe("fakepng"); - await expect(fs.access(opts.mediaUrl!)).rejects.toThrow(); + await expect(fs.access(mediaUrl)).rejects.toThrow(); expect(text).toContain("QR code sent above."); expect(text).toContain("IMPORTANT: Run /pair cleanup after pairing finishes."); }); diff --git a/extensions/diffs/src/tool-render-output.test.ts b/extensions/diffs/src/tool-render-output.test.ts index 2849986a9d3..2dadb1a3048 100644 --- a/extensions/diffs/src/tool-render-output.test.ts +++ b/extensions/diffs/src/tool-render-output.test.ts @@ -68,7 +68,7 @@ describe("diffs tool rendered output guards", () => { }); expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).filePath).toEqual(expect.any(String)); + expect((result?.details as Record).filePath).toMatch(/preview\.png$/); }); }); diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 1a5eab936e2..cefa9518278 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -190,8 +190,10 @@ describe("diffs tool", () => { }); expectArtifactOnlyFileResult(screenshotter, result); - expect((result?.details as Record).artifactId).toEqual(expect.any(String)); - expect((result?.details as Record).expiresAt).toEqual(expect.any(String)); + expect(requireString(readDetails(result).artifactId, "artifactId")).toMatch(/^[a-f0-9]{20}$/u); + expect(requireString(readDetails(result).expiresAt, "expiresAt")).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); }); it("honors ttlSeconds for artifact-only file output", async () => { diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 69d8a75bc81..3f90477fd89 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -142,8 +142,11 @@ describe("fetchDiscord", () => { }); expect(result).toEqual({ id: "42" }); - expect(request?.method).toBe("POST"); - expect(request?.body).toBe(JSON.stringify({ content: "hello" })); - expect(new Headers(request?.headers).get("content-type")).toBe("application/json"); + if (!request) { + throw new Error("expected Discord request init"); + } + expect(request.method).toBe("POST"); + expect(request.body).toBe(JSON.stringify({ content: "hello" })); + expect(new Headers(request.headers).get("content-type")).toBe("application/json"); }); }); diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts index 31ecd8878c5..273f398b41d 100644 --- a/extensions/discord/src/channel.message-adapter.test.ts +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -19,20 +19,61 @@ beforeAll(async () => { ({ discordPlugin } = await import("./channel.js")); }); +type DiscordMessageAdapter = NonNullable; +type DiscordMessageSender = NonNullable; + +function requireDiscordMessageAdapter(): DiscordMessageAdapter { + const adapter = discordPlugin.message; + if (!adapter) { + throw new Error("Expected discord plugin to expose a channel message adapter"); + } + return adapter; +} + +function requireTextSender( + adapter: DiscordMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected discord message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: DiscordMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected discord message adapter media sender"); + } + return media; +} + +function requirePayloadSender( + adapter: DiscordMessageAdapter, +): NonNullable { + const payload = adapter.send?.payload; + if (!payload) { + throw new Error("Expected discord message adapter payload sender"); + } + return payload; +} + describe("discord channel message adapter", () => { beforeEach(() => { resetDiscordOutboundMocks(hoisted); }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = discordPlugin.message; - if (!adapter) { - throw new Error("Expected discord plugin to expose a channel message adapter"); - } + const adapter = requireDiscordMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); + const sendPayload = requirePayloadSender(adapter); const proveText = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:123456", text: "hello", @@ -49,7 +90,7 @@ describe("discord channel message adapter", () => { const proveMedia = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.media!({ + const result = await sendMedia({ cfg: {}, to: "channel:123456", text: "caption", @@ -69,7 +110,7 @@ describe("discord channel message adapter", () => { const provePayload = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.payload!({ + const result = await sendPayload({ cfg: {}, to: "channel:123456", text: "payload", @@ -86,7 +127,7 @@ describe("discord channel message adapter", () => { const proveReplyThreadSilent = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:parent-1", text: "threaded", @@ -110,7 +151,7 @@ describe("discord channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "discordMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, @@ -119,43 +160,44 @@ describe("discord channel message adapter", () => { replyTo: proveReplyThreadSilent, thread: proveReplyThreadSilent, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = discordPlugin.message; + const adapter = requireDiscordMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "discordMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "discordMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, discardPending: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b2c0d927193..7401cebb298 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -467,12 +467,14 @@ describe("discordPlugin outbound", () => { }); it("does not block Discord monitor startup on the startup probe", async () => { - let resolveProbe!: (value: { - ok: true; - bot: { username: string }; - application: { intents: { messageContent: "limited" } }; - elapsedMs: number; - }) => void; + let resolveProbe: + | ((value: { + ok: true; + bot: { username: string }; + application: { intents: { messageContent: "limited" } }; + elapsedMs: number; + }) => void) + | undefined; probeDiscordMock.mockReturnValue( new Promise((resolve) => { resolveProbe = resolve; @@ -501,8 +503,11 @@ describe("discordPlugin outbound", () => { includeApplication: true, }), ); - expect(statusPatches.filter((patch) => "bot" in patch || "application" in patch)).toEqual([]); + expect(statusPatches.some((patch) => "bot" in patch || "application" in patch)).toBe(false); + if (!resolveProbe) { + throw new Error("Expected Discord startup probe resolver to be initialized"); + } resolveProbe({ ok: true, bot: { username: "AsyncBob" }, diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index 3373878bfa0..c160744c8dc 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -147,6 +147,47 @@ describe("discord config schema", () => { expect(cfg.voice?.model).toBe("openai/gpt-5.4-mini"); }); + it("accepts Discord realtime voice modes", () => { + const cfg = expectValidDiscordConfig({ + voice: { + mode: "bidi", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + toolPolicy: "safe-read-only", + consultPolicy: "always", + providers: { + openai: { + apiKey: "sk-test", + voice: "marin", + }, + }, + }, + }, + }); + + expect(cfg.voice?.mode).toBe("bidi"); + expect(cfg.voice?.model).toBe("openai-codex/gpt-5.5"); + expect(cfg.voice?.realtime?.provider).toBe("openai"); + expect(cfg.voice?.realtime?.model).toBe("gpt-realtime-2"); + expect(cfg.voice?.realtime?.voice).toBe("cedar"); + expect(cfg.voice?.realtime?.toolPolicy).toBe("safe-read-only"); + expect(cfg.voice?.realtime?.consultPolicy).toBe("always"); + }); + + it("rejects invalid Discord realtime voice modes", () => { + for (const voice of [ + { mode: "realtime" }, + { mode: "bidi", realtime: { toolPolicy: "dangerous" } }, + { mode: "talk-buffer", realtime: { consultPolicy: "substantive" } }, + { mode: "talk-buffer", realtime: { debounceMs: 10_001 } }, + ]) { + expectInvalidDiscordConfig({ voice }); + } + }); + it("accepts Discord voice timing overrides", () => { const cfg = expectValidDiscordConfig({ voice: { diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index fee04e9cffc..3cd53564b31 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -179,7 +179,36 @@ export const discordChannelConfigUiHints = { }, "voice.model": { label: "Discord Voice Model", - help: "Optional LLM model override for Discord voice channel responses (for example openai/gpt-5.4-mini). Leave unset to inherit the routed agent model.", + help: "Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai-codex/gpt-5.5). Leave unset to inherit the routed agent model.", + }, + "voice.mode": { + label: "Discord Voice Mode", + help: "Conversation mode: stt-tts uses batch speech-to-text plus TTS, talk-buffer uses a realtime voice shell with the OpenClaw agent as the brain, and bidi lets the realtime provider converse directly with the OpenClaw consult tool.", + }, + "voice.realtime.provider": { + label: "Discord Realtime Provider", + help: "Realtime voice provider for talk-buffer or bidi Discord voice modes, such as openai.", + }, + "voice.realtime.model": { + label: "Discord Realtime Model", + help: "Provider realtime session model, such as gpt-realtime-2. This is separate from voice.model, which remains the OpenClaw agent brain model.", + }, + "voice.realtime.voice": { + label: "Discord Realtime Voice", + help: "Provider realtime output voice, such as cedar.", + }, + "voice.realtime.toolPolicy": { + label: "Discord Realtime Tool Policy", + help: "Tool policy for the OpenClaw agent consult tool in bidi mode: safe-read-only, owner, or none.", + }, + "voice.realtime.consultPolicy": { + label: "Discord Realtime Consult Policy", + help: "Use always to strongly prefer the OpenClaw agent brain for substantive bidi turns.", + }, + "voice.realtime.providers": { + label: "Discord Realtime Provider Settings", + help: "Provider-specific realtime voice settings keyed by provider id.", + advanced: true, }, "voice.autoJoin": { label: "Discord Voice Auto-Join", diff --git a/extensions/discord/src/internal/gateway.test.ts b/extensions/discord/src/internal/gateway.test.ts index ba37791f6d8..206bafccfbb 100644 --- a/extensions/discord/src/internal/gateway.test.ts +++ b/extensions/discord/src/internal/gateway.test.ts @@ -484,15 +484,15 @@ describe("GatewayPlugin", () => { expect(gateway.ws).toBeNull(); expect(gateway.firstHeartbeatTimeout).toBeUndefined(); expect(gateway.heartbeatInterval).toBeUndefined(); - expect(() => vi.advanceTimersByTime(20)).not.toThrow(); + vi.advanceTimersByTime(20); expect(send).not.toHaveBeenCalled(); - expect(() => + expect( ( gateway as unknown as { sendHeartbeat(): void; } ).sendHeartbeat(), - ).not.toThrow(); + ).toBeUndefined(); }); it("clears stale heartbeat timers before early reconnect exits", () => { diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index 540c1bea257..e81ac2c6815 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -38,6 +38,17 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild; +function expectNormalizedAllowList( + entries: string[], + prefixes: string[], +): NonNullable> { + const allow = normalizeDiscordAllowList(entries, prefixes); + if (allow === null) { + throw new Error("Expected allow list to be normalized"); + } + return allow; +} + const makeEntries = ( entries: Record>, ): Record => { @@ -226,14 +237,10 @@ describe("discord allowlist helpers", () => { }); it("matches ids by default and names only when enabled", () => { - const allow = normalizeDiscordAllowList( + const allow = expectNormalizedAllowList( ["123", "steipete", "Friends of OpenClaw"], ["discord:", "user:", "guild:", "channel:"], ); - expect(allow).not.toBeNull(); - if (!allow) { - throw new Error("Expected allow list to be normalized"); - } expect(allowListMatches(allow, { id: "123" })).toBe(true); expect(allowListMatches(allow, { name: "steipete" })).toBe(false); expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(false); @@ -245,11 +252,7 @@ describe("discord allowlist helpers", () => { }); it("matches pk-prefixed allowlist entries", () => { - const allow = normalizeDiscordAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]); - expect(allow).not.toBeNull(); - if (!allow) { - throw new Error("Expected allow list to be normalized"); - } + const allow = expectNormalizedAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]); expect(allowListMatches(allow, { id: "member-123" })).toBe(true); expect(allowListMatches(allow, { id: "member-999" })).toBe(false); }); @@ -266,7 +269,11 @@ describe("discord allowlist helpers", () => { allowFrom: ["*", "user:123"], sender: { id: "123" }, }); - expect(explicitOwner.ownerAllowList).not.toBeNull(); + if (explicitOwner.ownerAllowList === null) { + throw new Error("Expected explicit owner allowlist"); + } + expect(explicitOwner.ownerAllowList.allowAll).toBe(false); + expect(explicitOwner.ownerAllowList.ids).toEqual(new Set(["123"])); expect(explicitOwner.ownerAllowed).toBe(true); }); }); diff --git a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts index b88d7609c61..3f666f67ab9 100644 --- a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts +++ b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts @@ -203,9 +203,12 @@ describe("Discord ACP bind here end-to-end flow", () => { allowFrom: ["*"], }); - expect(preflight).not.toBeNull(); - expect(preflight?.boundSessionKey).toBe(binding.targetSessionKey); - expect(preflight?.route.sessionKey).toBe(binding.targetSessionKey); - expect(preflight?.route.agentId).toBe("codex"); + expect(preflight).toMatchObject({ + boundSessionKey: binding.targetSessionKey, + route: { + sessionKey: binding.targetSessionKey, + agentId: "codex", + }, + }); }); }); diff --git a/extensions/discord/src/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts index fb7cdfa1cad..3071c5fde01 100644 --- a/extensions/discord/src/monitor/agent-components.wildcard.test.ts +++ b/extensions/discord/src/monitor/agent-components.wildcard.test.ts @@ -50,7 +50,7 @@ describe("discord wildcard component registration ids", () => { const components = createWildcardComponents(); const customIds = components.map((component) => component.customId); - expect(customIds.filter((id) => id === "*")).toEqual([]); + expect(customIds.some((id) => id === "*")).toBe(false); expect(new Set(customIds).size).toBe(customIds.length); }); diff --git a/extensions/discord/src/monitor/gateway-supervisor.test.ts b/extensions/discord/src/monitor/gateway-supervisor.test.ts index 7dd18c9de23..b906b7a07c8 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.test.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.test.ts @@ -98,10 +98,10 @@ describe("createDiscordGatewaySupervisor", () => { }); expect(supervisor.drainPending(() => "continue")).toBe("continue"); - expect(() => supervisor.attachLifecycle(() => {})).not.toThrow(); - expect(() => supervisor.detachLifecycle()).not.toThrow(); - expect(() => supervisor.dispose()).not.toThrow(); - expect(() => supervisor.dispose()).not.toThrow(); + supervisor.attachLifecycle(() => {}); + supervisor.detachLifecycle(); + supervisor.dispose(); + supervisor.dispose(); }); it("keeps suppressing late gateway errors after dispose", () => { @@ -115,9 +115,7 @@ describe("createDiscordGatewaySupervisor", () => { supervisor.dispose(); - expect(() => - emitter.emit("error", new Error("Max reconnect attempts (0) reached after close code 1005")), - ).not.toThrow(); + emitter.emit("error", new Error("Max reconnect attempts (0) reached after close code 1005")); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"), ); diff --git a/extensions/discord/src/monitor/inbound-job.test.ts b/extensions/discord/src/monitor/inbound-job.test.ts index 643ed180859..0f8e373ff8f 100644 --- a/extensions/discord/src/monitor/inbound-job.test.ts +++ b/extensions/discord/src/monitor/inbound-job.test.ts @@ -88,7 +88,20 @@ describe("buildDiscordInboundJob", () => { }, ownerId: "user-1", }); - expect(() => JSON.stringify(job.payload)).not.toThrow(); + expect(JSON.parse(JSON.stringify(job.payload))).toEqual( + expect.objectContaining({ + threadChannel: { + id: "thread-1", + name: "codex", + parentId: "forum-1", + parent: { + id: "forum-1", + name: "Forum", + }, + ownerId: "user-1", + }, + }), + ); }); it("normalizes partial thread channels without reading throwing getters", async () => { @@ -115,7 +128,13 @@ describe("buildDiscordInboundJob", () => { parent: undefined, ownerId: undefined, }); - expect(() => JSON.stringify(job.payload)).not.toThrow(); + expect(JSON.parse(JSON.stringify(job.payload))).toEqual( + expect.objectContaining({ + threadChannel: { + id: "thread-1", + }, + }), + ); }); it("re-materializes the process context with an overridden abort signal", async () => { diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 874e58f9d2a..00e817c00c4 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -278,10 +278,11 @@ describe("preflightDiscordMessage configured ACP bindings", () => { }), ); - expect(result).not.toBeNull(); expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); - expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); + expect(result).toMatchObject({ + boundSessionKey: "agent:codex:acp:binding:discord:default:abc123", + }); }); it("accepts plain messages in configured ACP-bound channels without a mention", async () => { @@ -309,9 +310,10 @@ describe("preflightDiscordMessage configured ACP bindings", () => { }), ); - expect(result).not.toBeNull(); expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); - expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123"); + expect(result).toMatchObject({ + boundSessionKey: "agent:codex:acp:binding:discord:default:abc123", + }); }); it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => { diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 38254f59827..f4fa9d56f93 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -87,6 +87,17 @@ function createPreflightArgs(params: { return createDiscordPreflightArgs(params); } +type DiscordPreflightResult = NonNullable>>; + +function expectPreflightResult( + result: Awaited>, +): DiscordPreflightResult { + if (result === null) { + throw new Error("Expected Discord preflight result"); + } + return result; +} + function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { return { fetchChannel: async (channelId: string) => { @@ -386,8 +397,8 @@ describe("preflightDiscordMessage", () => { } as DiscordConfig, }); - expect(result).not.toBeNull(); - expect(result?.threadBinding).toMatchObject({ + const preflight = expectPreflightResult(result); + expect(preflight.threadBinding).toMatchObject({ conversation: { channel: "discord", accountId: "default", @@ -468,11 +479,11 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.route.agentId).toBe("newagent"); - expect(result?.route.sessionKey).toBe(`agent:newagent:discord:channel:${channelId}`); - expect(result?.boundSessionKey).toBeUndefined(); - expect(result?.threadBinding).toBeUndefined(); + const preflight = expectPreflightResult(result); + expect(preflight.route.agentId).toBe("newagent"); + expect(preflight.route.sessionKey).toBe(`agent:newagent:discord:channel:${channelId}`); + expect(preflight.boundSessionKey).toBeUndefined(); + expect(preflight.threadBinding).toBeUndefined(); }); it("preflights direct-message voice notes without mention gating", async () => { @@ -512,9 +523,9 @@ describe("preflightDiscordMessage", () => { }), }), ); - expect(result).not.toBeNull(); - expect(result?.isDirectMessage).toBe(true); - expect(result?.preflightAudioTranscript).toBe("hello openclaw from dm audio"); + const preflight = expectPreflightResult(result); + expect(preflight.isDirectMessage).toBe(true); + expect(preflight.preflightAudioTranscript).toBe("hello openclaw from dm audio"); }); it("keeps no-guild messages direct when channel lookup is unavailable", async () => { @@ -542,11 +553,11 @@ describe("preflightDiscordMessage", () => { } as DiscordConfig, }); - expect(result).not.toBeNull(); - expect(result?.channelInfo).toBeNull(); - expect(result?.isDirectMessage).toBe(true); - expect(result?.isGroupDm).toBe(false); - expect(result?.route.sessionKey).toBe("agent:main:discord:direct:user-1"); + const preflight = expectPreflightResult(result); + expect(preflight.channelInfo).toBeNull(); + expect(preflight.isDirectMessage).toBe(true); + expect(preflight.isGroupDm).toBe(false); + expect(preflight.route.sessionKey).toBe("agent:main:discord:direct:user-1"); }); it("falls back to the default discord account for omitted-account dm authorization", async () => { @@ -628,8 +639,7 @@ describe("preflightDiscordMessage", () => { registerBindingAdapter: true, }); - expect(result).not.toBeNull(); - expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); + expect(expectPreflightResult(result).boundSessionKey).toBe(threadBinding.targetSessionKey); }); it("drops hydrated bound-thread webhook copies after fetching an empty payload", async () => { @@ -759,9 +769,9 @@ describe("preflightDiscordMessage", () => { config: expect.objectContaining({ enabled: true }), }), ); - expect(result).not.toBeNull(); - expect(result?.sender.isPluralKit).toBe(true); - expect(result?.canonicalMessageId).toBe("orig-123"); + const preflight = expectPreflightResult(result); + expect(preflight.sender.isPluralKit).toBe(true); + expect(preflight.canonicalMessageId).toBe("orig-123"); }); it("skips PluralKit lookup for bound-thread webhook echoes", async () => { @@ -837,9 +847,9 @@ describe("preflightDiscordMessage", () => { }), ); - expect(result).not.toBeNull(); - expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.boundSessionKey).toBe(threadBinding.targetSessionKey); + expect(preflight.shouldRequireMention).toBe(false); }); it("drops bot messages without mention when allowBots=mentions", async () => { @@ -878,7 +888,7 @@ describe("preflightDiscordMessage", () => { const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); }); it("hydrates mention metadata from REST when bot mention syntax is present but mentions are missing", async () => { @@ -924,7 +934,7 @@ describe("preflightDiscordMessage", () => { botUserId: botId, }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); }); it("still drops bot control commands without a real mention when allowBots=mentions", async () => { @@ -963,7 +973,7 @@ describe("preflightDiscordMessage", () => { const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); }); it("routes ordinary guild text control commands through authorization instead of dropping them", async () => { @@ -1005,11 +1015,11 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.baseText).toBe("/steer keep digging"); - expect(result?.commandAuthorized).toBe(true); - expect(result?.shouldRequireMention).toBe(true); - expect(result?.shouldBypassMention).toBe(true); + const preflight = expectPreflightResult(result); + expect(preflight.baseText).toBe("/steer keep digging"); + expect(preflight.commandAuthorized).toBe(true); + expect(preflight.shouldRequireMention).toBe(true); + expect(preflight.shouldBypassMention).toBe(true); }); it("still drops Discord native command echo messages", async () => { @@ -1138,9 +1148,9 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.shouldRequireMention).toBe(true); - expect(result?.wasMentioned).toBe(true); + const preflight = expectPreflightResult(result); + expect(preflight.shouldRequireMention).toBe(true); + expect(preflight.wasMentioned).toBe(true); }); it("accepts allowlisted guild messages when guild object is missing", async () => { @@ -1173,10 +1183,10 @@ describe("preflightDiscordMessage", () => { includeGuildObject: false, }); - expect(result).not.toBeNull(); - expect(result?.guildInfo?.id).toBe("guild-1"); - expect(result?.channelConfig?.allowed).toBe(true); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.guildInfo?.id).toBe("guild-1"); + expect(preflight.channelConfig?.allowed).toBe(true); + expect(preflight.shouldRequireMention).toBe(false); }); it("inherits parent thread allowlist when guild object is missing", async () => { @@ -1221,11 +1231,11 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.guildInfo?.id).toBe("guild-1"); - expect(result?.threadParentId).toBe(parentId); - expect(result?.channelConfig?.allowed).toBe(true); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.guildInfo?.id).toBe("guild-1"); + expect(preflight.threadParentId).toBe(parentId); + expect(preflight.channelConfig?.allowed).toBe(true); + expect(preflight.shouldRequireMention).toBe(false); }); it("handles partial thread channel owner getters during mention preflight", async () => { @@ -1284,9 +1294,9 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.threadParentId).toBe(parentId); - expect(result?.shouldRequireMention).toBe(false); + const preflight = expectPreflightResult(result); + expect(preflight.threadParentId).toBe(parentId); + expect(preflight.shouldRequireMention).toBe(false); }); it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { @@ -1326,8 +1336,7 @@ describe("preflightDiscordMessage", () => { const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message }); - expect(result).not.toBeNull(); - expect(result?.hasAnyMention).toBe(true); + expect(expectPreflightResult(result).hasAnyMention).toBe(true); }); it("ignores bot-sent @everyone mentions for detection", async () => { @@ -1367,8 +1376,7 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.hasAnyMention).toBe(false); + expect(expectPreflightResult(result).hasAnyMention).toBe(false); }); it("does not treat bot-sent @everyone as wasMentioned", async () => { @@ -1408,8 +1416,7 @@ describe("preflightDiscordMessage", () => { }, }); - expect(result).not.toBeNull(); - expect(result?.wasMentioned).toBe(false); + expect(expectPreflightResult(result).wasMentioned).toBe(false); }); it("uses attachment content_type for guild audio preflight mention detection", async () => { @@ -1477,9 +1484,9 @@ describe("preflightDiscordMessage", () => { }), }), ); - expect(result).not.toBeNull(); - expect(result?.wasMentioned).toBe(true); - expect(result?.preflightAudioTranscript).toBe("hey openclaw"); + const preflight = expectPreflightResult(result); + expect(preflight.wasMentioned).toBe(true); + expect(preflight.preflightAudioTranscript).toBe("hey openclaw"); }); it("does not transcribe guild audio from unauthorized members", async () => { @@ -1622,7 +1629,7 @@ describe("preflightDiscordMessage", () => { "guild-1": { channels: { [channelId]: { enabled: true, requireMention: true } } }, }, }); - expect(result).not.toBeNull(); + expect(expectPreflightResult(result)).toEqual(expect.any(Object)); } finally { routeSpy.mockRestore(); ensureSpy.mockRestore(); @@ -1694,7 +1701,7 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => { webhookId: "wh-1", webhookToken: "tok-1", }); - expect(binding).not.toBeNull(); + expect(binding).toEqual(expect.any(Object)); manager.unbindThread({ threadId: "thread-1", diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 3f738cc3229..a1779f93f46 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -716,7 +716,7 @@ describe("processDiscordMessage ack reactions", () => { it("shows stall emojis for long no-progress runs", async () => { vi.useFakeTimers(); - let releaseDispatch!: () => void; + let releaseDispatch: (() => void) | undefined; const dispatchGate = new Promise((resolve) => { releaseDispatch = () => resolve(); }); @@ -729,6 +729,9 @@ describe("processDiscordMessage ack reactions", () => { const runPromise = runProcessDiscordMessage(ctx); await vi.advanceTimersByTimeAsync(30_001); + if (!releaseDispatch) { + throw new Error("Expected Discord dispatch release callback to be initialized"); + } releaseDispatch(); await vi.runAllTimersAsync(); @@ -1680,7 +1683,8 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenNthCalledWith(1, "Clawing...\n🧩 First\n🧩 Second"); + expect(draftStream.update).toHaveBeenNthCalledWith(2, "🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { @@ -1725,8 +1729,8 @@ describe("processDiscordMessage draft streaming", () => { kind: "analysis", title: "Reasoning", }); - await params?.replyOptions?.onReasoningStream?.({ text: "Reading " }); - await params?.replyOptions?.onReasoningStream?.({ text: "the event projector" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Reading" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Reading the event projector" }); return createNoQueuedDispatchResult(); }); @@ -1744,7 +1748,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenCalledWith( - "Clawing...\n🛠️ Exec\n• Reading the event projector", + "Clawing...\n🛠️ Exec\n• _Reading the event projector_", ); expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Reasoning")); }); @@ -1754,9 +1758,9 @@ describe("processDiscordMessage draft streaming", () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); - await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning:\n_Checking files_" }); + await params?.replyOptions?.onReasoningStream?.({ text: "Checking files" }); await params?.replyOptions?.onReasoningStream?.({ - text: "Reasoning:\n_Checking files and tests_", + text: "Checking files and tests", }); return createNoQueuedDispatchResult(); }); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 2810dc5f5bf..65324c88774 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,4 +1,8 @@ -import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { + formatReasoningMessage, + resolveAckReaction, + resolveHumanDelayConfig, +} from "openclaw/plugin-sdk/agent-runtime"; import { createStatusReactionController, DEFAULT_TIMING, @@ -665,7 +669,10 @@ export async function processDiscordMessage( draftPreview.suppressDefaultToolProgressMessages ? true : undefined, onReasoningStream: async (payload) => { await statusReactions.setThinking(); - await draftPreview.pushReasoningProgress(payload?.text); + const formattedText = payload?.text + ? formatReasoningMessage(payload.text) + : undefined; + await draftPreview.pushReasoningProgress(formattedText); }, onToolStart: async (payload) => { if (isProcessAborted(abortSignal)) { diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index 33f90079d00..86ecc74447b 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -440,7 +440,7 @@ describe("createDiscordMessageHandler queue behavior", () => { await flushQueueWork(); expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); - expect(capturedAbortSignals[0]?.aborted).not.toBe(true); + expect(capturedAbortSignals).toEqual([undefined]); expect(params.runtime.error).not.toHaveBeenCalledWith(expect.stringContaining("timed out")); firstRun.resolve(); @@ -448,7 +448,7 @@ describe("createDiscordMessageHandler queue behavior", () => { await flushQueueWork(); expect(processDiscordMessageMock).toHaveBeenCalledTimes(2); - expect(capturedAbortSignals[1]?.aborted).not.toBe(true); + expect(capturedAbortSignals).toEqual([undefined, undefined]); secondRun.resolve(); await secondRun.promise; diff --git a/extensions/discord/src/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts index c87d1e1d655..9e1fcf5d5c1 100644 --- a/extensions/discord/src/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -392,9 +392,8 @@ describe("Discord model picker rendering", () => { return parsed?.action === "provider"; }); expect(providerButtons).toHaveLength(Object.keys(entries).length); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( - false, - ); + const customIds = allButtons.map((component) => component.custom_id ?? ""); + expect(customIds).not.toEqual(expect.arrayContaining([expect.stringContaining(";a=nav;")])); }); it("does not render navigation buttons even when provider count exceeds one page", () => { @@ -419,9 +418,8 @@ describe("Discord model picker rendering", () => { expect(rows.length).toBeGreaterThan(0); const allButtons = rows.flatMap((row) => row.components ?? []); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( - false, - ); + const customIds = allButtons.map((component) => component.custom_id ?? ""); + expect(customIds).not.toEqual(expect.arrayContaining([expect.stringContaining(";a=nav;")])); }); it("supports classic fallback rendering with content + action rows", () => { @@ -497,7 +495,12 @@ describe("Discord model picker rendering", () => { throw new Error("models view did not render a provider select"); } expect(providerSelect.options?.length).toBe(2); - expect(providerSelect.options?.find((option) => option.value === "openai")?.default).toBe(true); + expect(providerSelect.options).toContainEqual( + expect.objectContaining({ + value: "openai", + default: true, + }), + ); const parsedProviderState = parseDiscordModelPickerCustomId(providerSelect.custom_id ?? ""); expect(parsedProviderState?.action).toBe("provider"); @@ -508,7 +511,12 @@ describe("Discord model picker rendering", () => { throw new Error("models view did not render a model select"); } expect(modelSelect.options?.length).toBe(3); - expect(modelSelect.options?.find((option) => option.value === "o3")?.default).toBe(true); + expect(modelSelect.options).toContainEqual( + expect.objectContaining({ + value: "o3", + default: true, + }), + ); const parsedModelSelectState = parseDiscordModelPickerCustomId(modelSelect.custom_id ?? ""); expect(parsedModelSelectState?.action).toBe("model"); @@ -579,7 +587,12 @@ describe("Discord model picker rendering", () => { expect(runtimeSelect.options?.find((option) => option.value === "pi")?.label).toBe( "OpenClaw Pi Default", ); - expect(runtimeSelect.options?.find((option) => option.value === "codex")?.default).toBe(true); + expect(runtimeSelect.options).toContainEqual( + expect.objectContaining({ + value: "codex", + default: true, + }), + ); const submitButton = rows[3]?.components?.at(-1); const submitState = requireValue( diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 52c4afbfc26..bd27e152136 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -392,7 +392,11 @@ describe("discord component interactions", () => { await button.run(secondInteraction, { cid: "btn_1" } as ComponentData); expect(dispatchReplyMock).toHaveBeenCalledTimes(2); - expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); + const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false }); + if (!entry) { + throw new Error("expected reusable Discord component entry"); + } + expect(entry.id).toBe("btn_1"); }); it("blocks buttons when allowedUsers does not match", async () => { @@ -411,7 +415,11 @@ describe("discord component interactions", () => { ephemeral: true, }); expect(dispatchReplyMock).not.toHaveBeenCalled(); - expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); + const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false }); + if (!entry) { + throw new Error("expected unauthorized Discord component entry to remain active"); + } + expect(entry.id).toBe("btn_1"); }); it("blocks buttons from guilds removed from the allowlist", async () => { @@ -590,7 +598,11 @@ describe("discord component interactions", () => { const { acknowledge } = await runModalSubmission({ reusable: true }); expect(acknowledge).toHaveBeenCalledTimes(1); - expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); + const entry = resolveDiscordModalEntry({ id: "mdl_1", consume: false }); + if (!entry) { + throw new Error("expected reusable Discord modal entry"); + } + expect(entry.id).toBe("mdl_1"); }); it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => { diff --git a/extensions/discord/src/monitor/monitor.threading-utils.test.ts b/extensions/discord/src/monitor/monitor.threading-utils.test.ts index fdd73967e8a..869998e4e09 100644 --- a/extensions/discord/src/monitor/monitor.threading-utils.test.ts +++ b/extensions/discord/src/monitor/monitor.threading-utils.test.ts @@ -245,20 +245,23 @@ describe("resolveDiscordPresenceUpdate", () => { it("returns status-only presence when activity is omitted", () => { const presence = resolveDiscordPresenceUpdate({ status: "dnd" }); - expect(presence).not.toBeNull(); - expect(presence?.status).toBe("dnd"); - expect(presence?.activities).toEqual([]); + expect(presence).toMatchObject({ + status: "dnd", + activities: [], + }); }); it("defaults to custom activity type when activity is set without type", () => { const presence = resolveDiscordPresenceUpdate({ activity: "Focus time" }); - expect(presence).not.toBeNull(); - expect(presence?.status).toBe("online"); - expect(presence?.activities).toHaveLength(1); - expect(presence?.activities[0]).toMatchObject({ - type: 4, - name: "Custom Status", - state: "Focus time", + expect(presence).toMatchObject({ + status: "online", + activities: [ + expect.objectContaining({ + type: 4, + name: "Custom Status", + state: "Focus time", + }), + ], }); }); @@ -268,12 +271,14 @@ describe("resolveDiscordPresenceUpdate", () => { activityType: 1, activityUrl: "https://twitch.tv/openclaw", }); - expect(presence).not.toBeNull(); - expect(presence?.activities).toHaveLength(1); - expect(presence?.activities[0]).toMatchObject({ - type: 1, - name: "Live", - url: "https://twitch.tv/openclaw", + expect(presence).toMatchObject({ + activities: [ + expect.objectContaining({ + type: 1, + name: "Live", + url: "https://twitch.tv/openclaw", + }), + ], }); }); }); @@ -331,17 +336,16 @@ describe("resolveDiscordAutoThreadContext", () => { continue; } - expect(context, testCase.name).not.toBeNull(); - expect(context?.To, testCase.name).toBe("channel:thread"); - expect(context?.From, testCase.name).toBe("discord:channel:thread"); - expect(context?.OriginatingTo, testCase.name).toBe("channel:thread"); - expect(context?.SessionKey, testCase.name).toBe( - buildAgentSessionKey({ + expect(context, testCase.name).toMatchObject({ + To: "channel:thread", + From: "discord:channel:thread", + OriginatingTo: "channel:thread", + SessionKey: buildAgentSessionKey({ agentId: "agent", channel: "discord", peer: { kind: "channel", id: "thread" }, }), - ); + }); expect(context?.ParentSessionKey, testCase.name).toBe(testCase.expectedParentSessionKey); expect(context?.ModelParentSessionKey, testCase.name).toBe( testCase.expectedModelParentSessionKey, diff --git a/extensions/discord/src/monitor/presence.test.ts b/extensions/discord/src/monitor/presence.test.ts index 1ea06f9dc28..6c1d117105b 100644 --- a/extensions/discord/src/monitor/presence.test.ts +++ b/extensions/discord/src/monitor/presence.test.ts @@ -1,44 +1,61 @@ import { describe, expect, it } from "vitest"; import { resolveDiscordPresenceUpdate } from "./presence.js"; +type DiscordPresenceUpdate = NonNullable>; + +function expectPresenceUpdate( + result: ReturnType, +): DiscordPresenceUpdate { + expect(result).toEqual(expect.objectContaining({ activities: expect.any(Array) })); + if (result === null) { + throw new Error("Expected Discord presence update"); + } + return result; +} + describe("resolveDiscordPresenceUpdate", () => { it("returns online presence when no config is provided", () => { - const result = resolveDiscordPresenceUpdate({}); - expect(result).not.toBeNull(); - expect(result!.status).toBe("online"); - expect(result!.activities).toEqual([]); + const result = expectPresenceUpdate(resolveDiscordPresenceUpdate({})); + expect(result.status).toBe("online"); + expect(result.activities).toEqual([]); }); it("uses configured status", () => { - const result = resolveDiscordPresenceUpdate({ status: "dnd" }); - expect(result!.status).toBe("dnd"); + const result = expectPresenceUpdate(resolveDiscordPresenceUpdate({ status: "dnd" })); + expect(result.status).toBe("dnd"); }); it("includes activity when configured", () => { - const result = resolveDiscordPresenceUpdate({ activity: "Helping humans" }); - expect(result!.status).toBe("online"); - expect(result!.activities).toHaveLength(1); - expect(result!.activities[0].state).toBe("Helping humans"); + const result = expectPresenceUpdate( + resolveDiscordPresenceUpdate({ activity: "Helping humans" }), + ); + expect(result.status).toBe("online"); + expect(result.activities).toHaveLength(1); + expect(result.activities[0].state).toBe("Helping humans"); }); it("uses custom activity type by default", () => { - const result = resolveDiscordPresenceUpdate({ activity: "test" }); - expect(result!.activities[0].type).toBe(4); - expect(result!.activities[0].name).toBe("Custom Status"); + const result = expectPresenceUpdate(resolveDiscordPresenceUpdate({ activity: "test" })); + expect(result.activities[0].type).toBe(4); + expect(result.activities[0].name).toBe("Custom Status"); }); it("respects explicit activityType", () => { - const result = resolveDiscordPresenceUpdate({ activity: "test", activityType: 3 }); - expect(result!.activities[0].type).toBe(3); - expect(result!.activities[0].name).toBe("test"); + const result = expectPresenceUpdate( + resolveDiscordPresenceUpdate({ activity: "test", activityType: 3 }), + ); + expect(result.activities[0].type).toBe(3); + expect(result.activities[0].name).toBe("test"); }); it("sets streaming URL for type 1", () => { - const result = resolveDiscordPresenceUpdate({ - activity: "Live", - activityType: 1, - activityUrl: "https://twitch.tv/test", - }); - expect(result!.activities[0].url).toBe("https://twitch.tv/test"); + const result = expectPresenceUpdate( + resolveDiscordPresenceUpdate({ + activity: "Live", + activityType: 1, + activityUrl: "https://twitch.tv/test", + }), + ); + expect(result.activities[0].url).toBe("https://twitch.tv/test"); }); }); diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index f87bb2d66ec..c6390da5ccc 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -327,9 +327,9 @@ describe("monitorDiscordProvider", () => { expect(monitorLifecycleMock).not.toHaveBeenCalled(); expect(disconnect).toHaveBeenCalledTimes(1); - expect(() => + expect( emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1005")), - ).not.toThrow(); + ).toBe(true); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"), ); @@ -1105,26 +1105,26 @@ describe("monitorDiscordProvider", () => { }); await vi.waitFor(() => - expect( - vi - .mocked(runtime.log) - .mock.calls.some((call) => String(call[0]).includes("deploy-commands:done")), - ).toBe(true), + expect(vi.mocked(runtime.log).mock.calls.map((call) => String(call[0]))).toEqual( + expect.arrayContaining([expect.stringContaining("deploy-commands:done")]), + ), ); const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); - expect(messages.some((msg) => msg.includes("fetch-application-id:start"))).toBe(true); - expect(messages.some((msg) => msg.includes("fetch-application-id:done"))).toBe(true); - expect(messages.some((msg) => msg.includes("deploy-commands:schedule"))).toBe(true); - expect(messages.some((msg) => msg.includes("deploy-commands:scheduled"))).toBe(true); - expect(messages.some((msg) => msg.includes("deploy-commands:done"))).toBe(true); - expect(messages.some((msg) => msg.includes("fetch-bot-identity:start"))).toBe(true); - expect(messages.some((msg) => msg.includes("fetch-bot-identity:done"))).toBe(true); - expect( - messages.some( - (msg) => msg.includes("gateway-debug") && msg.includes("Gateway websocket opened"), - ), - ).toBe(true); + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining("fetch-application-id:start"), + expect.stringContaining("fetch-application-id:done"), + expect.stringContaining("deploy-commands:schedule"), + expect.stringContaining("deploy-commands:scheduled"), + expect.stringContaining("deploy-commands:done"), + expect.stringContaining("fetch-bot-identity:start"), + expect.stringContaining("fetch-bot-identity:done"), + ]), + ); + expect(messages).toEqual( + expect.arrayContaining([expect.stringMatching(/gateway-debug.*Gateway websocket opened/)]), + ); }); it("keeps Discord startup chatter quiet by default", async () => { @@ -1136,6 +1136,8 @@ describe("monitorDiscordProvider", () => { }); const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); - expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); + expect(messages).not.toEqual( + expect.arrayContaining([expect.stringContaining("discord startup [")]), + ); }); }); diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index ee2d777cc43..a7fa3e7cbbb 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -288,7 +288,7 @@ describe("thread binding lifecycle", () => { webhookToken: "tok-1", introText: "intro", }); - expect(binding).not.toBeNull(); + expect(binding).toEqual(expect.any(Object)); hoisted.sendMessageDiscord.mockClear(); hoisted.sendWebhookMessageDiscord.mockClear(); @@ -327,7 +327,7 @@ describe("thread binding lifecycle", () => { webhookId: "wh-1", webhookToken: "tok-1", }); - expect(binding).not.toBeNull(); + expect(binding).toEqual(expect.any(Object)); hoisted.sendMessageDiscord.mockClear(); await vi.advanceTimersByTimeAsync(120_000); @@ -656,7 +656,7 @@ describe("thread binding lifecycle", () => { vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z")); const touched = manager.touchThread({ threadId: "thread-1", persist: false }); - expect(touched).not.toBeNull(); + expect(touched).toEqual(expect.any(Object)); const record = requireBinding(manager, "thread-1"); expect(record.lastActivityAt).toBe(new Date("2026-02-20T00:00:30.000Z").getTime()); @@ -746,7 +746,7 @@ describe("thread binding lifecycle", () => { targetSessionKey: "agent:main:subagent:child-1", agentId: "main", }); - expect(first).not.toBeNull(); + expect(first).toEqual(expect.any(Object)); expect(hoisted.restPost).toHaveBeenCalledTimes(1); manager.unbindThread({ @@ -761,9 +761,10 @@ describe("thread binding lifecycle", () => { targetSessionKey: "agent:main:subagent:child-2", agentId: "main", }); - expect(second).not.toBeNull(); - expect(second?.webhookId).toBe("wh-created"); - expect(second?.webhookToken).toBe("tok-created"); + expect(second).toMatchObject({ + webhookId: "wh-created", + webhookToken: "tok-created", + }); expect(hoisted.restPost).toHaveBeenCalledTimes(1); }); @@ -796,7 +797,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(childBinding).not.toBeNull(); + expect(childBinding).toEqual(expect.any(Object)); expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1); expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( "parent-1", @@ -836,8 +837,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(childBinding).not.toBeNull(); - expect(childBinding?.channelId).toBe("parent-1"); + expect(childBinding).toMatchObject({ channelId: "parent-1" }); expect(hoisted.restGet).toHaveBeenCalledTimes(1); expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( "parent-1", @@ -879,7 +879,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(childBinding).not.toBeNull(); + expect(childBinding).toEqual(expect.any(Object)); const firstClientArgs = hoisted.createDiscordRestClient.mock.calls[0]?.[0] as | { accountId?: string; token?: string } | undefined; @@ -929,7 +929,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(bound).not.toBeNull(); + expect(bound).toEqual(expect.any(Object)); const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { if (call?.[1] === refreshedCfg) { return true; @@ -986,7 +986,7 @@ describe("thread binding lifecycle", () => { agentId: "main", }); - expect(bound).not.toBeNull(); + expect(bound).toEqual(expect.any(Object)); expect(hoisted.createThreadDiscord).toHaveBeenCalledWith( "parent-runtime", expect.objectContaining({ autoArchiveMinutes: 60 }), diff --git a/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts b/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts index f852908d819..1f2cab9e739 100644 --- a/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.shared-state.test.ts @@ -30,7 +30,8 @@ describe("thread binding manager state", () => { enableSweeper: false, }); - expect(getThreadBindingManager("work")).not.toBeNull(); - expect(viaAlternateLoader.getThreadBindingManager("work")).not.toBeNull(); + const direct = getThreadBindingManager("work"); + expect(direct).toEqual(expect.any(Object)); + expect(viaAlternateLoader.getThreadBindingManager("work")).toBe(direct); }); }); diff --git a/extensions/discord/src/outbound-adapter.interactive-order.test.ts b/extensions/discord/src/outbound-adapter.interactive-order.test.ts index e66c14f84b8..fc9a7e368f5 100644 --- a/extensions/discord/src/outbound-adapter.interactive-order.test.ts +++ b/extensions/discord/src/outbound-adapter.interactive-order.test.ts @@ -10,6 +10,16 @@ await installDiscordOutboundModuleSpies(hoisted); const { discordOutbound } = await import("./outbound-adapter.js"); +type DiscordSendPayload = NonNullable; + +function requireDiscordSendPayload(): DiscordSendPayload { + const sendPayload = discordOutbound.sendPayload; + if (!sendPayload) { + throw new Error("Expected Discord outbound sendPayload"); + } + return sendPayload; +} + describe("discordOutbound shared interactive ordering", () => { beforeEach(() => { resetDiscordOutboundMocks(hoisted); @@ -20,7 +30,8 @@ describe("discordOutbound shared interactive ordering", () => { }); it("keeps shared text blocks in authored order without hoisting fallback text", async () => { - const result = await discordOutbound.sendPayload!({ + const sendPayload = requireDiscordSendPayload(); + const result = await sendPayload({ cfg: {}, to: "channel:123456", text: "", diff --git a/extensions/discord/src/outbound-payload.contract.test.ts b/extensions/discord/src/outbound-payload.contract.test.ts index a6349938103..ad4f3ac2dd2 100644 --- a/extensions/discord/src/outbound-payload.contract.test.ts +++ b/extensions/discord/src/outbound-payload.contract.test.ts @@ -6,6 +6,16 @@ import { import { describe, vi } from "vitest"; import { discordOutbound } from "./outbound-adapter.js"; +type DiscordSendPayload = NonNullable; + +function requireDiscordSendPayload(): DiscordSendPayload { + const sendPayload = discordOutbound.sendPayload; + if (!sendPayload) { + throw new Error("Expected Discord outbound sendPayload"); + } + return sendPayload; +} + function createDiscordHarness(params: OutboundPayloadHarnessParams) { const sendDiscord = vi.fn(); primeChannelOutboundSendMock( @@ -22,8 +32,9 @@ function createDiscordHarness(params: OutboundPayloadHarnessParams) { sendDiscord, }, }; + const sendPayload = requireDiscordSendPayload(); return { - run: async () => await discordOutbound.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: sendDiscord, to: ctx.to, }; diff --git a/extensions/discord/src/proxy-request-client.test.ts b/extensions/discord/src/proxy-request-client.test.ts index 9e0ca64226f..3509708d87f 100644 --- a/extensions/discord/src/proxy-request-client.test.ts +++ b/extensions/discord/src/proxy-request-client.test.ts @@ -51,7 +51,10 @@ describe("createDiscordRequestClient", () => { client.abortAllRequests(); await expect(request).rejects.toThrow(); - expect(abortable.receivedSignal?.aborted).toBe(true); + if (!abortable.receivedSignal) { + throw new Error("Expected proxied fetch abort signal"); + } + expect(abortable.receivedSignal.aborted).toBe(true); }); it("provides the REST client's timeout signal even without a caller signal", async () => { diff --git a/extensions/discord/src/resolve-allowlist-common.test.ts b/extensions/discord/src/resolve-allowlist-common.test.ts index 338fae1bd0d..a110d54832e 100644 --- a/extensions/discord/src/resolve-allowlist-common.test.ts +++ b/extensions/discord/src/resolve-allowlist-common.test.ts @@ -13,7 +13,11 @@ describe("resolve-allowlist-common", () => { ]; it("resolves and filters guilds by id or name", () => { - expect(findDiscordGuildByName(guilds, "Main Guild")?.id).toBe("1"); + const mainGuild = findDiscordGuildByName(guilds, "Main Guild"); + if (!mainGuild) { + throw new Error("expected Main Guild lookup result"); + } + expect(mainGuild.id).toBe("1"); expect(filterDiscordGuilds(guilds, { guildId: "2" })).toEqual([guilds[1]]); expect(filterDiscordGuilds(guilds, { guildName: "main-guild" })).toEqual([guilds[0]]); }); diff --git a/extensions/discord/src/send.permissions.authz.test.ts b/extensions/discord/src/send.permissions.authz.test.ts index 62b20433f95..74e9c05c389 100644 --- a/extensions/discord/src/send.permissions.authz.test.ts +++ b/extensions/discord/src/send.permissions.authz.test.ts @@ -82,11 +82,13 @@ describe("discord guild permission authorization", () => { "user-1", EMPTY_DISCORD_TEST_OPTS, ); - expect(result).not.toBeNull(); - expect((result! & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe( + if (result === null) { + throw new Error("Expected guild permissions bitfield"); + } + expect((result & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe( true, ); - expect((result! & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe( + expect((result & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe( true, ); }); diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index feee9c0175d..e00eb3f56cb 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -50,6 +50,18 @@ describe("ensureOggOpus", () => { runFfprobeMock.mockReset(); runFfmpegMock.mockReset(); }); + + function expectStagedFfmpegOutput(ffmpegOutputPath: string | undefined, finalPath: string) { + expect(ffmpegOutputPath).toBeTypeOf("string"); + if (typeof ffmpegOutputPath !== "string") { + throw new Error("missing ffmpeg output path"); + } + expect(ffmpegOutputPath).not.toBe(finalPath); + const stagedBase = path.basename(ffmpegOutputPath); + expect(stagedBase.startsWith(".fs-safe-output-")).toBe(true); + expect(stagedBase.endsWith(`-${path.basename(finalPath)}.part`)).toBe(true); + } + it("rejects URL/protocol input paths", async () => { await expect(ensureOggOpus("https://example.com/audio.ogg")).rejects.toThrow( /local file path/i, @@ -90,8 +102,7 @@ describe("ensureOggOpus", () => { expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg"]), ); const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1); - expect(ffmpegOutputPath).not.toBe(result.path); - expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path)); + expectStagedFfmpegOutput(ffmpegOutputPath, result.path); await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg"); }); @@ -113,8 +124,7 @@ describe("ensureOggOpus", () => { expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3"]), ); const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1); - expect(ffmpegOutputPath).not.toBe(result.path); - expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path)); + expectStagedFfmpegOutput(ffmpegOutputPath, result.path); await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg"); }); }); diff --git a/extensions/discord/src/voice/audio.ts b/extensions/discord/src/voice/audio.ts index fe305059295..1273382e1d9 100644 --- a/extensions/discord/src/voice/audio.ts +++ b/extensions/discord/src/voice/audio.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import { createRequire } from "node:module"; import type { Readable } from "node:stream"; +import { resamplePcm } from "openclaw/plugin-sdk/realtime-voice"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; @@ -140,6 +141,67 @@ export async function decodeOpusStream( return chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0); } +export async function decodeOpusStreamChunks( + stream: Readable, + params: { + onChunk: (pcm48kStereo: Buffer) => void; + onVerbose: (message: string) => void; + onWarn: (message: string) => void; + }, +): Promise { + const selected = createOpusDecoder({ onWarn: params.onWarn }); + if (!selected) { + return; + } + params.onVerbose(`opus decoder: ${selected.name}`); + try { + for await (const chunk of stream) { + if (!chunk || !(chunk instanceof Buffer) || chunk.length === 0) { + continue; + } + const decoded = selected.decoder.decode(chunk); + if (decoded && decoded.length > 0) { + params.onChunk(Buffer.from(decoded)); + } + } + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`discord voice: opus decode failed: ${formatErrorMessage(err)}`); + } + } +} + +export function convertDiscordPcm48kStereoToRealtimePcm24kMono(pcm: Buffer): Buffer { + const frameCount = Math.floor(pcm.length / 4); + if (frameCount === 0) { + return Buffer.alloc(0); + } + const mono48k = Buffer.alloc(frameCount * 2); + for (let frame = 0; frame < frameCount; frame += 1) { + const offset = frame * 4; + const left = pcm.readInt16LE(offset); + const right = pcm.readInt16LE(offset + 2); + mono48k.writeInt16LE(Math.round((left + right) / 2), frame * 2); + } + return resamplePcm(mono48k, SAMPLE_RATE, 24_000); +} + +export function convertRealtimePcm24kMonoToDiscordPcm48kStereo(pcm: Buffer): Buffer { + const mono48k = resamplePcm(pcm, 24_000, SAMPLE_RATE); + const sampleCount = Math.floor(mono48k.length / 2); + if (sampleCount === 0) { + return Buffer.alloc(0); + } + const stereo = Buffer.alloc(sampleCount * 4); + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const sample = mono48k.readInt16LE(sampleIndex * 2); + const offset = sampleIndex * 4; + stereo.writeInt16LE(sample, offset); + stereo.writeInt16LE(sample, offset + 2); + } + return stereo; +} + function estimateDurationSeconds(pcm: Buffer): number { const bytesPerSample = (BIT_DEPTH / 8) * CHANNELS; if (bytesPerSample <= 0) { diff --git a/extensions/discord/src/voice/ingress.ts b/extensions/discord/src/voice/ingress.ts new file mode 100644 index 00000000000..33863383e9c --- /dev/null +++ b/extensions/discord/src/voice/ingress.ts @@ -0,0 +1,119 @@ +import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { formatMention } from "../mentions.js"; +import { normalizeDiscordSlug } from "../monitor/allow-list.js"; +import { buildDiscordGroupSystemPrompt } from "../monitor/inbound-context.js"; +import { authorizeDiscordVoiceIngress } from "./access.js"; +import type { VoiceSessionEntry } from "./session.js"; +import type { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; + +export const DISCORD_VOICE_MESSAGE_PROVIDER = "discord-voice"; + +export type DiscordVoiceIngressContext = { + extraSystemPrompt?: string; + senderIsOwner: boolean; + speakerLabel: string; +}; + +export type DiscordVoiceAgentTurnResult = { + context: DiscordVoiceIngressContext; + text: string; +}; + +export async function resolveDiscordVoiceIngressContext(params: { + entry: VoiceSessionEntry; + userId: string; + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + ownerAllowFrom?: string[]; + fetchGuildName: (guildId: string) => Promise; + speakerContext: DiscordVoiceSpeakerContextResolver; +}): Promise { + const { entry, userId } = params; + if (!entry.guildName) { + entry.guildName = await params.fetchGuildName(entry.guildId); + } + const speaker = await params.speakerContext.resolveContext(entry.guildId, userId); + const speakerIdentity = await params.speakerContext.resolveIdentity(entry.guildId, userId); + const access = await authorizeDiscordVoiceIngress({ + cfg: params.cfg, + discordConfig: params.discordConfig, + guildName: entry.guildName, + guildId: entry.guildId, + channelId: entry.channelId, + channelName: entry.channelName, + channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", + channelLabel: formatMention({ channelId: entry.channelId }), + memberRoleIds: speakerIdentity.memberRoleIds, + ownerAllowFrom: params.ownerAllowFrom, + sender: { + id: speakerIdentity.id, + name: speakerIdentity.name, + tag: speakerIdentity.tag, + }, + }); + if (!access.ok) { + return null; + } + return { + extraSystemPrompt: buildDiscordGroupSystemPrompt(access.channelConfig), + senderIsOwner: speaker.senderIsOwner, + speakerLabel: speaker.label, + }; +} + +export async function runDiscordVoiceAgentTurn(params: { + entry: VoiceSessionEntry; + userId: string; + message: string; + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + runtime: RuntimeEnv; + context?: DiscordVoiceIngressContext; + toolsAllow?: string[]; + ownerAllowFrom?: string[]; + fetchGuildName: (guildId: string) => Promise; + speakerContext: DiscordVoiceSpeakerContextResolver; +}): Promise { + const context = + params.context ?? + (await resolveDiscordVoiceIngressContext({ + entry: params.entry, + userId: params.userId, + cfg: params.cfg, + discordConfig: params.discordConfig, + ownerAllowFrom: params.ownerAllowFrom, + fetchGuildName: params.fetchGuildName, + speakerContext: params.speakerContext, + })); + if (!context) { + return null; + } + const voiceModel = normalizeOptionalString(params.discordConfig.voice?.model); + const result = await agentCommandFromIngress( + { + message: params.message, + sessionKey: params.entry.route.sessionKey, + agentId: params.entry.route.agentId, + messageChannel: "discord", + messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER, + extraSystemPrompt: context.extraSystemPrompt, + senderIsOwner: context.senderIsOwner, + allowModelOverride: Boolean(voiceModel), + model: voiceModel, + toolsAllow: params.toolsAllow, + deliver: false, + }, + params.runtime, + ); + return { + context, + text: (result.payloads ?? []) + .map((payload) => payload.text) + .filter((text) => typeof text === "string" && text.trim()) + .join("\n") + .trim(), + }; +} diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 59d178e238b..359aa2cad21 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -17,6 +17,10 @@ const { textToSpeechStreamMock, textToSpeechMock, logVerboseMock, + resolveConfiguredRealtimeVoiceProviderMock, + createRealtimeVoiceBridgeSessionMock, + realtimeSessionMock, + decodeOpusStreamChunksMock, } = vi.hoisted(() => { type EventHandler = (...args: unknown[]) => unknown; type MockConnection = { @@ -90,6 +94,19 @@ const { const getVoiceConnectionMock = vi.fn((): MockConnection | undefined => undefined); + const realtimeSessionMock = { + bridge: { supportsToolResultContinuation: true }, + acknowledgeMark: vi.fn(), + close: vi.fn(), + connect: vi.fn(async () => undefined), + sendAudio: vi.fn(), + sendUserMessage: vi.fn(), + handleBargeIn: vi.fn(), + setMediaTimestamp: vi.fn(), + submitToolResult: vi.fn(), + triggerGreeting: vi.fn(), + }; + return { createConnectionMock, getVoiceConnectionMock, @@ -106,13 +123,25 @@ const { state: { status: "idle" }, })), resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })), - agentCommandMock: vi.fn(async (_opts?: unknown, _runtime?: unknown) => ({ payloads: [] })), + agentCommandMock: vi.fn( + async ( + _opts?: unknown, + _runtime?: unknown, + ): Promise<{ payloads?: Array<{ text?: string }> }> => ({ payloads: [] }), + ), transcribeAudioFileMock: vi.fn(async () => ({ text: "hello from voice" })), textToSpeechStreamMock: vi.fn( async (): Promise => ({ success: false, error: "stream unavailable" }), ), textToSpeechMock: vi.fn(async () => ({ success: true, audioPath: "/tmp/voice.mp3" })), logVerboseMock: vi.fn(), + resolveConfiguredRealtimeVoiceProviderMock: vi.fn(() => ({ + provider: { id: "openai" }, + providerConfig: { model: "gpt-realtime-2", voice: "cedar" }, + })), + createRealtimeVoiceBridgeSessionMock: vi.fn((_params?: unknown) => realtimeSessionMock), + realtimeSessionMock, + decodeOpusStreamChunksMock: vi.fn(), }; }); @@ -121,6 +150,7 @@ vi.mock("./sdk-runtime.js", () => ({ AudioPlayerStatus: { Playing: "playing", Idle: "idle" }, EndBehaviorType: { AfterSilence: "AfterSilence", Manual: "Manual" }, NetworkingStatusCode: { Ready: "networking-ready", Resuming: "networking-resuming" }, + StreamType: { Raw: "raw" }, VoiceConnectionStatus: { Ready: "ready", Disconnected: "disconnected", @@ -166,6 +196,25 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => { }; }); +vi.mock("openclaw/plugin-sdk/realtime-voice", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/realtime-voice", + ); + return { + ...actual, + createRealtimeVoiceBridgeSession: createRealtimeVoiceBridgeSessionMock, + resolveConfiguredRealtimeVoiceProvider: resolveConfiguredRealtimeVoiceProviderMock, + }; +}); + +vi.mock("./audio.js", async () => { + const actual = await vi.importActual("./audio.js"); + return { + ...actual, + decodeOpusStreamChunks: decodeOpusStreamChunksMock, + }; +}); + vi.mock("../runtime.js", () => ({ getDiscordRuntime: () => ({ mediaUnderstanding: { @@ -232,6 +281,21 @@ describe("DiscordVoiceManager", () => { textToSpeechMock.mockResolvedValue({ success: true, audioPath: "/tmp/voice.mp3" }); logVerboseMock.mockClear(); createAudioResourceMock.mockClear(); + realtimeSessionMock.close.mockClear(); + realtimeSessionMock.connect.mockClear(); + realtimeSessionMock.sendAudio.mockClear(); + realtimeSessionMock.sendUserMessage.mockClear(); + realtimeSessionMock.handleBargeIn.mockClear(); + realtimeSessionMock.submitToolResult.mockClear(); + createRealtimeVoiceBridgeSessionMock.mockClear(); + createRealtimeVoiceBridgeSessionMock.mockReturnValue(realtimeSessionMock); + resolveConfiguredRealtimeVoiceProviderMock.mockClear(); + resolveConfiguredRealtimeVoiceProviderMock.mockReturnValue({ + provider: { id: "openai" }, + providerConfig: { model: "gpt-realtime-2", voice: "cedar" }, + }); + decodeOpusStreamChunksMock.mockReset(); + decodeOpusStreamChunksMock.mockResolvedValue(undefined); }); const createManager = ( @@ -276,7 +340,12 @@ describe("DiscordVoiceManager", () => { const getLastAudioPlayer = () => { const player = createAudioPlayerMock.mock.results.at(-1)?.value as - | { state: { status: string }; stop: ReturnType } + | { + on: ReturnType; + play: ReturnType; + state: { status: string }; + stop: ReturnType; + } | undefined; if (!player) { throw new Error("expected Discord voice audio player to be created"); @@ -412,7 +481,7 @@ describe("DiscordVoiceManager", () => { expectConnectedStatus(manager, "1002"); }); - it("does not throw when stale tracked voice connections are already destroyed", async () => { + it("skips destroying stale tracked voice connections that are already destroyed", async () => { const staleConnection = createConnectionMock(); staleConnection.state.status = "destroyed"; staleConnection.destroy.mockImplementation(() => { @@ -429,7 +498,7 @@ describe("DiscordVoiceManager", () => { expect(staleConnection.destroy).not.toHaveBeenCalled(); }); - it("does not throw when leaving an already destroyed voice connection", async () => { + it("skips destroying an already destroyed voice connection on leave", async () => { const connection = createConnectionMock(); connection.destroy.mockImplementation(() => { throw new Error("Cannot destroy VoiceConnection - it has already been destroyed"); @@ -558,6 +627,578 @@ describe("DiscordVoiceManager", () => { expect(manager.status()).toEqual([]); }); + it("closes realtime sessions when disconnected recovery destroys the connection", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + entersStateMock.mockClear(); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + + const disconnected = connection.handlers.get("disconnected"); + expect(disconnected).toBeTypeOf("function"); + await disconnected?.(); + + expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1); + expect(connection.destroy).toHaveBeenCalledTimes(1); + expect(manager.status()).toEqual([]); + }); + + it("closes realtime sessions when Discord destroys the connection", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + const destroyed = connection.handlers.get("destroyed"); + expect(destroyed).toBeTypeOf("function"); + destroyed?.(); + + expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1); + expect(connection.destroy).not.toHaveBeenCalled(); + expect(manager.status()).toEqual([]); + }); + + it("starts Discord realtime voice in talk-buffer mode", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "buffered brain answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + debounceMs: 1, + }, + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + configuredProviderId: "openai", + defaultModel: "gpt-realtime-2", + providerConfigOverrides: { model: "gpt-realtime-2", voice: "cedar" }, + }), + ); + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + autoRespondToAudio?: boolean; + tools?: unknown[]; + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + expect(bridgeParams?.autoRespondToAudio).toBe(false); + expect(bridgeParams?.tools).toEqual([]); + + bridgeParams?.onTranscript?.("user", "what did I ask?", true); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: "openai-codex/gpt-5.5", + messageProvider: "discord-voice", + }), + expect.anything(), + ); + expect(realtimeSessionMock.sendUserMessage).toHaveBeenCalledWith( + expect.stringContaining("buffered brain answer"), + ); + }); + + it("creates a fresh realtime output stream after the Discord player idles", async () => { + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + const player = getLastAudioPlayer() as { + on: ReturnType; + play: ReturnType; + }; + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + audioSink?: { + sendAudio: (audio: Buffer) => void; + }; + } + | undefined; + + bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480)); + expect(createAudioResourceMock).toHaveBeenCalledTimes(1); + expect(player.play).toHaveBeenCalledTimes(1); + + const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as + | (() => void) + | undefined; + expect(idleHandler).toBeTypeOf("function"); + idleHandler?.(); + + bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480)); + expect(createAudioResourceMock).toHaveBeenCalledTimes(2); + expect(player.play).toHaveBeenCalledTimes(2); + }); + + it("applies Discord realtime model and voice overrides during provider auto-selection", async () => { + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { + model: "gpt-realtime-2", + voice: "cedar", + providers: { + openai: { model: "provider-default", voice: "marin" }, + }, + }, + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + configuredProviderId: undefined, + defaultModel: "gpt-realtime-2", + providerConfigs: expect.objectContaining({ + openai: { model: "provider-default", voice: "marin" }, + }), + providerConfigOverrides: { model: "gpt-realtime-2", voice: "cedar" }, + }), + ); + }); + + it("keeps talk-buffer realtime transcripts on the audio turn speaker context", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "non-owner answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai", debounceMs: 1 }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const nonOwnerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + nonOwnerTurn?.sendInputAudio(Buffer.alloc(8)); + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + bridgeParams?.onTranscript?.("user", "non-owner question", true); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + }), + expect.anything(), + ); + }); + + it("expires closed talk-buffer turns before later speaker audio", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai", debounceMs: 1 }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = getSessionEntry(manager) as { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + }; + const ownerTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + ownerTurn?.close(); + const guestTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + guestTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void; + } + | undefined; + bridgeParams?.onTranscript?.("user", "guest question", true); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + }), + expect.anything(), + ); + }); + + it("starts Discord realtime voice in bidi mode with the consult tool", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "consult answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "bidi", + model: "openai-codex/gpt-5.5", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + voice: "cedar", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + autoRespondToAudio?: boolean; + instructions?: string; + tools?: Array<{ name: string }>; + onToolCall?: ( + event: { + itemId: string; + callId: string; + name: string; + args: unknown; + }, + session: typeof realtimeSessionMock, + ) => void; + } + | undefined; + expect(bridgeParams?.autoRespondToAudio).toBe(true); + expect(bridgeParams?.instructions).toContain("Call openclaw_agent_consult"); + expect(bridgeParams?.tools?.map((tool) => tool.name)).toContain("openclaw_agent_consult"); + + bridgeParams?.onToolCall?.( + { + itemId: "item-1", + callId: "call-1", + name: "openclaw_agent_consult", + args: { question: "check my Discord" }, + }, + realtimeSessionMock, + ); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith( + "call-1", + expect.objectContaining({ status: "working" }), + { willContinue: true }, + ); + expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-1", { + text: "consult answer", + }); + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: true, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }), + expect.anything(), + ); + }); + + it("keeps bidi realtime consults on the audio turn speaker context", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest consult answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "bidi", + realtime: { + provider: "openai", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + } + | undefined; + const nonOwnerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + nonOwnerTurn?.sendInputAudio(Buffer.alloc(8)); + const ownerTurn = entry?.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onToolCall?: ( + event: { + itemId: string; + callId: string; + name: string; + args: unknown; + }, + session: typeof realtimeSessionMock, + ) => void; + } + | undefined; + bridgeParams?.onToolCall?.( + { + itemId: "item-guest", + callId: "call-guest", + name: "openclaw_agent_consult", + args: { question: "guest question" }, + }, + realtimeSessionMock, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }), + expect.anything(), + ); + }); + + it("expires closed bidi turns before later speaker consults", async () => { + agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest consult answer" }] }); + const manager = createManager({ + groupPolicy: "open", + voice: { + enabled: true, + mode: "bidi", + realtime: { + provider: "openai", + toolPolicy: "safe-read-only", + consultPolicy: "always", + }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = getSessionEntry(manager) as { + realtime?: { + beginSpeakerTurn: ( + context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string }, + userId: string, + ) => { close: () => void; sendInputAudio: (audio: Buffer) => void }; + }; + }; + const ownerTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" }, + "u-owner", + ); + ownerTurn?.sendInputAudio(Buffer.alloc(8)); + ownerTurn?.close(); + const guestTurn = entry.realtime?.beginSpeakerTurn( + { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" }, + "u-guest", + ); + guestTurn?.sendInputAudio(Buffer.alloc(8)); + + const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as + | { + onToolCall?: ( + event: { + itemId: string; + callId: string; + name: string; + args: unknown; + }, + session: typeof realtimeSessionMock, + ) => void; + } + | undefined; + bridgeParams?.onToolCall?.( + { + itemId: "item-guest", + callId: "call-guest", + name: "openclaw_agent_consult", + args: { question: "guest question" }, + }, + realtimeSessionMock, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(agentCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: false, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }), + expect.anything(), + ); + }); + + it("authorizes realtime speakers before subscribing receiver streams", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const client = createClient(); + client.fetchMember.mockResolvedValue({ + nickname: "Denied Speaker", + roles: [], + user: { + id: "u-denied", + username: "denied", + globalName: "Denied", + discriminator: "3333", + }, + }); + const manager = createManager( + { + groupPolicy: "allowlist", + guilds: { + g1: { + channels: { + "1001": { + roles: ["role:voice-allowed"], + }, + }, + }, + }, + voice: { + enabled: true, + mode: "bidi", + realtime: { + provider: "openai", + model: "gpt-realtime-2", + }, + }, + }, + client, + ); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { + player: { state: { status: string } }; + } + | undefined; + if (!entry) { + throw new Error("expected voice session for guild g1"); + } + expect(entry.player.state.status).toBe("idle"); + entry.player.state.status = "playing"; + + await ( + manager as unknown as { + handleSpeakingStart: (entry: unknown, userId: string) => Promise; + } + ).handleSpeakingStart(entry, "u-denied"); + + expect(connection.receiver.subscribe).not.toHaveBeenCalled(); + expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled(); + expect(client.fetchMember).toHaveBeenCalledWith("g1", "u-denied"); + }); + it("stores guild metadata on joined voice sessions", async () => { const manager = createManager(); @@ -599,6 +1240,55 @@ describe("DiscordVoiceManager", () => { expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); }); + it("resets DAVE receive recovery after realtime audio decodes", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + decodeOpusStreamChunksMock.mockImplementationOnce( + async ( + _stream: Readable, + params: { + onChunk: (pcm48kStereo: Buffer) => void; + }, + ) => { + params.onChunk(Buffer.alloc(8)); + }, + ); + const manager = createManager({ + groupPolicy: "open", + allowFrom: ["discord:u-speaker"], + voice: { + enabled: true, + mode: "talk-buffer", + realtime: { provider: "openai" }, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + emitDecryptFailure(manager); + emitDecryptFailure(manager); + const entry = getSessionEntry(manager) as { + receiveRecovery: { decryptFailureCount: number; lastDecryptFailureAt: number }; + }; + expect(entry.receiveRecovery.decryptFailureCount).toBe(2); + const stream = { + on: vi.fn(), + destroy: vi.fn(), + async *[Symbol.asyncIterator]() {}, + }; + connection.receiver.subscribe.mockReturnValueOnce(stream); + + await ( + manager as unknown as { + handleSpeakingStart: (entry: unknown, userId: string) => Promise; + } + ).handleSpeakingStart(entry, "u-speaker"); + + expect(decodeOpusStreamChunksMock).toHaveBeenCalledTimes(1); + expect(entry.receiveRecovery.decryptFailureCount).toBe(0); + expect(entry.receiveRecovery.lastDecryptFailureAt).toBe(0); + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1); + }); + it("allows the same speaker to restart after finalize fires", async () => { vi.useFakeTimers(); try { @@ -1095,7 +1785,7 @@ describe("DiscordVoiceManager", () => { expect(agentCommandMock).toHaveBeenCalledTimes(1); }); - it("DiscordVoiceReadyListener: propagates autoJoin errors fire-and-forget without throwing", async () => { + it("DiscordVoiceReadyListener: starts autoJoin fire-and-forget on ready", async () => { const manager = createManager(); const autoJoinSpy = vi .spyOn(manager, "autoJoin") @@ -1104,7 +1794,7 @@ describe("DiscordVoiceManager", () => { const { DiscordVoiceReadyListener } = managerModule; const listener = new DiscordVoiceReadyListener(manager); - await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow(); + await expect(listener.handle(undefined, undefined as never)).resolves.toBeUndefined(); expect(autoJoinSpy).toHaveBeenCalledTimes(1); }); @@ -1115,7 +1805,7 @@ describe("DiscordVoiceManager", () => { const { DiscordVoiceResumedListener } = managerModule; const listener = new DiscordVoiceResumedListener(manager); - await expect(listener.handle(undefined, undefined as never)).resolves.not.toThrow(); + await expect(listener.handle(undefined, undefined as never)).resolves.toBeUndefined(); expect(autoJoinSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 426dd406a13..5c29c1dfc6d 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -8,7 +8,7 @@ import { resolveDiscordAccountAllowFrom } from "../accounts.js"; import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js"; import type { VoicePlugin } from "../internal/voice.js"; import { formatMention } from "../mentions.js"; -import { decodeOpusStream, writeVoiceWavFile } from "./audio.js"; +import { decodeOpusStream, decodeOpusStreamChunks, writeVoiceWavFile } from "./audio.js"; import { beginVoiceCapture, clearVoiceCaptureFinalizeTimer, @@ -20,6 +20,16 @@ import { stopVoiceCaptureState, } from "./capture-state.js"; import { resolveDiscordVoiceEnabled } from "./config.js"; +import { + type DiscordVoiceIngressContext, + resolveDiscordVoiceIngressContext, + runDiscordVoiceAgentTurn, +} from "./ingress.js"; +import { + DiscordRealtimeVoiceSession, + isDiscordRealtimeVoiceMode, + resolveDiscordVoiceMode, +} from "./realtime.js"; import { analyzeVoiceReceiveError, createVoiceReceiveRecoveryState, @@ -224,6 +234,7 @@ export class DiscordVoiceManager { } const voiceConfig = this.params.discordConfig.voice; + const voiceMode = resolveDiscordVoiceMode(voiceConfig); const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); const daveEncryption = voiceConfig?.daveEncryption; const decryptionFailureTolerance = voiceConfig?.decryptionFailureTolerance; @@ -307,12 +318,48 @@ export class DiscordVoiceManager { let disconnectedHandler: (() => Promise) | undefined; let destroyedHandler: (() => void) | undefined; let playerErrorHandler: ((err: Error) => void) | undefined; + let stopped = false; const clearSessionIfCurrent = () => { const active = this.sessions.get(guildId); if (active?.connection === connection) { this.sessions.delete(guildId); } }; + const stopEntry = ( + entry: VoiceSessionEntry, + options: { destroyConnection: boolean; reason: string }, + ) => { + if (stopped) { + return; + } + stopped = true; + if (speakingHandler) { + connection.receiver.speaking.off("start", speakingHandler); + } + if (speakingEndHandler) { + connection.receiver.speaking.off("end", speakingEndHandler); + } + stopVoiceCaptureState(entry.capture); + if (disconnectedHandler) { + connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler); + } + if (destroyedHandler) { + connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler); + } + if (playerErrorHandler) { + player.off("error", playerErrorHandler); + } + entry.realtime?.close(); + entry.realtime = undefined; + player.stop(); + if (options.destroyConnection) { + destroyVoiceConnectionSafely({ + connection, + voiceSdk, + reason: options.reason, + }); + } + }; const entry: VoiceSessionEntry = { guildId, @@ -337,31 +384,40 @@ export class DiscordVoiceManager { capture: createVoiceCaptureState(), receiveRecovery: createVoiceReceiveRecoveryState(), stop: () => { - if (speakingHandler) { - connection.receiver.speaking.off("start", speakingHandler); - } - if (speakingEndHandler) { - connection.receiver.speaking.off("end", speakingEndHandler); - } - stopVoiceCaptureState(entry.capture); - if (disconnectedHandler) { - connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler); - } - if (destroyedHandler) { - connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler); - } - if (playerErrorHandler) { - player.off("error", playerErrorHandler); - } - player.stop(); - destroyVoiceConnectionSafely({ - connection, - voiceSdk, + stopEntry(entry, { + destroyConnection: true, reason: `stop guild ${guildId} channel ${channelId}`, }); }, }; + if (voiceMode !== "stt-tts") { + entry.realtime = new DiscordRealtimeVoiceSession({ + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + entry, + mode: voiceMode, + runAgentTurn: ({ context, message, toolsAllow, userId }) => + this.runDiscordRealtimeAgentTurn({ context, entry, message, toolsAllow, userId }), + }); + try { + await entry.realtime.connect(); + } catch (err) { + entry.realtime.close(); + destroyVoiceConnectionSafely({ + connection, + voiceSdk, + reason: `realtime setup failed guild ${guildId} channel ${channelId}`, + }); + return { + ok: false, + message: `Failed to start Discord realtime voice: ${formatErrorMessage(err)}`, + guildId, + channelId, + }; + } + } + speakingHandler = (userId: string) => { void this.handleSpeakingStart(entry, userId).catch((err) => { logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`); @@ -394,15 +450,18 @@ export class DiscordVoiceManager { `discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`, ); clearSessionIfCurrent(); - destroyVoiceConnectionSafely({ - connection, - voiceSdk, + stopEntry(entry, { + destroyConnection: true, reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`, }); } }; destroyedHandler = () => { clearSessionIfCurrent(); + stopEntry(entry, { + destroyConnection: false, + reason: `destroyed guild ${guildId} channel ${channelId}`, + }); }; playerErrorHandler = (err: Error) => { logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`); @@ -511,12 +570,30 @@ export class DiscordVoiceManager { `capture start: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); const voiceSdk = loadDiscordVoiceSdk(); - if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing) { + const voiceMode = resolveDiscordVoiceMode(this.params.discordConfig.voice); + const realtime = + entry.realtime && isDiscordRealtimeVoiceMode(voiceMode) ? entry.realtime : undefined; + if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing && !realtime) { logVoiceVerbose( `capture ignored during playback: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); return; } + const realtimeIngress = realtime + ? await this.resolveDiscordVoiceIngressContext(entry, userId) + : undefined; + if (realtime && !realtimeIngress) { + logVoiceVerbose( + `realtime capture unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing && realtime) { + logVoiceVerbose( + `realtime barge-in: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + realtime.handleBargeIn(); + } this.enableDaveReceivePassthrough( entry, `speaker ${userId} start`, @@ -535,6 +612,15 @@ export class DiscordVoiceManager { }); try { + if (realtime && realtimeIngress) { + const turn = realtime.beginSpeakerTurn(realtimeIngress, userId); + try { + await this.processRealtimeAudioCapture({ entry, stream, turn }); + } finally { + turn.close(); + } + return; + } const pcm = await decodeOpusStream(stream, { onVerbose: logVoiceVerbose, onWarn: (message) => logger.warn(message), @@ -565,6 +651,85 @@ export class DiscordVoiceManager { } } + private async processRealtimeAudioCapture(params: { + entry: VoiceSessionEntry; + stream: import("node:stream").Readable; + turn: import("./session.js").VoiceRealtimeSpeakerTurn; + }): Promise { + const { entry, stream, turn } = params; + let resetReceiveRecovery = false; + await decodeOpusStreamChunks(stream, { + onChunk: (pcm) => { + if (!resetReceiveRecovery && pcm.length > 0) { + resetReceiveRecovery = true; + this.resetDecryptFailureState(entry); + } + turn.sendInputAudio(pcm); + }, + onVerbose: logVoiceVerbose, + onWarn: (message) => logger.warn(message), + }); + } + + private async resolveDiscordVoiceIngressContext( + entry: VoiceSessionEntry, + userId: string, + ): Promise { + return await resolveDiscordVoiceIngressContext({ + entry, + userId, + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + ownerAllowFrom: this.ownerAllowFrom, + fetchGuildName: async (guildId) => { + const guild = await this.params.client.fetchGuild(guildId).catch(() => null); + return guild && typeof guild.name === "string" && guild.name.trim() + ? guild.name + : undefined; + }, + speakerContext: this.speakerContext, + }); + } + + private async runDiscordRealtimeAgentTurn(params: { + context: { + extraSystemPrompt?: string; + senderIsOwner: boolean; + speakerLabel: string; + }; + entry: VoiceSessionEntry; + message: string; + toolsAllow?: string[]; + userId: string; + }): Promise { + const { context, entry, message, toolsAllow, userId } = params; + const turn = await runDiscordVoiceAgentTurn({ + entry, + userId, + message, + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + runtime: this.params.runtime, + context, + toolsAllow, + ownerAllowFrom: this.ownerAllowFrom, + fetchGuildName: async (guildId) => { + const guild = await this.params.client.fetchGuild(guildId).catch(() => null); + return guild && typeof guild.name === "string" && guild.name.trim() + ? guild.name + : undefined; + }, + speakerContext: this.speakerContext, + }); + if (!turn) { + logVoiceVerbose( + `realtime agent unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return ""; + } + return turn.text; + } + private async processSegment(params: { entry: VoiceSessionEntry; wavPath: string; diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts new file mode 100644 index 00000000000..2849f9de345 --- /dev/null +++ b/extensions/discord/src/voice/realtime.ts @@ -0,0 +1,418 @@ +import { PassThrough } from "node:stream"; +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { + buildRealtimeVoiceAgentConsultChatMessage, + buildRealtimeVoiceAgentConsultPolicyInstructions, + buildRealtimeVoiceAgentConsultWorkingResponse, + createRealtimeVoiceAgentTalkbackQueue, + createRealtimeVoiceBridgeSession, + REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + resolveConfiguredRealtimeVoiceProvider, + resolveRealtimeVoiceAgentConsultToolPolicy, + resolveRealtimeVoiceAgentConsultTools, + resolveRealtimeVoiceAgentConsultToolsAllow, + type RealtimeVoiceAgentTalkbackQueue, + type RealtimeVoiceAgentConsultToolPolicy, + type RealtimeVoiceBridgeSession, + type RealtimeVoiceProviderConfig, + type RealtimeVoiceToolCallEvent, +} from "openclaw/plugin-sdk/realtime-voice"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + convertDiscordPcm48kStereoToRealtimePcm24kMono, + convertRealtimePcm24kMonoToDiscordPcm48kStereo, +} from "./audio.js"; +import { formatVoiceIngressPrompt } from "./prompt.js"; +import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; +import { + logVoiceVerbose, + type VoiceRealtimeAgentTurnParams, + type VoiceRealtimeSession, + type VoiceRealtimeSpeakerContext, + type VoiceRealtimeSpeakerTurn, + type VoiceSessionEntry, +} from "./session.js"; + +const logger = createSubsystemLogger("discord/voice"); +const DISCORD_REALTIME_TALKBACK_DEBOUNCE_MS = 350; +const DISCORD_REALTIME_FALLBACK_TEXT = "I hit an error while checking that. Please try again."; +const DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT = 32; + +export type DiscordVoiceMode = "stt-tts" | "talk-buffer" | "bidi"; + +type DiscordRealtimeSpeakerContext = VoiceRealtimeSpeakerContext & { userId: string }; + +type DiscordRealtimeVoiceConfig = NonNullable["realtime"]; + +type PendingSpeakerTurn = { + context: DiscordRealtimeSpeakerContext; + hasAudio: boolean; + closed: boolean; +}; + +export function resolveDiscordVoiceMode(voice: DiscordAccountConfig["voice"]): DiscordVoiceMode { + const mode = voice?.mode; + return mode === "talk-buffer" || mode === "bidi" ? mode : "stt-tts"; +} + +export function isDiscordRealtimeVoiceMode(mode: DiscordVoiceMode): boolean { + return mode === "talk-buffer" || mode === "bidi"; +} + +export function buildDiscordSpeakExactUserMessage(text: string): string { + return [ + "Speak this exact OpenClaw answer to the Discord voice channel, without adding, removing, or rephrasing words.", + `Answer: ${JSON.stringify(text)}`, + ].join("\n"); +} + +export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { + private bridge: RealtimeVoiceBridgeSession | null = null; + private outputStream: PassThrough | null = null; + private readonly talkback: RealtimeVoiceAgentTalkbackQueue; + private stopped = false; + private consultToolPolicy: RealtimeVoiceAgentConsultToolPolicy = "safe-read-only"; + private consultToolsAllow: string[] | undefined; + private readonly pendingSpeakerTurns: PendingSpeakerTurn[] = []; + private readonly playerIdleHandler = () => { + this.resetOutputStream(); + }; + + constructor( + private readonly params: { + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + entry: VoiceSessionEntry; + mode: Exclude; + runAgentTurn: (params: VoiceRealtimeAgentTurnParams) => Promise; + }, + ) { + this.talkback = createRealtimeVoiceAgentTalkbackQueue({ + debounceMs: this.realtimeConfig?.debounceMs ?? DISCORD_REALTIME_TALKBACK_DEBOUNCE_MS, + isStopped: () => this.stopped, + logger, + logPrefix: "[discord] realtime agent", + responseStyle: "Brief, natural spoken answer for a Discord voice channel.", + fallbackText: DISCORD_REALTIME_FALLBACK_TEXT, + consult: async ({ question, responseStyle, metadata }) => { + const context = isDiscordRealtimeSpeakerContext(metadata) ? metadata : undefined; + return { + text: await this.runAgentTurn({ + context, + message: formatVoiceIngressPrompt( + [question, responseStyle ? `Spoken style: ${responseStyle}` : undefined] + .filter(Boolean) + .join("\n\n"), + context?.speakerLabel ?? "Discord voice speaker", + ), + }), + }; + }, + deliver: (text) => this.bridge?.sendUserMessage(buildDiscordSpeakExactUserMessage(text)), + }); + } + + async connect(): Promise { + const resolved = resolveConfiguredRealtimeVoiceProvider({ + configuredProviderId: this.realtimeConfig?.provider, + providerConfigs: buildProviderConfigs(this.realtimeConfig), + providerConfigOverrides: buildProviderConfigOverrides(this.realtimeConfig), + cfg: this.params.cfg, + defaultModel: this.realtimeConfig?.model, + noRegisteredProviderMessage: "No configured realtime voice provider registered", + }); + const toolPolicy = resolveRealtimeVoiceAgentConsultToolPolicy( + this.realtimeConfig?.toolPolicy, + "safe-read-only", + ); + this.consultToolPolicy = toolPolicy; + this.consultToolsAllow = resolveRealtimeVoiceAgentConsultToolsAllow(toolPolicy); + const consultPolicy = this.realtimeConfig?.consultPolicy ?? "auto"; + const instructions = buildDiscordRealtimeInstructions({ + mode: this.params.mode, + instructions: this.realtimeConfig?.instructions, + toolPolicy, + consultPolicy, + }); + this.bridge = createRealtimeVoiceBridgeSession({ + provider: resolved.provider, + providerConfig: resolved.providerConfig, + audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + instructions, + autoRespondToAudio: this.params.mode === "bidi", + markStrategy: "ack-immediately", + tools: this.params.mode === "bidi" ? resolveRealtimeVoiceAgentConsultTools(toolPolicy) : [], + audioSink: { + isOpen: () => !this.stopped, + sendAudio: (audio) => this.sendOutputAudio(audio), + clearAudio: () => this.clearOutputAudio(), + }, + onTranscript: (role, text, isFinal) => { + if (!isFinal || role !== "user" || this.params.mode !== "talk-buffer") { + return; + } + this.talkback.enqueue(text, this.consumePendingSpeakerContext()); + }, + onToolCall: (event, session) => this.handleToolCall(event, session), + onEvent: (event) => { + const detail = event.detail ? ` ${event.detail}` : ""; + logVoiceVerbose(`realtime ${event.direction}:${event.type}${detail}`); + }, + onError: (error) => + logger.warn(`discord voice: realtime error: ${formatErrorMessage(error)}`), + onClose: (reason) => logVoiceVerbose(`realtime closed: ${reason}`), + }); + logVoiceVerbose( + `realtime voice bridge starting: mode=${this.params.mode} provider=${resolved.provider.id}`, + ); + const voiceSdk = loadDiscordVoiceSdk(); + this.params.entry.player.on(voiceSdk.AudioPlayerStatus.Idle, this.playerIdleHandler); + await this.bridge.connect(); + } + + close(): void { + this.stopped = true; + this.talkback.close(); + this.pendingSpeakerTurns.length = 0; + this.clearOutputAudio(); + this.bridge?.close(); + this.bridge = null; + const voiceSdk = loadDiscordVoiceSdk(); + this.params.entry.player.off(voiceSdk.AudioPlayerStatus.Idle, this.playerIdleHandler); + } + + beginSpeakerTurn(context: VoiceRealtimeSpeakerContext, userId: string): VoiceRealtimeSpeakerTurn { + const turn: PendingSpeakerTurn = { + context: { ...context, userId }, + hasAudio: false, + closed: false, + }; + this.pendingSpeakerTurns.push(turn); + this.prunePendingSpeakerTurns(); + return { + sendInputAudio: (discordPcm48kStereo) => + this.sendInputAudioForTurn(turn, discordPcm48kStereo), + close: () => { + turn.closed = true; + this.prunePendingSpeakerTurns(); + }, + }; + } + + private sendInputAudioForTurn(turn: PendingSpeakerTurn, discordPcm48kStereo: Buffer): void { + if (!this.bridge || this.stopped) { + return; + } + turn.hasAudio = true; + const realtimePcm = convertDiscordPcm48kStereoToRealtimePcm24kMono(discordPcm48kStereo); + if (realtimePcm.length > 0) { + this.bridge.sendAudio(realtimePcm); + } + } + + handleBargeIn(): void { + this.bridge?.handleBargeIn({ audioPlaybackActive: Boolean(this.outputStream) }); + this.clearOutputAudio(); + } + + private get realtimeConfig(): DiscordRealtimeVoiceConfig { + return this.params.discordConfig.voice?.realtime; + } + + private sendOutputAudio(realtimePcm24kMono: Buffer): void { + const discordPcm = convertRealtimePcm24kMonoToDiscordPcm48kStereo(realtimePcm24kMono); + if (discordPcm.length === 0) { + return; + } + const stream = this.ensureOutputStream(); + stream.write(discordPcm); + } + + private ensureOutputStream(): PassThrough { + if (this.outputStream && !this.outputStream.destroyed) { + return this.outputStream; + } + const voiceSdk = loadDiscordVoiceSdk(); + const stream = new PassThrough(); + this.outputStream = stream; + stream.once("close", () => { + if (this.outputStream === stream) { + this.outputStream = null; + } + }); + const resource = voiceSdk.createAudioResource(stream, { + inputType: voiceSdk.StreamType.Raw, + }); + this.params.entry.player.play(resource); + return stream; + } + + private clearOutputAudio(): void { + this.resetOutputStream(); + this.params.entry.player.stop(true); + } + + private resetOutputStream(): void { + const stream = this.outputStream; + this.outputStream = null; + stream?.end(); + stream?.destroy(); + } + + private handleToolCall( + event: RealtimeVoiceToolCallEvent, + session: RealtimeVoiceBridgeSession, + ): void { + const callId = event.callId || event.itemId; + if (this.params.mode !== "bidi") { + session.submitToolResult(callId, { + error: `Tool "${event.name}" is only available in bidi Discord voice mode`, + }); + return; + } + if (event.name !== REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) { + session.submitToolResult(callId, { error: `Tool "${event.name}" not available` }); + return; + } + if (this.consultToolPolicy === "none") { + session.submitToolResult(callId, { error: `Tool "${event.name}" not available` }); + return; + } + if (session.bridge.supportsToolResultContinuation) { + session.submitToolResult(callId, buildRealtimeVoiceAgentConsultWorkingResponse("speaker"), { + willContinue: true, + }); + } + const context = this.consumePendingSpeakerContext(); + if (!context) { + session.submitToolResult(callId, { error: "No Discord speaker context available" }); + return; + } + void this.runAgentTurn({ + context, + message: buildRealtimeVoiceAgentConsultChatMessage(event.args), + }) + .then((text) => { + session.submitToolResult(callId, { text }); + }) + .catch((error: unknown) => { + session.submitToolResult(callId, { error: formatErrorMessage(error) }); + }); + } + + private async runAgentTurn(params: { + context?: DiscordRealtimeSpeakerContext; + message: string; + }): Promise { + const context = params.context; + if (!context) { + return ""; + } + return this.params.runAgentTurn({ + context, + message: params.message, + toolsAllow: this.params.mode === "bidi" ? this.consultToolsAllow : undefined, + userId: context.userId, + }); + } + + private consumePendingSpeakerContext(): DiscordRealtimeSpeakerContext | undefined { + this.prunePendingSpeakerTurns(); + this.expireClosedSpeakerTurnsBeforeLaterAudio(); + const index = this.pendingSpeakerTurns.findIndex((turn) => turn.hasAudio); + if (index < 0) { + return undefined; + } + const [turn] = this.pendingSpeakerTurns.splice(index, 1); + this.prunePendingSpeakerTurns(); + return turn?.context; + } + + private prunePendingSpeakerTurns(): void { + for (let index = this.pendingSpeakerTurns.length - 1; index >= 0; index -= 1) { + const turn = this.pendingSpeakerTurns[index]; + if (turn?.closed && !turn.hasAudio) { + this.pendingSpeakerTurns.splice(index, 1); + } + } + while (this.pendingSpeakerTurns.length > DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT) { + const completedIndex = this.pendingSpeakerTurns.findIndex((turn) => turn.closed); + this.pendingSpeakerTurns.splice(Math.max(completedIndex, 0), 1); + } + } + + private expireClosedSpeakerTurnsBeforeLaterAudio(): void { + let hasLaterAudio = false; + for (let index = this.pendingSpeakerTurns.length - 1; index >= 0; index -= 1) { + const turn = this.pendingSpeakerTurns[index]; + if (!turn?.hasAudio) { + continue; + } + if (turn.closed && hasLaterAudio) { + this.pendingSpeakerTurns.splice(index, 1); + continue; + } + hasLaterAudio = true; + } + } +} + +function isDiscordRealtimeSpeakerContext(value: unknown): value is DiscordRealtimeSpeakerContext { + return ( + Boolean(value) && + typeof value === "object" && + typeof (value as { userId?: unknown }).userId === "string" && + typeof (value as { senderIsOwner?: unknown }).senderIsOwner === "boolean" && + typeof (value as { speakerLabel?: unknown }).speakerLabel === "string" + ); +} + +function buildProviderConfigs( + realtimeConfig: DiscordRealtimeVoiceConfig, +): Record | undefined { + const configs = realtimeConfig?.providers; + return configs && Object.keys(configs).length > 0 ? { ...configs } : undefined; +} + +function buildProviderConfigOverrides( + realtimeConfig: DiscordRealtimeVoiceConfig, +): RealtimeVoiceProviderConfig | undefined { + const overrides = { + ...(realtimeConfig?.model ? { model: realtimeConfig.model } : {}), + ...(realtimeConfig?.voice ? { voice: realtimeConfig.voice } : {}), + }; + return Object.keys(overrides).length > 0 ? overrides : undefined; +} + +function buildDiscordRealtimeInstructions(params: { + mode: Exclude; + instructions?: string; + toolPolicy: RealtimeVoiceAgentConsultToolPolicy; + consultPolicy: "auto" | "always"; +}): string { + const base = + params.instructions ?? + [ + "You are OpenClaw's Discord voice interface.", + "Keep spoken replies concise, natural, and suitable for a live Discord voice channel.", + ].join("\n"); + if (params.mode === "talk-buffer") { + return [ + base, + "Mode: buffered OpenClaw agent talkback.", + "Use audio input only to transcribe the speaker. Do not answer user speech by yourself.", + "When OpenClaw sends an exact answer to speak, say only that answer.", + ].join("\n\n"); + } + return [ + base, + buildRealtimeVoiceAgentConsultPolicyInstructions({ + toolPolicy: params.toolPolicy, + consultPolicy: params.consultPolicy, + }), + ] + .filter(Boolean) + .join("\n\n"); +} diff --git a/extensions/discord/src/voice/segment.ts b/extensions/discord/src/voice/segment.ts index fabefce8f34..b21aea6a936 100644 --- a/extensions/discord/src/voice/segment.ts +++ b/extensions/discord/src/voice/segment.ts @@ -1,14 +1,9 @@ import path from "node:path"; import { Readable } from "node:stream"; -import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { formatMention } from "../mentions.js"; -import { normalizeDiscordSlug } from "../monitor/allow-list.js"; -import { buildDiscordGroupSystemPrompt } from "../monitor/inbound-context.js"; -import { authorizeDiscordVoiceIngress } from "./access.js"; +import { resolveDiscordVoiceIngressContext, runDiscordVoiceAgentTurn } from "./ingress.js"; import { formatVoiceIngressPrompt } from "./prompt.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; import { @@ -20,7 +15,6 @@ import { import type { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.js"; -const DISCORD_VOICE_MESSAGE_PROVIDER = "discord-voice"; const VOICE_TRANSCRIPT_LOG_PREVIEW_CHARS = 500; const logger = createSubsystemLogger("discord/voice"); @@ -49,31 +43,18 @@ export async function processDiscordVoiceSegment(params: { logVoiceVerbose( `segment processing (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId}`, ); - if (!entry.guildName) { - entry.guildName = await params.fetchGuildName(entry.guildId); - } - const speaker = await params.speakerContext.resolveContext(entry.guildId, userId); - const speakerIdentity = await params.speakerContext.resolveIdentity(entry.guildId, userId); - const access = await authorizeDiscordVoiceIngress({ + const ingress = await resolveDiscordVoiceIngressContext({ + entry, + userId, cfg: params.cfg, discordConfig: params.discordConfig, - guildName: entry.guildName, - guildId: entry.guildId, - channelId: entry.channelId, - channelName: entry.channelName, - channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", - channelLabel: formatMention({ channelId: entry.channelId }), - memberRoleIds: speakerIdentity.memberRoleIds, ownerAllowFrom: params.ownerAllowFrom, - sender: { - id: speakerIdentity.id, - name: speakerIdentity.name, - tag: speakerIdentity.tag, - }, + fetchGuildName: params.fetchGuildName, + speakerContext: params.speakerContext, }); - if (!access.ok) { + if (!ingress) { logVoiceVerbose( - `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${access.message}`, + `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); return; } @@ -92,34 +73,29 @@ export async function processDiscordVoiceSegment(params: { `transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, ); logVoiceVerbose( - `transcript from ${speaker.label} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceTranscriptLogPreview(transcript)}`, + `transcript from ${ingress.speakerLabel} (${userId}) in guild ${entry.guildId} channel ${entry.channelId}: ${formatVoiceTranscriptLogPreview(transcript)}`, ); - const prompt = formatVoiceIngressPrompt(transcript, speaker.label); - const extraSystemPrompt = buildDiscordGroupSystemPrompt(access.channelConfig); - const modelOverride = normalizeOptionalString(params.discordConfig.voice?.model); - - const result = await agentCommandFromIngress( - { - message: prompt, - sessionKey: entry.route.sessionKey, - agentId: entry.route.agentId, - messageChannel: "discord", - messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER, - extraSystemPrompt, - senderIsOwner: speaker.senderIsOwner, - allowModelOverride: Boolean(modelOverride), - model: modelOverride, - deliver: false, - }, - params.runtime, - ); - - const replyText = (result.payloads ?? []) - .map((payload) => payload.text) - .filter((text) => typeof text === "string" && text.trim()) - .join("\n") - .trim(); + const prompt = formatVoiceIngressPrompt(transcript, ingress.speakerLabel); + const turn = await runDiscordVoiceAgentTurn({ + entry, + userId, + message: prompt, + cfg: params.cfg, + discordConfig: params.discordConfig, + runtime: params.runtime, + context: ingress, + ownerAllowFrom: params.ownerAllowFrom, + fetchGuildName: params.fetchGuildName, + speakerContext: params.speakerContext, + }); + if (!turn) { + logVoiceVerbose( + `segment unauthorized before agent turn: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + const replyText = turn.text; if (!replyText) { logVoiceVerbose( @@ -135,7 +111,7 @@ export async function processDiscordVoiceSegment(params: { cfg: params.cfg, override: params.discordConfig.voice?.tts, replyText, - speakerLabel: speaker.label, + speakerLabel: ingress.speakerLabel, }); if (voiceReplyAudio.status === "empty") { logVoiceVerbose( diff --git a/extensions/discord/src/voice/session.ts b/extensions/discord/src/voice/session.ts index 9960deb194a..cc161e015f1 100644 --- a/extensions/discord/src/voice/session.ts +++ b/extensions/discord/src/voice/session.ts @@ -25,6 +25,34 @@ export type VoiceOperationResult = { guildId?: string; }; +export type VoiceRealtimeSpeakerContext = { + extraSystemPrompt?: string; + senderIsOwner: boolean; + speakerLabel: string; +}; + +export type VoiceRealtimeAgentTurnParams = { + context: VoiceRealtimeSpeakerContext; + message: string; + toolsAllow?: string[]; + userId: string; +}; + +export type VoiceRealtimeSpeakerTurn = { + close: () => void; + sendInputAudio: (discordPcm48kStereo: Buffer) => void; +}; + +export type VoiceRealtimeSession = { + beginSpeakerTurn: ( + context: VoiceRealtimeSpeakerContext, + userId: string, + ) => VoiceRealtimeSpeakerTurn; + close: () => void; + connect: () => Promise; + handleBargeIn: () => void; +}; + export type VoiceSessionEntry = { guildId: string; guildName?: string; @@ -37,6 +65,7 @@ export type VoiceSessionEntry = { playbackQueue: Promise; processingQueue: Promise; capture: VoiceCaptureState; + realtime?: VoiceRealtimeSession; receiveRecovery: VoiceReceiveRecoveryState; stop: () => void; }; diff --git a/extensions/document-extract/document-extractor.test.ts b/extensions/document-extract/document-extractor.test.ts index f7f5605bf6f..8c68771e63c 100644 --- a/extensions/document-extract/document-extractor.test.ts +++ b/extensions/document-extract/document-extractor.test.ts @@ -70,7 +70,10 @@ describe("PDF document extractor", () => { minTextChars: 10, }); - expect(result?.images).toHaveLength(1); + if (!result) { + throw new Error("Expected PDF extraction result"); + } + expect(result.images).toHaveLength(1); expect(canvasSizes).toEqual([{ width: 10, height: 10 }]); }); diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 6ce0880810b..6c6836fb7ed 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -46,7 +46,11 @@ describe("duckduckgo web search provider", () => { ]); expect(provider.requiresCredential).toBe(false); expect(provider.credentialPath).toBe(""); - expect(applied.plugins?.entries?.duckduckgo?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.duckduckgo; + if (!pluginEntry) { + throw new Error("expected DuckDuckGo plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("maps generic tool arguments into DuckDuckGo search params", async () => { diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 5fd9dae255d..2646e85eccd 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -14,7 +14,11 @@ describe("exa web search provider", () => { expect(provider.id).toBe("exa"); expect(provider.onboardingScopes).toEqual(["text-inference"]); expect(provider.credentialPath).toBe("plugins.entries.exa.config.webSearch.apiKey"); - expect(applied.plugins?.entries?.exa?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.exa; + if (!pluginEntry) { + throw new Error("expected Exa plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("keeps the lightweight contract surface aligned with provider metadata", () => { @@ -39,7 +43,11 @@ describe("exa web search provider", () => { credentialPath: provider.credentialPath, }); expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull(); - expect(applied.plugins?.entries?.exa?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.exa; + if (!pluginEntry) { + throw new Error("expected contract Exa plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("prefers scoped configured api keys over environment fallbacks", () => { diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index e8d2e8577b2..5647ac533a3 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -436,8 +436,8 @@ describe("resolveFeishuAccount", () => { expect((caught as Error).message).toMatch(/channels\.feishu\.appSecret: unresolved SecretRef/i); }); - it("does not throw when account name is non-string", () => { - expect(() => + it("ignores non-string account names", () => { + expect( resolveFeishuAccount({ cfg: { channels: { @@ -454,6 +454,11 @@ describe("resolveFeishuAccount", () => { } as never, accountId: "main", }), - ).not.toThrow(); + ).toMatchObject({ + accountId: "main", + appId: "cli_123", + appSecret: "secret_456", + name: undefined, + }); }); }); diff --git a/extensions/feishu/src/agent-config.ts b/extensions/feishu/src/agent-config.ts new file mode 100644 index 00000000000..ca5ab8ea810 --- /dev/null +++ b/extensions/feishu/src/agent-config.ts @@ -0,0 +1,21 @@ +import type { ClawdbotConfig } from "./bot-runtime-api.js"; + +type ReasoningDefault = "on" | "stream" | "off"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "").trim().toLowerCase(); + return normalized || DEFAULT_AGENT_ID; +} + +export function resolveFeishuConfigReasoningDefault( + cfg: ClawdbotConfig, + agentId: string, +): ReasoningDefault { + const id = normalizeAgentId(agentId); + const agentDefault = cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry?.id) === id, + )?.reasoningDefault; + return agentDefault ?? cfg.agents?.defaults?.reasoningDefault ?? "off"; +} diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 4f7376cb55f..52920116c48 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1357,6 +1357,8 @@ export async function handleFeishuMessage(params: { }, }; const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId, storePath: agentStorePath, sessionKey: agentSessionKey, }); @@ -1532,6 +1534,8 @@ export async function handleFeishuMessage(params: { agentId: route.agentId, }); const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: route.agentId, storePath, sessionKey: route.sessionKey, }); diff --git a/extensions/feishu/src/monitor.comment.test.ts b/extensions/feishu/src/monitor.comment.test.ts index a1ed3e9808d..e9f590ad978 100644 --- a/extensions/feishu/src/monitor.comment.test.ts +++ b/extensions/feishu/src/monitor.comment.test.ts @@ -224,7 +224,6 @@ describe("resolveDriveCommentEventTurn", () => { createClient: () => client as never, }); - expect(turn).not.toBeNull(); expect(turn?.senderId).toBe("ou_509d4d7ace4a9addec2312676ffcba9b"); expect(turn?.messageId).toBe("drive-comment:10d9d60b990db39f96a4c2fd357fb877"); expect(turn?.fileType).toBe("docx"); diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index c8f2bdc6b7b..32b20c7c307 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -64,10 +64,14 @@ describe("Feishu monitor startup preflight", () => { let inFlight = 0; let maxInFlight = 0; const started: string[] = []; - let releaseProbes!: () => void; + let releaseProbes: (() => void) | undefined; const probesReleased = new Promise((resolve) => { releaseProbes = () => resolve(); }); + if (!releaseProbes) { + throw new Error("Expected probe release callback to be initialized"); + } + const releaseStartedProbes = releaseProbes; probeFeishuMock.mockImplementation(async (account: { accountId: string }) => { started.push(account.accountId); inFlight += 1; @@ -88,7 +92,7 @@ describe("Feishu monitor startup preflight", () => { expect(started).toEqual(["alpha"]); expect(maxInFlight).toBe(1); } finally { - releaseProbes(); + releaseStartedProbes(); abortController.abort(); await monitorPromise; } @@ -96,10 +100,14 @@ describe("Feishu monitor startup preflight", () => { it("does not refetch bot info after a failed sequential preflight", async () => { const started: string[] = []; - let releaseBetaProbe!: () => void; + let releaseBetaProbe: (() => void) | undefined; const betaProbeReleased = new Promise((resolve) => { releaseBetaProbe = () => resolve(); }); + if (!releaseBetaProbe) { + throw new Error("Expected beta probe release callback to be initialized"); + } + const releaseStartedBetaProbe = releaseBetaProbe; probeFeishuMock.mockImplementation(async (account: { accountId: string }) => { started.push(account.accountId); @@ -119,9 +127,11 @@ describe("Feishu monitor startup preflight", () => { try { await waitForStartedAccount(started, "beta"); expect(started).toEqual(["alpha", "beta"]); - expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1); + expect(started.reduce((count, accountId) => count + (accountId === "alpha" ? 1 : 0), 0)).toBe( + 1, + ); } finally { - releaseBetaProbe(); + releaseStartedBetaProbe(); abortController.abort(); await monitorPromise; } @@ -129,10 +139,14 @@ describe("Feishu monitor startup preflight", () => { it("continues startup when probe layer reports timeout", async () => { const started: string[] = []; - let releaseBetaProbe!: () => void; + let releaseBetaProbe: (() => void) | undefined; const betaProbeReleased = new Promise((resolve) => { releaseBetaProbe = () => resolve(); }); + if (!releaseBetaProbe) { + throw new Error("Expected beta probe release callback to be initialized"); + } + const releaseStartedBetaProbe = releaseBetaProbe; probeFeishuMock.mockImplementation((account: { accountId: string }) => { started.push(account.accountId); @@ -157,7 +171,7 @@ describe("Feishu monitor startup preflight", () => { expect.stringContaining("bot info probe timed out"), ); } finally { - releaseBetaProbe(); + releaseStartedBetaProbe(); abortController.abort(); await monitorPromise; } diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 0c1edf5c9eb..a7aa30593f8 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -75,7 +75,48 @@ vi.mock("./comment-reaction.js", () => ({ import { feishuPlugin } from "./channel.js"; import { feishuOutbound } from "./outbound.js"; import { createFeishuSendReceipt } from "./send-result.js"; -const sendText = feishuOutbound.sendText!; + +type FeishuSendText = NonNullable; +type FeishuMessageAdapter = NonNullable; +type FeishuMessageSender = NonNullable; + +function requireFeishuSendText(): FeishuSendText { + const sendText = feishuOutbound.sendText; + if (!sendText) { + throw new Error("Expected Feishu outbound sendText"); + } + return sendText; +} + +function requireFeishuMessageAdapter(): FeishuMessageAdapter { + const adapter = feishuPlugin.message; + if (!adapter) { + throw new Error("Expected Feishu message adapter"); + } + return adapter; +} + +function requireFeishuTextSender( + adapter: FeishuMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected Feishu message adapter text sender"); + } + return text; +} + +function requireFeishuMediaSender( + adapter: FeishuMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected Feishu message adapter media sender"); + } + return media; +} + +const sendText = requireFeishuSendText(); const emptyConfig: ClawdbotConfig = {}; const cardRenderConfig: ClawdbotConfig = { channels: { @@ -133,14 +174,17 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { kind: "media", }), }); + const adapter = requireFeishuMessageAdapter(); + const adapterSendText = requireFeishuTextSender(adapter); + const adapterSendMedia = requireFeishuMediaSender(adapter); await expect( verifyChannelMessageAdapterCapabilityProofs({ adapterName: "feishu", - adapter: feishuPlugin.message!, + adapter, proofs: { text: async () => { - const result = await feishuPlugin.message?.send?.text?.({ + const result = await adapterSendText({ cfg: emptyConfig, to: "chat:chat-1", text: "hello", @@ -153,10 +197,10 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { accountId: "default", }), ); - expect(result?.receipt.platformMessageIds).toEqual(["feishu-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["feishu-text-1"]); }, media: async () => { - const result = await feishuPlugin.message?.send?.media?.({ + const result = await adapterSendMedia({ cfg: emptyConfig, to: "chat:chat-1", text: "", @@ -170,7 +214,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { accountId: "default", }), ); - expect(result?.receipt.platformMessageIds).toEqual(["feishu-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["feishu-media-1"]); }, }, }), diff --git a/extensions/feishu/src/reasoning-preview.test.ts b/extensions/feishu/src/reasoning-preview.test.ts index c6bf99c9b2a..49f6b8e798c 100644 --- a/extensions/feishu/src/reasoning-preview.test.ts +++ b/extensions/feishu/src/reasoning-preview.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "./bot-runtime-api.js"; import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js"; const { loadSessionStoreMock } = vi.hoisted(() => ({ @@ -20,6 +21,8 @@ afterAll(() => { }); describe("resolveFeishuReasoningPreviewEnabled", () => { + const emptyCfg: ClawdbotConfig = {}; + beforeEach(() => { vi.clearAllMocks(); }); @@ -32,12 +35,16 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", sessionKey: "agent:main:feishu:dm:ou_sender_1", }), ).toBe(true); expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", sessionKey: "agent:main:feishu:dm:ou_sender_2", }), @@ -51,14 +58,56 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", sessionKey: "agent:main:feishu:dm:ou_sender_1", }), ).toBe(false); expect( resolveFeishuReasoningPreviewEnabled({ + cfg: emptyCfg, + agentId: "main", storePath: "/tmp/feishu-sessions.json", }), ).toBe(false); }); + + it("falls back to configured stream defaults", () => { + loadSessionStoreMock.mockReturnValue({ + "agent:main:feishu:dm:ou_sender_1": {}, + "agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "off" }, + }); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { reasoningDefault: "stream" }, + list: [{ id: "Ops", reasoningDefault: "off" }], + }, + }; + + expect( + resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: "main", + storePath: "/tmp/feishu-sessions.json", + sessionKey: "agent:main:feishu:dm:ou_sender_1", + }), + ).toBe(true); + expect( + resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: "ops", + storePath: "/tmp/feishu-sessions.json", + }), + ).toBe(false); + expect( + resolveFeishuReasoningPreviewEnabled({ + cfg, + agentId: "main", + storePath: "/tmp/feishu-sessions.json", + sessionKey: "agent:main:feishu:dm:ou_sender_2", + }), + ).toBe(false); + }); }); diff --git a/extensions/feishu/src/reasoning-preview.ts b/extensions/feishu/src/reasoning-preview.ts index 4f752b840a4..93ecccc4591 100644 --- a/extensions/feishu/src/reasoning-preview.ts +++ b/extensions/feishu/src/reasoning-preview.ts @@ -1,20 +1,28 @@ +import { resolveFeishuConfigReasoningDefault } from "./agent-config.js"; import { loadSessionStore, resolveSessionStoreEntry } from "./bot-runtime-api.js"; +import type { ClawdbotConfig } from "./bot-runtime-api.js"; export function resolveFeishuReasoningPreviewEnabled(params: { + cfg: ClawdbotConfig; + agentId: string; storePath: string; sessionKey?: string; }): boolean { + const configDefault = resolveFeishuConfigReasoningDefault(params.cfg, params.agentId); + if (!params.sessionKey) { - return false; + return configDefault === "stream"; } try { const store = loadSessionStore(params.storePath, { skipCache: true }); - return ( - resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing - ?.reasoningLevel === "stream" - ); + const level = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing + ?.reasoningLevel; + if (level === "on" || level === "stream" || level === "off") { + return level === "stream"; + } } catch { return false; } + return configDefault === "stream"; } diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 85d36c61721..66560972291 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -888,10 +888,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.onReplyStart?.(); - // Core agent sends pre-formatted text from formatReasoningMessage - result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" }); + result.replyOptions.onReasoningStream?.({ text: "thinking step 1" }); result.replyOptions.onReasoningStream?.({ - text: "Reasoning:\n_thinking step 1_\n_step 2_", + text: "thinking step 1\nstep 2", }); result.replyOptions.onPartialReply?.({ text: "answer part" }); result.replyOptions.onReasoningEnd?.(); @@ -967,7 +966,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.onReplyStart?.(); - result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" }); + result.replyOptions.onReasoningStream?.({ text: "deep thought" }); result.replyOptions.onReasoningEnd?.(); await options.onIdle?.(); @@ -1005,7 +1004,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.onReplyStart?.(); - result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" }); + result.replyOptions.onReasoningStream?.({ text: "thought" }); result.replyOptions.onReasoningEnd?.(); await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" }); await options.onIdle?.(); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 2c11c3e546d..9c56f9fa17e 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,3 +1,4 @@ +import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { @@ -522,7 +523,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP await typingCallbacks?.onReplyStart?.(); }, deliver: async (payload: ReplyPayload, info) => { - const reply = resolveSendableOutboundReplyParts(payload); + const payloadText = + payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text; + const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText }); const text = reply.text; const hasText = reply.hasText; const hasMedia = reply.hasMedia; @@ -694,7 +697,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return; } startStreaming(); - queueReasoningUpdate(payload.text); + queueReasoningUpdate(formatReasoningMessage(payload.text)); } : undefined, onReasoningEnd: reasoningPreviewEnabled ? () => {} : undefined, diff --git a/extensions/feishu/src/sequential-queue.test.ts b/extensions/feishu/src/sequential-queue.test.ts index a0ea3dfd4d6..2f4d6971860 100644 --- a/extensions/feishu/src/sequential-queue.test.ts +++ b/extensions/feishu/src/sequential-queue.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { createSequentialQueue } from "./sequential-queue.js"; function createDeferred() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index bb84467b5f7..8933c055395 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -88,7 +88,7 @@ describe("feishu setup wizard", () => { probeFeishuMock.mockResolvedValue({ ok: false, error: "mocked" }); }); - it("does not throw when config appId/appSecret are SecretRef objects", async () => { + it("prompts over SecretRef appId/appSecret config objects", async () => { const text = vi .fn() .mockResolvedValueOnce("cli_from_prompt") diff --git a/extensions/file-transfer/src/shared/policy.test.ts b/extensions/file-transfer/src/shared/policy.test.ts index af541b28043..6e37474d067 100644 --- a/extensions/file-transfer/src/shared/policy.test.ts +++ b/extensions/file-transfer/src/shared/policy.test.ts @@ -507,6 +507,6 @@ describe("persistAllowAlways", () => { }; }; const list = root.plugins.entries["file-transfer"].config.nodes.n1.allowReadPaths; - expect(list.filter((p) => p === "/tmp/x").length).toBe(1); + expect(list.reduce((count, p) => count + (p === "/tmp/x" ? 1 : 0), 0)).toBe(1); }); }); diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index d6f84b6c28f..52a3f37956d 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -82,7 +82,11 @@ describe("firecrawl tools", () => { expect(provider.id).toBe("firecrawl"); expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webSearch.apiKey"); - expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.firecrawl; + if (!pluginEntry) { + throw new Error("expected Firecrawl plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("parses scrape payloads into wrapped external-content results", () => { @@ -215,9 +219,9 @@ describe("firecrawl tools", () => { }); it("blocks private and non-http scrape targets before Firecrawl requests", () => { - expect(() => + expect( firecrawlClientTesting.assertFirecrawlScrapeTargetAllowed("https://example.com/page"), - ).not.toThrow(); + ).toBeUndefined(); for (const blockedUrl of [ "http://localhost/admin", @@ -348,7 +352,11 @@ describe("firecrawl tools", () => { expect(provider.id).toBe("firecrawl"); expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); - expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true); + const pluginEntry = applied.plugins?.entries?.firecrawl; + if (!pluginEntry) { + throw new Error("expected Firecrawl fetch plugin entry"); + } + expect(pluginEntry.enabled).toBe(true); }); it("passes proxy and storeInCache through the fetch provider tool", async () => { diff --git a/extensions/fireworks/index.test.ts b/extensions/fireworks/index.test.ts index 920fd69a587..7e03deebdf5 100644 --- a/extensions/fireworks/index.test.ts +++ b/extensions/fireworks/index.test.ts @@ -48,8 +48,11 @@ describe("fireworks provider plugin", () => { expect(provider.aliases).toEqual(["fireworks-ai"]); expect(provider.envVars).toEqual(["FIREWORKS_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved?.provider.id).toBe("fireworks"); - expect(resolved?.method.id).toBe("api-key"); + if (!resolved) { + throw new Error("expected Fireworks api-key auth choice"); + } + expect(resolved.provider.id).toBe("fireworks"); + expect(resolved.method.id).toBe("api-key"); }); it("builds the Fireworks catalog", async () => { @@ -58,17 +61,21 @@ describe("fireworks provider plugin", () => { expect(catalogProvider.api).toBe("openai-completions"); expect(catalogProvider.baseUrl).toBe(FIREWORKS_BASE_URL); - expect(catalogProvider.models?.map((model) => model.id)).toEqual([ + const models = catalogProvider.models; + if (!models) { + throw new Error("expected Fireworks catalog models"); + } + expect(models.map((model) => model.id)).toEqual([ FIREWORKS_K2_6_MODEL_ID, FIREWORKS_DEFAULT_MODEL_ID, ]); - expect(catalogProvider.models?.[0]).toMatchObject({ + expect(models[0]).toMatchObject({ reasoning: false, input: ["text", "image"], contextWindow: FIREWORKS_K2_6_CONTEXT_WINDOW, maxTokens: FIREWORKS_K2_6_MAX_TOKENS, }); - expect(catalogProvider.models?.[1]).toMatchObject({ + expect(models[1]).toMatchObject({ reasoning: false, input: ["text", "image"], contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW, diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index b8f3f7beb85..3cfa5b32cf8 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -34,6 +34,14 @@ afterAll(() => { const TEST_BASE_URL = "https://api.githubcopilot.test"; +function shouldContinueAutoSelection(error: Error): boolean { + const shouldContinue = githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection; + if (!shouldContinue) { + throw new Error("GitHub Copilot embedding adapter did not expose auto-selection fallback"); + } + return shouldContinue(error); +} + function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: unknown }>) { return { data: models }; } @@ -242,25 +250,19 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { }); it("treats token parsing and discovery failures as auto-fallback errors", () => { + expect(shouldContinueAutoSelection(new Error("Copilot token response missing token"))).toBe( + true, + ); expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( - new Error("Copilot token response missing token"), - ), - ).toBe(true); - expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( + shouldContinueAutoSelection( new Error("Unexpected response from GitHub Copilot token endpoint"), ), ).toBe(true); expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( + shouldContinueAutoSelection( new Error("GitHub Copilot model discovery returned invalid JSON"), ), ).toBe(true); - expect( - githubCopilotMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection!( - new Error("Network timeout"), - ), - ).toBe(false); + expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false); }); }); diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index d0250e3ffbf..163f8866223 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -50,6 +50,10 @@ import { } from "./src/transports/twilio.js"; import type { GoogleMeetSession } from "./src/transports/types.js"; +type GoogleMeetManifestConfigSchema = JsonSchemaObject & { + properties?: Record }>; +}; + const voiceCallMocks = vi.hoisted(() => ({ joinMeetViaVoiceCallGateway: vi.fn(async () => ({ callId: "call-1", @@ -121,6 +125,15 @@ function jsonResponse(value: unknown): Response { }); } +function requireGoogleMeetManifestConfigSchema(manifest: { + configSchema?: GoogleMeetManifestConfigSchema; +}): GoogleMeetManifestConfigSchema { + if (!manifest.configSchema) { + throw new Error("Google Meet manifest did not include a config schema"); + } + return manifest.configSchema; +} + function requestUrl(input: RequestInfo | URL): URL { if (typeof input === "string") { return new URL(input); @@ -548,10 +561,9 @@ describe("google-meet plugin", () => { readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), ) as { uiHints?: Record; - configSchema?: JsonSchemaObject & { - properties?: Record }>; - }; + configSchema?: GoogleMeetManifestConfigSchema; }; + const configSchema = requireGoogleMeetManifestConfigSchema(manifest); const entry = plugin as unknown as { configSchema: { uiHints?: Record; @@ -574,7 +586,7 @@ describe("google-meet plugin", () => { "chrome.bargeInCooldownMs": expect.objectContaining({ advanced: true }), "voiceCall.postDtmfSpeechDelayMs": expect.objectContaining({ advanced: true }), }); - expect(manifest.configSchema?.properties?.chrome?.properties).toMatchObject({ + expect(configSchema.properties?.chrome?.properties).toMatchObject({ audioBufferBytes: expect.objectContaining({ type: "number", default: 4096 }), bargeInInputCommand: expect.objectContaining({ type: "array", @@ -584,11 +596,11 @@ describe("google-meet plugin", () => { bargeInPeakThreshold: expect.objectContaining({ type: "number", default: 2500 }), bargeInCooldownMs: expect.objectContaining({ type: "number", default: 900 }), }); - expect(manifest.configSchema?.properties?.voiceCall?.properties).toMatchObject({ + expect(configSchema.properties?.voiceCall?.properties).toMatchObject({ postDtmfSpeechDelayMs: expect.objectContaining({ type: "number", default: 5000 }), }); const result = validateJsonSchemaValue({ - schema: manifest.configSchema!, + schema: configSchema, cacheKey: "google-meet.manifest.voice-call-post-dtmf-speech-delay", value: { voiceCall: { @@ -1865,9 +1877,9 @@ describe("google-meet plugin", () => { }), ]), ); - expect(result.details.checks?.some((check) => check.id === "chrome-local-audio-device")).toBe( - false, - ); + expect( + result.details.checks?.filter((check) => check.id === "chrome-local-audio-device"), + ).toEqual([]); expect(runCommandWithTimeout).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, "platform", { value: originalPlatform }); diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 4c09c7ddff1..7ba11a69d83 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -251,8 +251,8 @@ describe("google provider plugin hooks", () => { if (!bridge) { throw new Error("expected Google realtime bridge"); } - expect(() => bridge.sendAudio(Buffer.alloc(160))).not.toThrow(); - expect(() => bridge.setMediaTimestamp(20)).not.toThrow(); - expect(() => bridge.sendUserMessage?.("hello")).not.toThrow(); + expect(bridge.sendAudio(Buffer.alloc(160))).toBeUndefined(); + expect(bridge.setMediaTimestamp(20)).toBeUndefined(); + expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); }); diff --git a/extensions/google/manifest.test.ts b/extensions/google/manifest.test.ts index 663ad4edaa7..33764c0a5e2 100644 --- a/extensions/google/manifest.test.ts +++ b/extensions/google/manifest.test.ts @@ -98,6 +98,7 @@ describe("google manifest model catalog", () => { for (const provider of GOOGLE_CHAT_PROVIDERS) { expect(manifest.modelIdNormalization?.providers?.[provider]?.aliases).toMatchObject({ "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", }); } }); diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 396df81e98a..1274fffa620 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -46,6 +46,16 @@ const mockReaddirSync = vi.fn(); const mockSettingsExistsSync = vi.fn(); const mockSettingsReadFileSync = vi.fn(); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("resolveGeminiCliSelectedAuthType", () => { const ENV_KEYS = ["GOOGLE_GENAI_USE_GCA"] as const; @@ -520,7 +530,7 @@ describe("extractGeminiCliCredentials", () => { // First call const result1 = extractGeminiCliCredentials(); - expect(result1).not.toBeNull(); + expectFakeCliCredentials(result1); // Second call should use cache (readFileSync not called again) const readCount = mockReadFileSync.mock.calls.length; @@ -843,8 +853,10 @@ describe("loginGeminiCliOAuth", () => { }); await runProjectDiscoveryExpectingProjectId("env-project"); - expect(requests.filter(({ url }) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); - expect(requests.some(({ url }) => url.includes("v1internal:onboardUser"))).toBe(false); + expect(countMatching(requests, ({ url }) => url.includes("v1internal:loadCodeAssist"))).toBe(3); + expect(requests.map(({ url }) => url)).not.toEqual( + expect.arrayContaining([expect.stringContaining("v1internal:onboardUser")]), + ); }); it("skips loadCodeAssist entirely when Gemini CLI is configured for personal OAuth", async () => { diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 24111760b7c..8369be29d40 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -11,6 +11,7 @@ "google": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", @@ -21,6 +22,7 @@ "google-gemini-cli": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", @@ -31,6 +33,7 @@ "google-vertex": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", diff --git a/extensions/gradium/speech-provider.test.ts b/extensions/gradium/speech-provider.test.ts index e98c4beb922..784828f85d9 100644 --- a/extensions/gradium/speech-provider.test.ts +++ b/extensions/gradium/speech-provider.test.ts @@ -94,8 +94,12 @@ describe("gradium speech provider", () => { const audioData = Buffer.from("ulaw-audio-data"); const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 })); vi.stubGlobal("fetch", fetchMock); + const synthesizeTelephony = provider.synthesizeTelephony; + if (!synthesizeTelephony) { + throw new Error("Expected Gradium provider synthesizeTelephony"); + } - const result = await provider.synthesizeTelephony!({ + const result = await synthesizeTelephony({ text: "Telephony test", cfg: {} as never, providerConfig: { apiKey: "gsk_test123", voiceId: "default-voice" }, diff --git a/extensions/groq/index.test.ts b/extensions/groq/index.test.ts index a18bb3a2a6c..34b98ba3c86 100644 --- a/extensions/groq/index.test.ts +++ b/extensions/groq/index.test.ts @@ -46,6 +46,11 @@ describe("groq provider compat", () => { label: "Groq", envVars: ["GROQ_API_KEY"], }); - expect(captured.mediaUnderstandingProviders[0]?.id).toBe("groq"); + expect(captured.mediaUnderstandingProviders).toHaveLength(1); + const [mediaProvider] = captured.mediaUnderstandingProviders; + if (!mediaProvider) { + throw new Error("Expected Groq media understanding provider"); + } + expect(mediaProvider.id).toBe("groq"); }); }); diff --git a/extensions/huggingface/index.test.ts b/extensions/huggingface/index.test.ts index c6c89a8ad2d..e40c1843fe5 100644 --- a/extensions/huggingface/index.test.ts +++ b/extensions/huggingface/index.test.ts @@ -40,7 +40,11 @@ function registerProviderWithPluginConfig(pluginConfig: Record) ); expect(registerProviderMock).toHaveBeenCalledTimes(1); - return registerProviderMock.mock.calls[0]?.[0]; + const firstCall = registerProviderMock.mock.calls.at(0); + if (!firstCall) { + throw new Error("expected huggingface provider registration"); + } + return firstCall[0]; } describe("huggingface plugin", () => { diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index b6aff0a77c0..c2b88d9aa17 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -19,6 +19,66 @@ afterEach(() => { resetFacadeRuntimeStateForTest(); }); +type IMessageOutbound = NonNullable["outbound"]>; +type IMessageMessageAdapter = NonNullable; +type IMessageMessageSender = NonNullable; + +function requireOutbound(): IMessageOutbound { + const outbound = createIMessageTestPlugin().outbound; + if (!outbound) { + throw new Error("Expected iMessage test plugin outbound adapter"); + } + return outbound; +} + +function requireOutboundSendText( + outbound: IMessageOutbound, +): NonNullable { + const sendText = outbound.sendText; + if (!sendText) { + throw new Error("Expected iMessage outbound sendText"); + } + return sendText; +} + +function requireOutboundSendMedia( + outbound: IMessageOutbound, +): NonNullable { + const sendMedia = outbound.sendMedia; + if (!sendMedia) { + throw new Error("Expected iMessage outbound sendMedia"); + } + return sendMedia; +} + +function requireMessageAdapter(): IMessageMessageAdapter { + const adapter = imessagePlugin.message; + if (!adapter) { + throw new Error("Expected iMessage message adapter"); + } + return adapter; +} + +function requireMessageSendText( + adapter: IMessageMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected iMessage message adapter text sender"); + } + return text; +} + +function requireMessageSendMedia( + adapter: IMessageMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected iMessage message adapter media sender"); + } + return media; +} + describe("createIMessageTestPlugin", () => { it("does not load the bundled iMessage facade by default", () => { expect(listImportedBundledPluginFacadeIds()).toEqual([]); @@ -55,7 +115,9 @@ describe("createIMessageTestPlugin", () => { }); it("backs declared durable final capabilities with delivery proofs", async () => { - const outbound = createIMessageTestPlugin().outbound!; + const outbound = requireOutbound(); + const sendText = requireOutboundSendText(outbound); + const sendMedia = requireOutboundSendMedia(outbound); const sendIMessage = async () => ({ messageId: "imsg-1" }); await verifyDurableFinalCapabilityProofs({ @@ -64,7 +126,7 @@ describe("createIMessageTestPlugin", () => { proofs: { text: async () => { await expect( - outbound.sendText?.({ + sendText({ cfg: {} as never, to: "+15551234567", text: "hello", @@ -74,7 +136,7 @@ describe("createIMessageTestPlugin", () => { }, media: async () => { await expect( - outbound.sendMedia?.({ + sendMedia({ cfg: {} as never, to: "+15551234567", text: "caption", @@ -86,7 +148,7 @@ describe("createIMessageTestPlugin", () => { }, replyTo: async () => { await expect( - outbound.sendText?.({ + sendText({ cfg: {} as never, to: "+15551234567", text: "reply", @@ -96,7 +158,7 @@ describe("createIMessageTestPlugin", () => { ).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" }); }, messageSendingHooks: () => { - expect(outbound.sendText).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); @@ -119,49 +181,52 @@ describe("createIMessageTestPlugin", () => { }), }; }; + const adapter = requireMessageAdapter(); + const sendText = requireMessageSendText(adapter); + const sendMedia = requireMessageSendMedia(adapter); await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "imessageMessage", - adapter: imessagePlugin.message!, + adapter, proofs: { text: async () => { - const result = await imessagePlugin.message?.send?.text?.({ + const result = await sendText({ cfg: {} as never, to: "+15551234567", text: "hello", deps: { imessage: sendIMessage }, - } as Parameters>[0] & { + } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); - expect(result?.receipt.platformMessageIds).toEqual(["imsg-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["imsg-text-1"]); }, media: async () => { - const result = await imessagePlugin.message?.send?.media?.({ + const result = await sendMedia({ cfg: {} as never, to: "+15551234567", text: "caption", mediaUrl: "/tmp/image.png", mediaLocalRoots: ["/tmp"], deps: { imessage: sendIMessage }, - } as Parameters>[0] & { + } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); - expect(result?.receipt.platformMessageIds).toEqual(["imsg-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["imsg-media-1"]); }, replyTo: async () => { - const result = await imessagePlugin.message?.send?.text?.({ + const result = await sendText({ cfg: {} as never, to: "+15551234567", text: "reply", replyToId: "reply-1", deps: { imessage: sendIMessage }, - } as Parameters>[0] & { + } as Parameters[0] & { deps: { imessage: typeof sendIMessage }; }); - expect(result?.receipt.replyToId).toBe("reply-1"); + expect(result.receipt.replyToId).toBe("reply-1"); }, messageSendingHooks: () => { - expect(imessagePlugin.message?.send?.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); diff --git a/extensions/inworld/speech-provider.test.ts b/extensions/inworld/speech-provider.test.ts index 3fd8437fd65..cd79f58b3c5 100644 --- a/extensions/inworld/speech-provider.test.ts +++ b/extensions/inworld/speech-provider.test.ts @@ -111,12 +111,18 @@ describe("buildInworldSpeechProvider", () => { allowSeed: true, }; - expect(provider.parseDirectiveToken?.({ key: "voice", value: "Ashley", policy })).toEqual({ + const parseDirectiveToken = provider.parseDirectiveToken; + expect(parseDirectiveToken).toBeTypeOf("function"); + if (!parseDirectiveToken) { + throw new Error("expected Inworld directive parser"); + } + + expect(parseDirectiveToken({ key: "voice", value: "Ashley", policy })).toEqual({ handled: true, overrides: { voiceId: "Ashley" }, }); expect( - provider.parseDirectiveToken?.({ + parseDirectiveToken({ key: "model", value: "inworld-tts-1.5-mini", policy, @@ -125,7 +131,7 @@ describe("buildInworldSpeechProvider", () => { handled: true, overrides: { modelId: "inworld-tts-1.5-mini" }, }); - expect(provider.parseDirectiveToken?.({ key: "temperature", value: "0.7", policy })).toEqual({ + expect(parseDirectiveToken({ key: "temperature", value: "0.7", policy })).toEqual({ handled: true, overrides: { temperature: 0.7 }, }); diff --git a/extensions/inworld/tts.test.ts b/extensions/inworld/tts.test.ts index 22e71e3233f..33f57b6ac99 100644 --- a/extensions/inworld/tts.test.ts +++ b/extensions/inworld/tts.test.ts @@ -266,8 +266,11 @@ describe("inworldTTS", () => { expect(request.url).toBe("https://api.inworld.ai/tts/v1/voice:stream"); expect(request.auditContext).toBe("inworld-tts"); expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] }); - expect(request.init?.method).toBe("POST"); - const headers = new Headers(request.init?.headers); + if (!request.init) { + throw new Error("expected Inworld TTS request init"); + } + expect(request.init.method).toBe("POST"); + const headers = new Headers(request.init.headers); expect(headers.get("authorization")).toBe("Basic test-key"); expect(headers.get("content-type")).toBe("application/json"); expect(JSON.parse(readRequestBody(request))).toEqual({ diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 35002ae90ea..9cc8d488847 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -132,7 +132,7 @@ describe("sendMessageIrc cfg threading", () => { direction: "outbound", }); expect(result.target).toBe("#room"); - expect(result.messageId).toEqual(expect.any(String)); + expect(result.messageId).toBeTypeOf("string"); expect(result.messageId.length).toBeGreaterThan(0); expect(result.receipt).toMatchObject({ primaryPlatformMessageId: "irc-msg-1", @@ -191,7 +191,7 @@ describe("sendMessageIrc cfg threading", () => { expect(hoisted.loadConfig).not.toHaveBeenCalled(); expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello"); expect(result.target).toBe("#room"); - expect(result.messageId).toEqual(expect.any(String)); + expect(result.messageId).toBeTypeOf("string"); expect(result.messageId.length).toBeGreaterThan(0); }); diff --git a/extensions/irc/src/setup.test.ts b/extensions/irc/src/setup.test.ts index 56829a16577..0422cbec5e2 100644 --- a/extensions/irc/src/setup.test.ts +++ b/extensions/irc/src/setup.test.ts @@ -278,21 +278,24 @@ describe("irc setup", () => { const applyAccountConfig = ircSetupAdapter.applyAccountConfig; expect(validateInput).toBeTypeOf("function"); expect(applyAccountConfig).toBeTypeOf("function"); + if (!validateInput) { + throw new Error("Expected IRC setup validateInput"); + } expect( - validateInput!({ + validateInput({ input: { host: "", nick: "openclaw" }, } as never), ).toBe("IRC requires host."); expect( - validateInput!({ + validateInput({ input: { host: "irc.libera.chat", nick: "" }, } as never), ).toBe("IRC requires nick."); expect( - validateInput!({ + validateInput({ input: { host: "irc.libera.chat", nick: "openclaw" }, } as never), ).toBeNull(); diff --git a/extensions/kilocode/implicit-provider.test.ts b/extensions/kilocode/implicit-provider.test.ts index b549238d372..67040e3a698 100644 --- a/extensions/kilocode/implicit-provider.test.ts +++ b/extensions/kilocode/implicit-provider.test.ts @@ -7,6 +7,6 @@ describe("Kilo Gateway implicit provider", () => { expect(provider.baseUrl).toBe("https://api.kilo.ai/api/gateway/"); expect(provider.api).toBe("openai-completions"); - expect(provider.models?.length).toBeGreaterThan(0); + expect(provider.models.length).toBeGreaterThan(0); }); }); diff --git a/extensions/kilocode/index.test.ts b/extensions/kilocode/index.test.ts index 1f7ce78b1bf..509f3083907 100644 --- a/extensions/kilocode/index.test.ts +++ b/extensions/kilocode/index.test.ts @@ -95,7 +95,7 @@ describe("kilocode provider plugin", () => { ).toEqual([ { provider: "kilocode", - id: "google/gemini-3-pro-preview", + id: "google/gemini-3.1-pro-preview", name: "Gemini 3 Pro Preview", input: ["text", "image"], reasoning: true, diff --git a/extensions/kilocode/onboard.test.ts b/extensions/kilocode/onboard.test.ts index 311af08862c..cd24bb1126f 100644 --- a/extensions/kilocode/onboard.test.ts +++ b/extensions/kilocode/onboard.test.ts @@ -150,9 +150,10 @@ describe("Kilo Gateway provider config", () => { try { const result = resolveEnvApiKey("kilocode"); - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("test-kilo-key"); - expect(result?.source).toContain("KILOCODE_API_KEY"); + expect(result).toMatchObject({ + apiKey: "test-kilo-key", + source: expect.stringContaining("KILOCODE_API_KEY"), + }); } finally { vi.unstubAllEnvs(); } diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index 72c7f663f50..ebb13257638 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -175,8 +175,8 @@ describe("discoverKilocodeModels (fetch path)", () => { expect(sonnet.cost.cacheWrite).toBeCloseTo(3.75); expect(sonnet.input).toEqual(["text", "image"]); expect(sonnet.reasoning).toBe(true); - expect(sonnet?.contextWindow).toBe(200000); - expect(sonnet?.maxTokens).toBe(8192); + expect(sonnet.contextWindow).toBe(200000); + expect(sonnet.maxTokens).toBe(8192); }); }); @@ -234,9 +234,9 @@ describe("discoverKilocodeModels (fetch path)", () => { }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); - const textModel = models.find((m) => m.id === "some/text-model"); - expect(textModel?.input).toEqual(["text"]); - expect(textModel?.reasoning).toBe(false); + const textModel = requireModelById(models, "some/text-model"); + expect(textModel.input).toEqual(["text"]); + expect(textModel.reasoning).toBe(false); }); }); diff --git a/extensions/line/src/bot-message-context.test.ts b/extensions/line/src/bot-message-context.test.ts index 2ea9a261aef..9dd718f2e05 100644 --- a/extensions/line/src/bot-message-context.test.ts +++ b/extensions/line/src/bot-message-context.test.ts @@ -107,13 +107,9 @@ describe("buildLineMessageContext", () => { account, commandAuthorized: true, }); - expect(context).not.toBeNull(); - if (!context) { - throw new Error("context missing"); - } - expect(context.ctxPayload.OriginatingTo).toBe("line:group:group-1"); - expect(context.ctxPayload.To).toBe("line:group:group-1"); + expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-1"); + expect(context?.ctxPayload.To).toBe("line:group:group-1"); }); it("routes group postback replies to the group id", async () => { @@ -206,7 +202,6 @@ describe("buildLineMessageContext", () => { commandAuthorized: false, }); - expect(context).not.toBeNull(); expect(context?.ctxPayload.CommandAuthorized).toBe(false); }); @@ -284,9 +279,8 @@ describe("buildLineMessageContext", () => { account, commandAuthorized: true, }); - expect(context).not.toBeNull(); - expect(context!.route.agentId).toBe("line-group-agent"); - expect(context!.route.matchedBy).toBe("binding.peer"); + expect(context?.route.agentId).toBe("line-group-agent"); + expect(context?.route.matchedBy).toBe("binding.peer"); }); it("room peer binding matches raw roomId without prefix (#21907)", async () => { @@ -322,9 +316,8 @@ describe("buildLineMessageContext", () => { account, commandAuthorized: true, }); - expect(context).not.toBeNull(); - expect(context!.route.agentId).toBe("line-room-agent"); - expect(context!.route.matchedBy).toBe("binding.peer"); + expect(context?.route.agentId).toBe("line-room-agent"); + expect(context?.route.matchedBy).toBe("binding.peer"); }); it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", () => { @@ -397,9 +390,8 @@ describe("buildLineMessageContext", () => { commandAuthorized: true, }); - expect(context).not.toBeNull(); - expect(context!.route.agentId).toBe("codex"); - expect(context!.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123"); - expect(context!.route.matchedBy).toBe("binding.channel"); + expect(context?.route.agentId).toBe("codex"); + expect(context?.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123"); + expect(context?.route.matchedBy).toBe("binding.channel"); }); }); diff --git a/extensions/line/src/reply-payload-transform.test.ts b/extensions/line/src/reply-payload-transform.test.ts index 6f3ba9cc7f4..cea461a88fe 100644 --- a/extensions/line/src/reply-payload-transform.test.ts +++ b/extensions/line/src/reply-payload-transform.test.ts @@ -272,7 +272,8 @@ describe("parseLineDirectives", () => { expect(flexMessage.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0); } if ("expectBodyContents" in testCase && testCase.expectBodyContents) { - expect(flexMessage.contents?.body?.contents, testCase.name).toEqual(expect.any(Array)); + expect(Array.isArray(flexMessage.contents?.body?.contents), testCase.name).toBe(true); + expect(flexMessage.contents?.body?.contents?.length, testCase.name).toBeGreaterThan(0); } } }); diff --git a/extensions/line/src/webhook-node.test.ts b/extensions/line/src/webhook-node.test.ts index d229e6a439d..31d31ade96a 100644 --- a/extensions/line/src/webhook-node.test.ts +++ b/extensions/line/src/webhook-node.test.ts @@ -416,7 +416,7 @@ describe("createLineNodeWebhookHandler", () => { it("releases authenticated requests before event processing completes", async () => { const rawBody = JSON.stringify({ events: [{ type: "message" }] }); - let releaseAuthenticated!: () => void; + let releaseAuthenticated: (() => void) | undefined; const bot = { handleWebhook: vi.fn( async () => @@ -444,6 +444,9 @@ describe("createLineNodeWebhookHandler", () => { }); expect(res.headersSent).toBe(false); + if (!releaseAuthenticated) { + throw new Error("Expected LINE authenticated request release callback to be initialized"); + } releaseAuthenticated(); await request; diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index e77ca10ab63..7992a144ac2 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -75,7 +75,8 @@ describe("matrix plugin", () => { if (!entry.setChannelRuntime) { throw new Error("expected Matrix runtime setter"); } - expect(() => entry.setChannelRuntime?.({ marker: "runtime" } as never)).not.toThrow(); + entry.setChannelRuntime({ marker: "runtime" } as never); + expect(runtimeMocks.setMatrixRuntime).not.toHaveBeenCalled(); }); it("wires CLI metadata through the bundled entry", () => { diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 5e5c8156245..604eb978a96 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -44,8 +44,9 @@ describe("matrix channel message adapter", () => { it("backs declared durable-final capabilities with runtime outbound proofs", async () => { const adapter = matrixPlugin.message; - if (adapter?.send?.text === undefined || adapter.send.media === undefined) { - throw new Error("expected matrix text and media message adapter"); + expect(adapter).toBeDefined(); + if (!adapter?.send?.text || !adapter.send.media) { + throw new Error("Expected Matrix message adapter send capabilities."); } const sendText = adapter.send.text; const sendMedia = adapter.send.media; @@ -93,7 +94,7 @@ describe("matrix channel message adapter", () => { const proveReplyThread = async () => { mocks.sendMessageMatrix.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg, to: "room:!room:example", text: "threaded", @@ -116,14 +117,14 @@ describe("matrix channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "matrixMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveReplyThread, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(adapter.send?.text).toBeTypeOf("function"); }, }, }); diff --git a/extensions/matrix/src/matrix/client/config.test.ts b/extensions/matrix/src/matrix/client/config.test.ts index c2cc11e1972..ecc6c1ed81b 100644 --- a/extensions/matrix/src/matrix/client/config.test.ts +++ b/extensions/matrix/src/matrix/client/config.test.ts @@ -228,7 +228,7 @@ describe("Matrix auth/config live surfaces", () => { ).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i); }); - it("does not throw when accessToken uses a non-env SecretRef", () => { + it("leaves non-env SecretRef access tokens unresolved", () => { const cfg = { channels: { matrix: { diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index 9b87eb6820b..6c8acebed96 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -52,10 +52,13 @@ function createSyncResponse(nextBatch: string): ISyncResponse { } function createDeferred() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -96,7 +99,17 @@ describe("FileBackedMatrixSyncStore", () => { type: "com.openclaw.test", }, ]); - expect(savedSync?.roomsData.join?.["!room:example.org"]).toEqual(expect.any(Object)); + expect(savedSync?.roomsData.join?.["!room:example.org"]).toMatchObject({ + timeline: { + events: [ + { + event_id: "$message", + sender: "@user:example.org", + type: "m.room.message", + }, + ], + }, + }); expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false); }); diff --git a/extensions/matrix/src/matrix/credentials.test.ts b/extensions/matrix/src/matrix/credentials.test.ts index cff999c4282..cfba0d0a7ed 100644 --- a/extensions/matrix/src/matrix/credentials.test.ts +++ b/extensions/matrix/src/matrix/credentials.test.ts @@ -23,6 +23,18 @@ const DEFAULT_LEGACY_CREDENTIALS = { const EXPECTS_POSIX_PRIVATE_FILE_MODE = process.platform !== "win32"; +type MatrixCredentials = NonNullable>; + +function expectMatrixCredentials( + credentials: ReturnType, +): MatrixCredentials { + expect(credentials).toEqual(expect.objectContaining({ createdAt: expect.any(String) })); + if (credentials === null) { + throw new Error("Expected Matrix credentials"); + } + return credentials; +} + describe("matrix credentials storage", () => { const tempDirs: string[] = []; @@ -96,15 +108,15 @@ describe("matrix credentials storage", () => { "default", ); const initial = loadMatrixCredentials({}, "default"); - expect(initial).not.toBeNull(); + const initialCredentials = expectMatrixCredentials(initial); vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z")); await touchMatrixCredentials({}, "default"); const touched = loadMatrixCredentials({}, "default"); - expect(touched).not.toBeNull(); + const touchedCredentials = expectMatrixCredentials(touched); - expect(touched?.createdAt).toBe(initial?.createdAt); - expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z"); + expect(touchedCredentials.createdAt).toBe(initialCredentials.createdAt); + expect(touchedCredentials.lastUsedAt).toBe("2026-03-01T10:05:00.000Z"); } finally { vi.useRealTimers(); } diff --git a/extensions/matrix/src/matrix/direct-management.test.ts b/extensions/matrix/src/matrix/direct-management.test.ts index 7cab96fa1b7..4fa17052cd8 100644 --- a/extensions/matrix/src/matrix/direct-management.test.ts +++ b/extensions/matrix/src/matrix/direct-management.test.ts @@ -305,12 +305,15 @@ describe("promoteMatrixDirectRoomCandidate", () => { it("serializes concurrent m.direct writes so distinct mappings are not lost", async () => { let directContent: Record = {}; - let releaseFirstWrite!: () => void; + let releaseFirstWrite: (() => void) | undefined; const firstWriteStarted = new Promise((resolve) => { releaseFirstWrite = () => { resolve(); }; }); + if (!releaseFirstWrite) { + throw new Error("Expected first m.direct write release callback to be initialized"); + } let writeCount = 0; const setAccountData = vi.fn(async (_eventType: string, content: Record) => { writeCount += 1; diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 7916ef80b9c..51dc802b080 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -593,8 +593,10 @@ describe("registerMatrixMonitorEvents verification routing", () => { await flushTasks(); const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); - expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); + expect(bodies).toEqual(expect.arrayContaining([expect.stringContaining("SAS emoji:")])); + expect(bodies).toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 6158 1986 3513")]), + ); }); it("rehydrates an in-progress DM verification before resolving SAS notices", async () => { @@ -961,7 +963,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { await vi.waitFor(() => { const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); + expect(bodies).toEqual(expect.arrayContaining([expect.stringContaining("SAS emoji:")])); }); } finally { vi.useRealTimers(); @@ -1215,11 +1217,13 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await flushTasks(); - expect( - getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 6158 1986 3513")), - ).toBe(true); + expect(getSentNoticeBodies(sendMessage)).toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 6158 1986 3513")]), + ); const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + expect(bodies).not.toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 1111 2222 3333")]), + ); }); it("preserves strict-room SAS fallback when active DM inspection cannot resolve a room", async () => { @@ -1321,11 +1325,13 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await flushTasks(); - expect( - getSentNoticeBodies(sendMessage).some((body) => body.includes("SAS decimal: 6158 1986 3513")), - ).toBe(true); + expect(getSentNoticeBodies(sendMessage)).toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 6158 1986 3513")]), + ); const bodies = getSentNoticeBodies(sendMessage); - expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + expect(bodies).not.toEqual( + expect.arrayContaining([expect.stringContaining("SAS decimal: 1111 2222 3333")]), + ); }); it("does not emit SAS notices for cancelled verification events", async () => { @@ -1803,7 +1809,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { } }); - it("does not throw when getUserId fails during decrypt guidance lookup", async () => { + it("logs decrypt guidance when getUserId fails during lookup", async () => { const { logger, logVerboseMessage, failedDecryptListener } = createHarness({ accountId: "ops", selfUserIdError: new Error("lookup failed"), diff --git a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts index c3c7f1198e6..98d0dc3a8fa 100644 --- a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts @@ -90,10 +90,13 @@ beforeEach(() => { }); function deferred() { - let resolve!: (value: T | PromiseLike) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -199,8 +202,12 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { const ctx = finalizeInboundContext.mock.calls[1]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }>; expect(history).toHaveLength(2); - expect(history.map((h) => h.body).some((b) => b.includes("msg A"))).toBe(true); - expect(history.map((h) => h.body).some((b) => b.includes("msg B"))).toBe(true); + expect(history.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("msg A")]), + ); + expect(history.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("msg B")]), + ); } // @agent_b trigger D — A/B/C consumed; history is empty @@ -393,7 +400,9 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { expect(finalizeInboundContext).toHaveBeenCalledOnce(); const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((entry) => entry.body.includes("[matrix image attachment]"))).toBe(true); + expect(history?.map((entry) => entry.body)).toEqual( + expect.arrayContaining([expect.stringContaining("[matrix image attachment]")]), + ); }); it("includes skipped poll updates in next trigger history", async () => { @@ -458,7 +467,9 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { expect(getRelations).toHaveBeenCalledOnce(); const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((entry) => entry.body.includes("Lunch?"))).toBe(true); + expect(history?.map((entry) => entry.body)).toEqual( + expect.arrayContaining([expect.stringContaining("Lunch?")]), + ); }); }); @@ -511,8 +522,12 @@ describe("matrix group chat history — scenario 2: race condition safety", () = expect(finalizeInboundContext).toHaveBeenCalledTimes(2); const ctxForC = finalizeInboundContext.mock.calls[1]?.[0] as Record; const history = ctxForC["InboundHistory"] as Array<{ body: string }>; - expect(history.some((h) => h.body.includes("msg B"))).toBe(true); - expect(history.every((h) => !h.body.includes("msg A"))).toBe(true); + expect(history.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("msg B")]), + ); + expect(history.map((h) => h.body)).not.toEqual( + expect.arrayContaining([expect.stringContaining("msg A")]), + ); }); it("watermark does not advance when final reply delivery fails (retry sees same history)", async () => { @@ -546,7 +561,9 @@ describe("matrix group chat history — scenario 2: race condition safety", () = { const ctx = finalizeInboundContext.mock.calls[1]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((h) => h.body.includes("pending msg"))).toBe(true); + expect(history?.map((h) => h.body)).toEqual( + expect.arrayContaining([expect.stringContaining("pending msg")]), + ); } }); @@ -624,7 +641,9 @@ describe("matrix group chat history — scenario 2: race condition safety", () = const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record; const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined; - expect(history?.some((entry) => entry.body.includes("plain before trigger"))).toBe(true); + expect(history?.map((entry) => entry.body)).toEqual( + expect.arrayContaining([expect.stringContaining("plain before trigger")]), + ); }); it("preserves arrival order when a plain message starts before a later trigger", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 43a577b33ff..f36f7ac39a9 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -2766,7 +2766,7 @@ describe("matrix monitor handler draft streaming", () => { await finish(); }); - it("uses resolved Matrix account progress config for draft text", async () => { + it("uses resolved Matrix account progress maxLines for draft text", async () => { const { dispatch } = createStreamingHarness({ streaming: "progress", previewToolProgressEnabled: true, @@ -2789,7 +2789,7 @@ describe("matrix monitor handler draft streaming", () => { await vi.waitFor(() => { expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1); }); - expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Pearling\n- `second`"); + expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("- `second`"); await finish(); }); diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts index 14aae68654e..3c18a690858 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.test.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -228,7 +228,7 @@ describe("resolveMentions", () => { }); it("ignores out-of-range hexadecimal HTML entities in visible labels", () => { - expect(() => + expect( resolveMentions({ content: { msgtype: "m.text", @@ -239,11 +239,11 @@ describe("resolveMentions", () => { text: "hello", mentionRegexes: [], }), - ).not.toThrow(); + ).toEqual({ hasExplicitMention: false, wasMentioned: false }); }); it("ignores oversized decimal HTML entities in visible labels", () => { - expect(() => + expect( resolveMentions({ content: { msgtype: "m.text", @@ -255,7 +255,7 @@ describe("resolveMentions", () => { text: "hello", mentionRegexes: [], }), - ).not.toThrow(); + ).toEqual({ hasExplicitMention: false, wasMentioned: false }); }); it("does not detect mention when displayName is spoofed", () => { diff --git a/extensions/matrix/src/matrix/monitor/room-info.test.ts b/extensions/matrix/src/matrix/monitor/room-info.test.ts index c982de51775..ad59f517fda 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.test.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.test.ts @@ -46,7 +46,13 @@ function createMissingMetadataError() { } function getRoomStateCallCount(client: RoomInfoClientStub, eventType: string) { - return client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length; + let count = 0; + for (const [, type] of client.getRoomStateEvent.mock.calls) { + if (type === eventType) { + count++; + } + } + return count; } describe("createMatrixRoomInfoResolver", () => { diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 693e638e2ad..69d0a0a5be2 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -1573,8 +1573,7 @@ describe("MatrixClient crypto bootstrapping", () => { } ).cryptoBootstrapper.bootstrap = bootstrapSpy; - // start() must NOT throw even when the repair bootstrap fails - await expect(client.start()).resolves.not.toThrow(); + await expect(client.start()).resolves.toBeUndefined(); // repair was attempted expect(bootstrapSpy).toHaveBeenCalledTimes(2); @@ -1624,9 +1623,12 @@ describe("MatrixClient crypto bootstrapping", () => { debug?: (...args: unknown[]) => void; getChild?: (namespace: string) => unknown; } | null; - expect(logger).not.toBeNull(); - expect(logger?.debug).toBeTypeOf("function"); - expect(logger?.getChild).toBeTypeOf("function"); + expect(logger).toEqual( + expect.objectContaining({ + debug: expect.any(Function), + getChild: expect.any(Function), + }), + ); }); it("passes a custom sync filter to matrix-js-sdk startup", async () => { @@ -3211,7 +3213,9 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.success).toBe(true); expect(result.verification.verified).toBe(true); expect(result.crossSigning.published).toBe(true); - expect(result.cryptoBootstrap).not.toBeNull(); + if (!result.cryptoBootstrap) { + throw new Error("expected Matrix crypto bootstrap result"); + } }); it("reports bootstrap failure when the device is only locally trusted", async () => { diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts index 039c65eb690..c4505c4132c 100644 --- a/extensions/matrix/src/matrix/sdk/transport.ts +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -117,6 +117,8 @@ async function fetchWithMatrixGuardedRedirects(params: { const { signal, cleanup } = buildTimeoutAbortSignal({ timeoutMs: params.timeoutMs, signal: params.signal, + operation: "matrix.guarded-redirect-fetch", + url: params.url, }); for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts index d7507d38aa7..34a1b49ac79 100644 --- a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -548,10 +548,13 @@ describe("MatrixVerificationManager", () => { }); it("confirmVerificationSas awaits the verifier's verify promise before resolving", async () => { - let resolveVerify!: () => void; + let resolveVerify: (() => void) | undefined; const verifyPromise = new Promise((res) => { resolveVerify = res; }); + if (!resolveVerify) { + throw new Error("Expected verification resolver to be initialized"); + } const verifyImpl = vi.fn(() => verifyPromise); const { confirm, verifier } = createSasVerifierFixture({ decimal: [111, 222, 333], diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts index 4f27fee8531..64fb89769c0 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.test.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts @@ -135,7 +135,7 @@ describe("handleMatrixSubagentSpawning", () => { fakeApi, makeSpawnEvent({ channel: " Matrix " }), ); - expect(result).not.toBeUndefined(); + expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("returns error when thread bindings are disabled", async () => { diff --git a/extensions/matrix/src/migration-config.test.ts b/extensions/matrix/src/migration-config.test.ts index ffdcf62cf02..5fa411466bd 100644 --- a/extensions/matrix/src/migration-config.test.ts +++ b/extensions/matrix/src/migration-config.test.ts @@ -19,6 +19,16 @@ function resolveOpsTarget(cfg: OpenClawConfig, env = process.env) { }); } +type MatrixMigrationTarget = NonNullable>; + +function expectMigrationTarget(target: ReturnType): MatrixMigrationTarget { + expect(target).toEqual(expect.objectContaining({ homeserver: expect.any(String) })); + if (target === null) { + throw new Error("Expected Matrix migration account target"); + } + return target; +} + describe("resolveMatrixMigrationAccountTarget", () => { it("reuses stored user identity for token-only configs when the access token matches", async () => { await withTempHome(async (home) => { @@ -44,9 +54,9 @@ describe("resolveMatrixMigrationAccountTarget", () => { const target = resolveOpsTarget(cfg); - expect(target).not.toBeNull(); - expect(target?.userId).toBe(MATRIX_OPS_USER_ID); - expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.userId).toBe(MATRIX_OPS_USER_ID); + expect(migrationTarget.storedDeviceId).toBe("DEVICE-OPS"); }); }); @@ -76,10 +86,10 @@ describe("resolveMatrixMigrationAccountTarget", () => { const target = resolveOpsTarget(cfg); - expect(target).not.toBeNull(); - expect(target?.userId).toBe("@new-bot:example.org"); - expect(target?.accessToken).toBe("tok-new"); - expect(target?.storedDeviceId).toBeNull(); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.userId).toBe("@new-bot:example.org"); + expect(migrationTarget.accessToken).toBe("tok-new"); + expect(migrationTarget.storedDeviceId).toBeNull(); }); }); @@ -138,9 +148,9 @@ describe("resolveMatrixMigrationAccountTarget", () => { const target = resolveOpsTarget(cfg); - expect(target).not.toBeNull(); - expect(target?.userId).toBe(MATRIX_OPS_USER_ID); - expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.userId).toBe(MATRIX_OPS_USER_ID); + expect(migrationTarget.storedDeviceId).toBe("DEVICE-OPS"); }); }); @@ -219,10 +229,10 @@ describe("resolveMatrixMigrationAccountTarget", () => { accountId: "ops-prod", }); - expect(target).not.toBeNull(); - expect(target?.homeserver).toBe("https://matrix.example.org"); - expect(target?.userId).toBe("@ops-prod:example.org"); - expect(target?.accessToken).toBe("tok-ops-prod"); + const migrationTarget = expectMigrationTarget(target); + expect(migrationTarget.homeserver).toBe("https://matrix.example.org"); + expect(migrationTarget.userId).toBe("@ops-prod:example.org"); + expect(migrationTarget.accessToken).toBe("tok-ops-prod"); }); }); }); diff --git a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts index cb32b3fa034..d399c603943 100644 --- a/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/mattermost/src/channel-actions-setup-status.contract.test.ts @@ -70,9 +70,13 @@ describe("mattermost setup contract", () => { }, expectedAccountId: "default", assertPatchedConfig: (cfg) => { - expect(cfg.channels?.mattermost?.enabled).toBe(true); - expect(cfg.channels?.mattermost?.botToken).toBe("test-token"); - expect(cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com"); + const mattermostConfig = cfg.channels?.mattermost; + if (!mattermostConfig) { + throw new Error("expected Mattermost config patch"); + } + expect(mattermostConfig.enabled).toBe(true); + expect(mattermostConfig.botToken).toBe("test-token"); + expect(mattermostConfig.baseUrl).toBe("https://chat.example.com"); }, }, { diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts index 3eb5645537a..2df75528e0d 100644 --- a/extensions/mattermost/src/channel.message-adapter.test.ts +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -13,6 +13,37 @@ vi.mock("./mattermost/send.js", () => ({ import { mattermostPlugin } from "./channel.js"; +type MattermostMessageAdapter = NonNullable; +type MattermostMessageSender = NonNullable; + +function requireMattermostMessageAdapter(): MattermostMessageAdapter { + const adapter = mattermostPlugin.message; + if (!adapter) { + throw new Error("Expected mattermost plugin to expose a channel message adapter"); + } + return adapter; +} + +function requireTextSender( + adapter: MattermostMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected mattermost message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: MattermostMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected mattermost message adapter media sender"); + } + return media; +} + describe("mattermost channel message adapter", () => { beforeEach(() => { sendMessageMattermostMock.mockReset(); @@ -23,14 +54,13 @@ describe("mattermost channel message adapter", () => { }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = mattermostPlugin.message; - if (!adapter) { - throw new Error("Expected mattermost plugin to expose a channel message adapter"); - } + const adapter = requireMattermostMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); const proveText = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:team-1", text: "hello", @@ -47,7 +77,7 @@ describe("mattermost channel message adapter", () => { const proveMedia = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.media!({ + const result = await sendMedia({ cfg: {}, to: "channel:team-1", text: "caption", @@ -67,7 +97,7 @@ describe("mattermost channel message adapter", () => { const proveReplyThread = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:parent-1", text: "threaded", @@ -84,7 +114,7 @@ describe("mattermost channel message adapter", () => { const proveExplicitReply = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg: {}, to: "channel:parent-1", text: "reply", @@ -102,50 +132,51 @@ describe("mattermost channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveExplicitReply, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = mattermostPlugin.message; + const adapter = requireMattermostMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, discardPending: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); diff --git a/extensions/mattermost/src/doctor.test.ts b/extensions/mattermost/src/doctor.test.ts index e5e425d23ad..cc16ff3b2f7 100644 --- a/extensions/mattermost/src/doctor.test.ts +++ b/extensions/mattermost/src/doctor.test.ts @@ -30,16 +30,20 @@ describe("mattermost doctor", () => { } as never, }); - expect(result.config.channels?.mattermost?.network).toEqual({ + const mattermostConfig = result.config.channels?.mattermost; + if (!mattermostConfig) { + throw new Error("expected normalized Mattermost config"); + } + expect(mattermostConfig.network).toEqual({ dangerouslyAllowPrivateNetwork: true, }); - expect( - ( - result.config.channels?.mattermost?.accounts?.work as - | { network?: Record } - | undefined - )?.network, - ).toEqual({ + const workAccount = mattermostConfig.accounts?.work as + | { network?: Record } + | undefined; + if (!workAccount) { + throw new Error("expected Mattermost work account config"); + } + expect(workAccount.network).toEqual({ dangerouslyAllowPrivateNetwork: false, }); }); diff --git a/extensions/mattermost/src/mattermost/client.test.ts b/extensions/mattermost/src/mattermost/client.test.ts index 3aa3c3ebe92..6cbf3a91f7f 100644 --- a/extensions/mattermost/src/mattermost/client.test.ts +++ b/extensions/mattermost/src/mattermost/client.test.ts @@ -276,8 +276,15 @@ describe("updateMattermostPost", () => { it("sends PUT to /posts/{id}", async () => { const { calls } = await updatePostAndCapture({ message: "Updated" }); - expect(calls[0].url).toContain("/posts/post1"); - expect(calls[0].init?.method).toBe("PUT"); + const firstCall = calls[0]; + if (!firstCall) { + throw new Error("expected Mattermost update post request"); + } + expect(firstCall.url).toContain("/posts/post1"); + if (!firstCall.init) { + throw new Error("expected Mattermost update post request init"); + } + expect(firstCall.init.method).toBe("PUT"); }); it("includes post id in the body", async () => { diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index eead72ad12b..d72f21f6945 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -18,6 +18,34 @@ import { verifyInteractionToken, } from "./interactions.js"; +type ButtonAttachments = ReturnType; +type ButtonAttachment = ButtonAttachments[number]; +type ButtonAction = NonNullable[number]; + +function requireFirstAttachment(attachments: ButtonAttachments): ButtonAttachment { + const [attachment] = attachments; + if (!attachment) { + throw new Error("Expected button attachment fixture"); + } + return attachment; +} + +function requireActions(attachments: ButtonAttachments): ButtonAction[] { + const attachment = requireFirstAttachment(attachments); + if (!attachment.actions) { + throw new Error("Expected button attachment fixture actions"); + } + return attachment.actions; +} + +function requireAction(attachments: ButtonAttachments, index = 0): ButtonAction { + const action = requireActions(attachments).at(index); + if (!action) { + throw new Error(`Expected button attachment action at index ${index}`); + } + return action; +} + // ── HMAC token management ──────────────────────────────────────────── describe("setInteractionSecret / getInteractionSecret", () => { @@ -308,7 +336,7 @@ describe("buildButtonAttachments", () => { }); expect(result).toHaveLength(1); - expect(result[0].actions).toHaveLength(2); + expect(requireActions(result)).toHaveLength(2); }); it("sets type to 'button' on every action", () => { @@ -317,7 +345,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "a", name: "A" }], }); - expect(result[0].actions![0].type).toBe("button"); + expect(requireAction(result).type).toBe("button"); }); it("includes HMAC _token in integration context", () => { @@ -326,7 +354,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "test", name: "Test" }], }); - const action = result[0].actions![0]; + const action = requireAction(result); expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/); }); @@ -336,7 +364,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "my_action", name: "Do It" }], }); - const action = result[0].actions![0]; + const action = requireAction(result); // sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747) expect(action.integration.context.action_id).toBe("myaction"); expect(action.id).toBe("myaction"); @@ -348,7 +376,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }], }); - const ctx = result[0].actions![0].integration.context; + const ctx = requireAction(result).integration.context; expect(ctx.tweet_id).toBe("123"); expect(ctx.batch).toBe(true); expect(ctx.action_id).toBe("btn"); @@ -365,7 +393,7 @@ describe("buildButtonAttachments", () => { ], }); - for (const action of result[0].actions!) { + for (const action of requireActions(result)) { expect(action.integration.url).toBe(url); } }); @@ -379,8 +407,8 @@ describe("buildButtonAttachments", () => { ], }); - expect(result[0].actions![0].style).toBe("primary"); - expect(result[0].actions![1].style).toBe("danger"); + expect(requireAction(result, 0).style).toBe("primary"); + expect(requireAction(result, 1).style).toBe("danger"); }); it("uses provided text for the attachment", () => { @@ -390,7 +418,7 @@ describe("buildButtonAttachments", () => { text: "Choose an action:", }); - expect(result[0].text).toBe("Choose an action:"); + expect(requireFirstAttachment(result).text).toBe("Choose an action:"); }); it("defaults to empty string text when not provided", () => { @@ -399,7 +427,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "x", name: "X" }], }); - expect(result[0].text).toBe(""); + expect(requireFirstAttachment(result).text).toBe(""); }); it("generates verifiable tokens", () => { @@ -408,7 +436,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }], }); - const ctx = result[0].actions![0].integration.context; + const ctx = requireAction(result).integration.context; const token = ctx._token as string; const { _token, ...contextWithoutToken } = ctx; expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true); @@ -420,7 +448,7 @@ describe("buildButtonAttachments", () => { buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }], }); - const ctx = result[0].actions![0].integration.context; + const ctx = requireAction(result).integration.context; const token = ctx._token as string; // Simulate Mattermost returning context with keys in a different order diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 9886a205fe4..1c489947a76 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -59,7 +59,15 @@ describe("Mattermost model picker", () => { expect(view.text).toContain("/oc_model to switch"); expect(view.text).toContain("Browse keeps the current runtime"); expect(view.text).toContain("/oc_model --runtime "); - expect(view.buttons[0]?.[0]?.text).toBe("Browse providers"); + const firstRow = view.buttons[0]; + if (!firstRow) { + throw new Error("expected Mattermost model picker button row"); + } + const browseButton = firstRow[0]; + if (!browseButton) { + throw new Error("expected Mattermost browse providers button"); + } + expect(browseButton.text).toBe("Browse providers"); }); it("trims accidental model spacing in Mattermost current-model text", () => { @@ -104,7 +112,7 @@ describe("Mattermost model picker", () => { }); const ids = modelsView.buttons.flat().map((button) => button.id); - expect(ids.filter((id) => typeof id !== "string" || !/^[a-z0-9]+$/.test(id))).toEqual([]); + expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true); expect(new Set(ids).size).toBe(ids.length); }); diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts index a2fcf9c80fb..a95dd282299 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts @@ -6,6 +6,16 @@ import { WebSocketClosedBeforeOpenError, } from "./monitor-websocket.js"; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + class FakeWebSocket implements MattermostWebSocketLike { public readonly sent: string[] = []; public pingCalls = 0; @@ -172,8 +182,8 @@ describe("mattermost websocket monitor", () => { data: { token: "token" }, seq: 1, }); - expect(patches.filter((patch) => patch.connected === true)).toHaveLength(1); - expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2); + expect(countMatching(patches, (patch) => patch.connected === true)).toBe(1); + expect(countMatching(patches, (patch) => patch.connected === false)).toBe(2); }); it("dispatches reaction events to the reaction handler", async () => { diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts index 7714121c855..4d2828e20b1 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.test.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -46,7 +46,7 @@ describe("deliverMattermostReplyPayload", () => { await deliverMattermostReplyPayload({ core, cfg, - payload: { text: "Reasoning:\n_hidden_", isReasoning: true }, + payload: { text: "hidden", isReasoning: true }, to: "channel:town-square", accountId: "default", agentId: "agent-1", diff --git a/extensions/mattermost/src/mattermost/slash-commands.test.ts b/extensions/mattermost/src/mattermost/slash-commands.test.ts index fa356afdb23..d2f756f08d9 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.test.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.test.ts @@ -130,8 +130,12 @@ describe("slash-commands", () => { const result = await registerSingleStatusCommand(request); expect(result).toHaveLength(1); - expect(result[0]?.managed).toBe(false); - expect(result[0]?.id).toBe("cmd-1"); + const firstCommand = result[0]; + if (!firstCommand) { + throw new Error("expected Mattermost slash command result"); + } + expect(firstCommand.managed).toBe(false); + expect(firstCommand.id).toBe("cmd-1"); expect(request).toHaveBeenCalledTimes(1); }); diff --git a/extensions/mattermost/src/setup.test.ts b/extensions/mattermost/src/setup.test.ts index f1acb93b7f1..9af8238d314 100644 --- a/extensions/mattermost/src/setup.test.ts +++ b/extensions/mattermost/src/setup.test.ts @@ -142,9 +142,12 @@ describe("mattermost setup", () => { it("validates env and explicit credential requirements", () => { const validateInput = mattermostSetupAdapter.validateInput; expect(validateInput).toBeTypeOf("function"); + if (!validateInput) { + throw new Error("Expected Mattermost setup validateInput"); + } expect( - validateInput!({ + validateInput({ accountId: "secondary", input: { useEnv: true }, } as never), @@ -152,7 +155,7 @@ describe("mattermost setup", () => { normalizeMattermostBaseUrl.mockReturnValue(undefined); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, botToken: "tok", httpUrl: "not-a-url" }, } as never), @@ -160,7 +163,7 @@ describe("mattermost setup", () => { normalizeMattermostBaseUrl.mockReturnValue("https://chat.example.com"); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, botToken: "tok", httpUrl: "https://chat.example.com" }, } as never), @@ -388,8 +391,12 @@ describe("mattermost setup", () => { ([params]) => (params as { message: string }).message, ); expect(textMessages).toEqual(["Enter Mattermost bot token", "Enter Mattermost base URL"]); - expect(result.cfg.channels?.mattermost?.botToken).toBe("bot-token"); - expect(result.cfg.channels?.mattermost?.baseUrl).toBe("https://chat.example.com"); + const mattermostConfig = result.cfg.channels?.mattermost; + if (!mattermostConfig) { + throw new Error("expected Mattermost config"); + } + expect(mattermostConfig.botToken).toBe("bot-token"); + expect(mattermostConfig.baseUrl).toBe("https://chat.example.com"); expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID); }); }); diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index 4bfc4955f88..441ecc5347c 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -84,8 +84,9 @@ describe("buildMemoryFlushPlan", () => { expect(plan?.prompt).toContain("memory/2026-02-16.md"); expect(plan?.prompt).toContain( - "Current time: Monday, February 16th, 2026 - 10:00 AM (America/New_York) / 2026-02-16 15:00 UTC", + "Current time: Monday, February 16th, 2026 - 10:00 AM (America/New_York)", ); + expect(plan?.prompt).toContain("Reference UTC: 2026-02-16 15:00 UTC"); expect(plan?.relativePath).toBe("memory/2026-02-16.md"); }); @@ -114,7 +115,6 @@ describe("buildMemoryFlushPlan", () => { it("defaults to safe prompts and gating values", () => { const plan = buildMemoryFlushPlan(); - expect(plan).not.toBeNull(); expect(plan?.softThresholdTokens).toBe(DEFAULT_MEMORY_FLUSH_SOFT_TOKENS); expect(plan?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES); expect(plan?.prompt).toContain("memory/"); diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index eb22c05b394..f98ec42d6f2 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -865,12 +865,8 @@ describe("memory cli", () => { await runMemoryCli(["status", "--json"]); const payload = firstWrittenJsonArg(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } expect(Array.isArray(payload)).toBe(true); - expect((payload[0] as Record)?.agentId).toBe("main"); + expect((payload?.[0] as Record)?.agentId).toBe("main"); expect(probeVectorAvailability).not.toHaveBeenCalled(); expect(probeEmbeddingAvailability).not.toHaveBeenCalled(); expect(close).toHaveBeenCalled(); @@ -885,10 +881,6 @@ describe("memory cli", () => { await runMemoryCli(["status", "--json"]); const payload = firstWrittenJsonArg(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } expect(Array.isArray(payload)).toBe(true); expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true); }); @@ -1000,12 +992,8 @@ describe("memory cli", () => { await runMemoryCli(["search", "hello", "--json"]); const payload = firstWrittenJsonArg<{ results: unknown[] }>(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload.results)).toBe(true); - expect(payload.results).toHaveLength(1); + expect(Array.isArray(payload?.results)).toBe(true); + expect(payload?.results).toHaveLength(1); expect(close).toHaveBeenCalled(); }); @@ -1062,12 +1050,8 @@ describe("memory cli", () => { ]); const payload = firstWrittenJsonArg<{ candidates: unknown[] }>(writeJson); - expect(payload).not.toBeNull(); - if (!payload) { - throw new Error("expected json payload"); - } - expect(Array.isArray(payload.candidates)).toBe(true); - expect(payload.candidates).toHaveLength(1); + expect(Array.isArray(payload?.candidates)).toBe(true); + expect(payload?.candidates).toHaveLength(1); expect(close).toHaveBeenCalled(); }); }); diff --git a/extensions/memory-core/src/dreaming-markdown.test.ts b/extensions/memory-core/src/dreaming-markdown.test.ts index e2293baa36a..6bb3c3c6be6 100644 --- a/extensions/memory-core/src/dreaming-markdown.test.ts +++ b/extensions/memory-core/src/dreaming-markdown.test.ts @@ -6,6 +6,20 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js"; const { createTempWorkspace } = createMemoryCoreTestHarness(); +function requireInlinePath(result: { inlinePath?: string }): string { + if (!result.inlinePath) { + throw new Error("Expected inline dreaming markdown path"); + } + return result.inlinePath; +} + +function requireReportPath(reportPath: string | undefined): string { + if (!reportPath) { + throw new Error("Expected deep dreaming report path"); + } + return reportPath; +} + describe("dreaming markdown storage", () => { const nowMs = Date.parse("2026-04-05T10:00:00Z"); const timezone = "UTC"; @@ -25,8 +39,9 @@ describe("dreaming markdown storage", () => { }, }); - expect(result.inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); - const content = await fs.readFile(result.inlinePath!, "utf-8"); + const inlinePath = requireInlinePath(result); + expect(inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); + const content = await fs.readFile(inlinePath, "utf-8"); expect(content).toContain("## Light Sleep"); expect(content).toContain("- Candidate: remember the API key is fake"); }); @@ -82,8 +97,9 @@ describe("dreaming markdown storage", () => { }, }); - expect(result.inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); - const content = await fs.readFile(result.inlinePath!, "utf-8"); + const inlinePath = requireInlinePath(result); + expect(inlinePath).toBe(path.join(workspaceDir, "memory", "2026-04-05.md")); + const content = await fs.readFile(inlinePath, "utf-8"); expect(content).toContain("## REM Sleep"); expect(content).toContain("- Theme: `glacier` kept surfacing."); await expect(fs.readFile(lowercasePath, "utf-8")).resolves.toBe("# Scratch\n\n"); @@ -103,8 +119,11 @@ describe("dreaming markdown storage", () => { timezone: "UTC", }); - expect(reportPath).toBe(path.join(workspaceDir, "memory", "dreaming", "deep", "2026-04-05.md")); - const content = await fs.readFile(reportPath!, "utf-8"); + const requiredReportPath = requireReportPath(reportPath); + expect(requiredReportPath).toBe( + path.join(workspaceDir, "memory", "dreaming", "deep", "2026-04-05.md"), + ); + const content = await fs.readFile(requiredReportPath, "utf-8"); expect(content).toContain("# Deep Sleep"); expect(content).toContain("- Promoted: durable preference"); diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index bf662fe53b8..e929e84625a 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -979,7 +979,7 @@ describe("generateAndAppendDreamNarrative", () => { expect(updatedStore).toHaveProperty("agent:main:kept-session"); expect(updatedStore).toHaveProperty("agent:main:telegram:group:dreaming-narrative-room"); const sessionFiles = await fs.readdir(sessionsDir); - expect(sessionFiles.some((name) => name.startsWith("orphan.jsonl.deleted."))).toBe(true); + expect(sessionFiles).toContainEqual(expect.stringMatching(/^orphan\.jsonl\.deleted\./)); expect(sessionFiles).toContain("still-live.jsonl"); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("dreaming cleanup scrubbed")); }); @@ -1021,10 +1021,13 @@ describe("generateAndAppendDreamNarrative", () => { describe("runDetachedDreamNarrative", () => { type Deferred = { promise: Promise; resolve: (v: T) => void }; function deferred(): Deferred { - let resolve!: (v: T) => void; + let resolve: ((v: T) => void) | undefined; const promise = new Promise((r) => { resolve = r; }); + if (!resolve) { + throw new Error("Expected dream narrative deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/memory-core/src/dreaming-repair.test.ts b/extensions/memory-core/src/dreaming-repair.test.ts index cd1217316b1..0c7d27ac782 100644 --- a/extensions/memory-core/src/dreaming-repair.test.ts +++ b/extensions/memory-core/src/dreaming-repair.test.ts @@ -13,6 +13,13 @@ async function createWorkspace(): Promise { return workspaceDir; } +function requireArchiveDir(archiveDir: string | undefined): string { + if (!archiveDir) { + throw new Error("Expected dreaming repair to create an archive directory"); + } + return archiveDir; +} + afterEach(async () => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -113,7 +120,8 @@ describe("dreaming artifact repair", () => { expect(repair.archivedSessionCorpus).toBe(true); expect(repair.archivedSessionIngestion).toBe(true); expect(repair.archivedDreamsDiary).toBe(false); - expect(repair.archiveDir).toBe( + const archiveDir = requireArchiveDir(repair.archiveDir); + expect(archiveDir).toBe( path.join(workspaceDir, ".openclaw-repair", "dreaming", "2026-04-11T21-30-00-000Z"), ); await expect(fs.access(sessionCorpusDir)).rejects.toMatchObject({ code: "ENOENT" }); @@ -121,8 +129,8 @@ describe("dreaming artifact repair", () => { fs.access(path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json")), ).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toContain("# Dream Diary"); - const archivedEntries = await fs.readdir(repair.archiveDir!); - expect(archivedEntries.some((entry) => entry.startsWith("session-corpus."))).toBe(true); - expect(archivedEntries.some((entry) => entry.startsWith("session-ingestion.json."))).toBe(true); + const archivedEntries = await fs.readdir(archiveDir); + expect(archivedEntries).toContainEqual(expect.stringMatching(/^session-corpus\./)); + expect(archivedEntries).toContainEqual(expect.stringMatching(/^session-ingestion\.json\./)); }); }); diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index b53bd7a493e..d28e0b498be 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -1943,7 +1943,8 @@ describe("short-term dreaming trigger", () => { const result = await runShortTermDreamingPromotionIfTriggered({ cleanedBody: [ "[cron:e795558c-a273-4124-ba88-d4916688d977 Memory Dreaming Promotion] __openclaw_memory_core_short_term_promotion_dream__", - "Current time: Thursday, April 16th, 2026 - 3:10 PM (America/Los_Angeles) / 2026-04-16 22:10 UTC", + "Current time: Thursday, April 16th, 2026 - 3:10 PM (America/Los_Angeles)", + "Reference UTC: 2026-04-16 22:10 UTC", ].join("\n"), trigger: "cron", workspaceDir, diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index a838006a4ff..b6425311ff6 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -275,11 +275,10 @@ describe("memory index", () => { result: Awaited>, missingMessage = "manager missing", ): MemoryIndexManager { - expect(result.manager).not.toBeNull(); if (!result.manager) { throw new Error(missingMessage); } - return result.manager as MemoryIndexManager; + return result.manager as unknown as MemoryIndexManager; } async function getPersistentManager(cfg: TestCfg): Promise { @@ -377,10 +376,14 @@ describe("memory index", () => { expect(embedBatchInputCalls).toBeGreaterThan(0); const imageResults = await manager.search("image"); - expect(imageResults.some((result) => result.path.endsWith("diagram.png"))).toBe(true); + expect(imageResults.map((result) => result.path)).toContainEqual( + expect.stringMatching(/diagram\.png$/), + ); const audioResults = await manager.search("audio"); - expect(audioResults.some((result) => result.path.endsWith("meeting.wav"))).toBe(true); + expect(audioResults.map((result) => result.path)).toContainEqual( + expect.stringMatching(/meeting\.wav$/), + ); }); it("finds keyword matches via hybrid search when query embedding is zero", async () => { diff --git a/extensions/memory-core/src/memory/manager-cache.test.ts b/extensions/memory-core/src/memory/manager-cache.test.ts index af28895712d..6263c3b32c0 100644 --- a/extensions/memory-core/src/memory/manager-cache.test.ts +++ b/extensions/memory-core/src/memory/manager-cache.test.ts @@ -23,12 +23,15 @@ function createEntry(id: string): TestEntry { } function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/memory-core/src/memory/manager-embedding-policy.test.ts b/extensions/memory-core/src/memory/manager-embedding-policy.test.ts index 1332cc5eabf..d7f090e6fb6 100644 --- a/extensions/memory-core/src/memory/manager-embedding-policy.test.ts +++ b/extensions/memory-core/src/memory/manager-embedding-policy.test.ts @@ -23,7 +23,7 @@ describe("memory embedding policy", () => { const batches = buildMemoryEmbeddingBatches([chunk(line), chunk(line)], 8000); expect(batches).toHaveLength(2); - expect(batches.every((batch) => batch.length === 1)).toBe(true); + expect(batches.map((batch) => batch.length)).toEqual([1, 1]); }); it("keeps small files in a single embedding batch", () => { diff --git a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts index 2f5e7733a0f..8957f45b4d5 100644 --- a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts +++ b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts @@ -57,6 +57,19 @@ function createSettings(params: { } as unknown as ResolvedMemorySearchConfig; } +type MemoryFallbackProviderRequest = NonNullable< + ReturnType +>; + +function expectMemoryFallbackRequest( + request: ReturnType, +): MemoryFallbackProviderRequest { + if (!request) { + throw new Error("Expected memory fallback provider request"); + } + return request; +} + describe("memory manager mistral provider wiring", () => { it("stores mistral client when mistral provider is selected", () => { const mistralRuntime: EmbeddingProviderRuntime = { @@ -72,7 +85,7 @@ describe("memory manager mistral provider wiring", () => { providerUnavailableReason: undefined, }); - expect(state.provider?.id).toBe("mistral"); + expect(state.provider).toEqual(expect.objectContaining({ id: "mistral" })); expect(state.providerRuntime).toBe(mistralRuntime); }); @@ -105,7 +118,7 @@ describe("memory manager mistral provider wiring", () => { expect(fallbackState.fallbackFrom).toBe("openai"); expect(fallbackState.fallbackReason).toBe("forced test"); - expect(fallbackState.provider?.id).toBe("mistral"); + expect(fallbackState.provider).toEqual(expect.objectContaining({ id: "mistral" })); expect(fallbackState.providerRuntime).toBe(mistralRuntime); }); @@ -116,9 +129,10 @@ describe("memory manager mistral provider wiring", () => { currentProviderId: "openai", }); - expect(request?.provider).toBe("ollama"); - expect(request?.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL); - expect(request?.fallback).toBe("none"); + const fallbackRequest = expectMemoryFallbackRequest(request); + expect(fallbackRequest.provider).toBe("ollama"); + expect(fallbackRequest.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL); + expect(fallbackRequest.fallback).toBe("none"); }); it("includes outputDimensionality in the primary provider request", () => { @@ -158,8 +172,9 @@ describe("memory manager mistral provider wiring", () => { currentProviderId: "openai", }); - expect(request?.provider).toBe("lmstudio"); - expect(request?.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); - expect(request?.fallback).toBe("none"); + const fallbackRequest = expectMemoryFallbackRequest(request); + expect(fallbackRequest.provider).toBe("lmstudio"); + expect(fallbackRequest.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); + expect(fallbackRequest.fallback).toBe("none"); }); }); diff --git a/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts b/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts index 144f0708487..9120c649f3d 100644 --- a/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts +++ b/extensions/memory-core/src/memory/manager.vector-dedupe.test.ts @@ -29,13 +29,13 @@ describe("memory vector dedupe", () => { END; `); - expect(() => + expect( replaceMemoryVectorRow({ - db: db!, + db, id: "chunk-1", embedding: [2, 0, 0], }), - ).not.toThrow(); + ).toBeUndefined(); const row = db .prepare("SELECT COUNT(*) as c, length(embedding) as bytes FROM chunks_vec WHERE id = ?") diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 1c909ede938..9fc0f45fd64 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -146,7 +146,6 @@ describe("memory watcher config", () => { async function expectWatcherManager(cfg: OpenClawConfig) { const result = await getMemorySearchManager({ cfg, agentId: "main" }); - expect(result.manager).not.toBeNull(); if (!result.manager) { throw new Error("manager missing"); } @@ -173,7 +172,7 @@ describe("memory watcher config", () => { extraDir, ]), ); - expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true); + expect(watchedPaths).not.toContainEqual(expect.stringContaining("*")); expect(options.ignoreInitial).toBe(true); expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 }); @@ -200,7 +199,9 @@ describe("memory watcher config", () => { const cfg = createWatcherConfig(); const result = await getMemorySearchManager({ cfg, agentId: "main", purpose: "cli" }); - expect(result.manager).not.toBeNull(); + if (!result.manager) { + throw new Error("manager missing"); + } manager = result.manager as unknown as MemoryIndexManager; expect(watchMock).not.toHaveBeenCalled(); @@ -225,7 +226,7 @@ describe("memory watcher config", () => { expect(watchedPaths).toEqual( expect.arrayContaining([path.join(workspaceDir, "MEMORY.md"), path.join(extraDir)]), ); - expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true); + expect(watchedPaths).not.toContainEqual(expect.stringContaining("*")); const ignored = options.ignored as WatchIgnoredFn | undefined; expect(ignored).toBeTypeOf("function"); @@ -268,7 +269,7 @@ describe("memory watcher config", () => { const watcher = createdWatchers[0]; expect(watcher?.on).toHaveBeenCalledWith("error", expect.any(Function)); - expect(() => watcher?.emit("error", new Error("watcher error: ENOSPC"))).not.toThrow(); + expect(watcher?.emit("error", new Error("watcher error: ENOSPC"))).toBeUndefined(); expect(memoryLoggerWarn).toHaveBeenCalledWith("memory watcher error: watcher error: ENOSPC"); }); }); diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 9fc25f39a20..aa5a467358c 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -484,8 +484,12 @@ describe("QmdMemoryManager", () => { }); const { manager } = await createManager({ mode: "full" }); - expect(releaseUpdate).not.toBeNull(); - (releaseUpdate as (() => void) | null)?.(); + ( + releaseUpdate ?? + (() => { + throw new Error("Expected qmd update release callback"); + }) + )(); await manager?.close(); }); @@ -2468,8 +2472,8 @@ describe("QmdMemoryManager", () => { isMcporterCommand(call[0]), ); expect(mcporterCalls.length).toBeGreaterThan(0); - expect(mcporterCalls.some((call: unknown[]) => (call[1] as string[])[0] === "daemon")).toBe( - false, + expect(mcporterCalls.map((call: unknown[]) => (call[1] as string[])[0])).not.toContain( + "daemon", ); expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("cold-start")); @@ -3396,8 +3400,9 @@ describe("QmdMemoryManager", () => { }); expect(results).toHaveLength(4); - expect(results.some((entry) => entry.source === "memory")).toBe(true); - expect(results.some((entry) => entry.source === "sessions")).toBe(true); + expect(results.map((entry) => entry.source)).toEqual( + expect.arrayContaining(["memory", "sessions"]), + ); await manager.close(); }); @@ -4990,11 +4995,14 @@ describe("QmdMemoryManager", () => { }); function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index 6b84d11fb1c..36a2691c5c8 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -180,12 +180,15 @@ function requireManager(result: SearchManagerResult): SearchManager { } function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index 3b58d95ad15..8cf27ed3c6a 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -27,6 +27,16 @@ import { const { createTempWorkspace } = createMemoryCoreTestHarness(); +function collectWikiResultPaths(results: readonly { corpus: string; path: string }[]): string[] { + const paths: string[] = []; + for (const result of results) { + if (result.corpus === "wiki") { + paths.push(result.path); + } + } + return paths; +} + async function waitFor(task: () => Promise, timeoutMs: number = 1500): Promise { const startedAt = Date.now(); let lastError: unknown; @@ -70,6 +80,15 @@ beforeEach(() => { }); describe("memory search citations", () => { + function expectFirstMemoryResult(details: { results: T[] }): T { + expect(details.results).toHaveLength(1); + const [result] = details.results; + if (!result) { + throw new Error("Expected memory search result"); + } + return result; + } + it("appends source information when citations are enabled", async () => { setMemoryBackend("builtin"); const cfg = asOpenClawConfig({ @@ -79,8 +98,9 @@ describe("memory search citations", () => { const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_on", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/); - expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7"); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).toMatch(/Source: MEMORY.md#L5-L7/); + expect(firstResult.citation).toBe("MEMORY.md#L5-L7"); }); it("leaves snippet untouched when citations are off", async () => { @@ -92,8 +112,9 @@ describe("memory search citations", () => { const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_off", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet).not.toMatch(/Source:/); - expect(details.results[0]?.citation).toBeUndefined(); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).not.toMatch(/Source:/); + expect(firstResult.citation).toBeUndefined(); }); it("clamps decorated snippets to qmd injected budget", async () => { @@ -105,7 +126,8 @@ describe("memory search citations", () => { const tool = createMemorySearchToolOrThrow({ config: cfg }); const result = await tool.execute("call_citations_qmd", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; - expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet.length).toBeLessThanOrEqual(20); }); it("honors auto mode for direct chats", async () => { @@ -113,7 +135,8 @@ describe("memory search citations", () => { const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123"); const result = await tool.execute("auto_mode_direct", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; - expect(details.results[0]?.snippet).toMatch(/Source:/); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).toMatch(/Source:/); }); it("suppresses citations for auto mode in group chats", async () => { @@ -121,12 +144,13 @@ describe("memory search citations", () => { const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123"); const result = await tool.execute("auto_mode_group", { query: "notes" }); const details = result.details as { results: Array<{ snippet: string }> }; - expect(details.results[0]?.snippet).not.toMatch(/Source:/); + const firstResult = expectFirstMemoryResult(details); + expect(firstResult.snippet).not.toMatch(/Source:/); }); }); describe("memory tools", () => { - it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { + it("returns unavailable details when memory_search fails (e.g. embeddings 429)", async () => { setMemorySearchImpl(async () => { throw new Error("openai embeddings failed: 429 insufficient_quota"); }); @@ -142,7 +166,7 @@ describe("memory tools", () => { }); }); - it("does not throw when memory_get fails", async () => { + it("returns disabled details when memory_get fails", async () => { setMemoryReadFileImpl(async (_params: MemoryReadParams) => { throw new Error("path required"); }); @@ -355,9 +379,7 @@ describe("memory tools", () => { expect(corpora).toContain("memory"); expect(corpora).toContain("wiki"); expect(details.results).toHaveLength(5); - expect( - details.results.filter((entry) => entry.corpus === "wiki").map((entry) => entry.path), - ).toEqual(["w1.md", "w2.md", "w3.md", "w4.md"]); + expect(collectWikiResultPaths(details.results)).toEqual(["w1.md", "w2.md", "w3.md", "w4.md"]); }); it("merges memory and wiki corpus search results for corpus=all", async () => { diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 00ab318e0f5..3d56dd7f69e 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -196,7 +196,7 @@ describe("memory plugin e2e", () => { resolvePath: (filePath: string) => filePath, }; - expect(() => memoryPlugin.register(mockApi as any)).not.toThrow(); + memoryPlugin.register(mockApi as any); expect(registerService).toHaveBeenCalledWith({ id: "memory-lancedb", start: expect.any(Function), diff --git a/extensions/memory-wiki/src/bridge.test.ts b/extensions/memory-wiki/src/bridge.test.ts index 54e9b0eeab1..ec98804c4d9 100644 --- a/extensions/memory-wiki/src/bridge.test.ts +++ b/extensions/memory-wiki/src/bridge.test.ts @@ -128,7 +128,9 @@ describe("syncMemoryWikiBridgeSources", () => { expect(first.pagePaths).toHaveLength(3); const sourcePages = await fs.readdir(path.join(vaultDir, "sources")); - expect(sourcePages.filter((name) => name.startsWith("bridge-"))).toHaveLength(3); + expect( + sourcePages.reduce((count, name) => count + (name.startsWith("bridge-") ? 1 : 0), 0), + ).toBe(3); const memoryPage = await fs.readFile(path.join(vaultDir, first.pagePaths[0] ?? ""), "utf8"); expect(memoryPage).toContain("sourceType: memory-bridge"); diff --git a/extensions/memory-wiki/src/cli.test.ts b/extensions/memory-wiki/src/cli.test.ts index f676a08d57b..37a4925cc9a 100644 --- a/extensions/memory-wiki/src/cli.test.ts +++ b/extensions/memory-wiki/src/cli.test.ts @@ -508,10 +508,13 @@ cli note expect(secondDryRun.createdCount).toBe(0); expect(secondDryRun.updatedCount).toBe(0); expect(secondDryRun.skippedCount).toBe(1); + if (!applied.runId) { + throw new Error("Expected ChatGPT import dry-run apply runId"); + } const rollback = await runWikiChatGptRollback({ config, - runId: applied.runId!, + runId: applied.runId, json: true, }); expect(rollback.alreadyRolledBack).toBe(false); diff --git a/extensions/memory-wiki/src/lint.test.ts b/extensions/memory-wiki/src/lint.test.ts index 35d1f5c9602..c2e5415f3a3 100644 --- a/extensions/memory-wiki/src/lint.test.ts +++ b/extensions/memory-wiki/src/lint.test.ts @@ -47,7 +47,7 @@ describe("lintMemoryWikiVault", () => { const result = await lintMemoryWikiVault(config); - expect(result.issues.filter((issue) => issue.code === "broken-wikilink")).toEqual([]); + expect(result.issues.some((issue) => issue.code === "broken-wikilink")).toBe(false); }); it("detects duplicate ids, provenance gaps, contradictions, and open questions", async () => { diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index e6a000c0296..6979a9f2b2b 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -45,6 +45,16 @@ const { createVault } = createMemoryWikiTestHarness(); let suiteRoot = ""; let caseIndex = 0; +function collectWikiResultPaths(results: readonly { corpus: string; path: string }[]): string[] { + const paths: string[] = []; + for (const result of results) { + if (result.corpus === "wiki") { + paths.push(result.path); + } + } + return paths; +} + beforeEach(() => { getActiveMemorySearchManagerMock.mockReset(); getActiveMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "unavailable" }); @@ -638,8 +648,9 @@ describe("searchMemoryWiki", () => { }); expect(results).toHaveLength(2); - expect(results.some((result) => result.corpus === "wiki")).toBe(true); - expect(results.some((result) => result.corpus === "memory")).toBe(true); + expect(results.map((result) => result.corpus)).toEqual( + expect.arrayContaining(["wiki", "memory"]), + ); expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 }); expect(getActiveMemorySearchManagerMock).toHaveBeenCalledWith({ cfg: createAppConfig(), @@ -691,10 +702,8 @@ describe("searchMemoryWiki", () => { }); expect(results).toHaveLength(5); - expect(results.some((result) => result.corpus === "memory")).toBe(true); - expect( - results.filter((result) => result.corpus === "wiki").map((result) => result.path), - ).toEqual([ + expect(results.map((result) => result.corpus)).toContain("memory"); + expect(collectWikiResultPaths(results)).toEqual([ "entities/alpha-1.md", "entities/alpha-2.md", "entities/alpha-3.md", @@ -754,7 +763,9 @@ describe("searchMemoryWiki", () => { "sessions/child-session.jsonl", "MEMORY.md", ]); - expect(results.some((result) => result.path.includes("sibling-session"))).toBe(false); + expect(results.map((result) => result.path)).not.toEqual( + expect.arrayContaining([expect.stringContaining("sibling-session")]), + ); }); it("filters session memory hits for session-bound non-sandboxed callers", async () => { @@ -808,7 +819,9 @@ describe("searchMemoryWiki", () => { "sessions/child-session.jsonl", "MEMORY.md", ]); - expect(results.some((result) => result.path.includes("sibling-session"))).toBe(false); + expect(results.map((result) => result.path)).not.toEqual( + expect.arrayContaining([expect.stringContaining("sibling-session")]), + ); }); it("requires appConfig for session-bound shared memory searches", async () => { diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts index 6d3c2447825..308aad61d3a 100644 --- a/extensions/memory-wiki/src/tool.test.ts +++ b/extensions/memory-wiki/src/tool.test.ts @@ -3,7 +3,12 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { createWikiApplyTool } from "./tool.js"; function asSchemaObject(value: unknown): Record { + expect(typeof value).toBe("object"); expect(value).toEqual(expect.any(Object)); + expect(Array.isArray(value)).toBe(false); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Expected JSON schema object"); + } return value as Record; } diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 12aa64f9f33..5fe0508b032 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -53,7 +53,31 @@ function registerProvider() { }), ); expect(registerProviderMock).toHaveBeenCalledTimes(1); - return registerProviderMock.mock.calls[0]?.[0]; + const firstCall = registerProviderMock.mock.calls[0]; + if (!firstCall) { + throw new Error("expected Microsoft Foundry provider registration"); + } + return firstCall[0]; +} + +type FoundryProvider = ReturnType; + +function requirePrepareRuntimeAuth( + provider: FoundryProvider, +): NonNullable { + const prepareRuntimeAuth = provider.prepareRuntimeAuth; + expect(prepareRuntimeAuth).toBeTypeOf("function"); + if (!prepareRuntimeAuth) { + throw new Error("expected Microsoft Foundry runtime auth hook"); + } + return prepareRuntimeAuth; +} + +function requireRuntimeAuthResult(result: { apiKey?: string; baseUrl?: string } | undefined) { + if (!result) { + throw new Error("expected Microsoft Foundry runtime auth result"); + } + return result; } const defaultFoundryBaseUrl = "https://example.services.ai.azure.com/openai/v1"; @@ -276,27 +300,29 @@ describe("microsoft-foundry plugin", () => { it("preserves the model-derived base URL for Entra runtime auth refresh", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliToken({ accessToken: "test-token", expiresInMs: 60_000 }); ensureAuthProfileStoreMock.mockReturnValueOnce(buildEntraProfileStore()); - const prepared = await provider.prepareRuntimeAuth?.(buildFoundryRuntimeAuthContext()); + const prepared = requireRuntimeAuthResult( + await prepareRuntimeAuth(buildFoundryRuntimeAuthContext()), + ); - expect(prepared?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); + expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); }); it("retries Entra token refresh after a failed attempt", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliLoginFailure(); mockAzureCliToken({ accessToken: "retry-token", expiresInMs: 10 * 60_000 }); ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore()); const runtimeContext = buildFoundryRuntimeAuthContext(); - await expect(provider.prepareRuntimeAuth?.(runtimeContext)).rejects.toThrow( - "Azure CLI is not logged in", - ); + await expect(prepareRuntimeAuth(runtimeContext)).rejects.toThrow("Azure CLI is not logged in"); - await expect(provider.prepareRuntimeAuth?.(runtimeContext)).resolves.toMatchObject({ + await expect(prepareRuntimeAuth(runtimeContext)).resolves.toMatchObject({ apiKey: "retry-token", }); expect(execFileMock).toHaveBeenCalledTimes(2); @@ -304,23 +330,25 @@ describe("microsoft-foundry plugin", () => { it("dedupes concurrent Entra token refreshes for the same profile", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliToken({ accessToken: "deduped-token", expiresInMs: 60_000, delayMs: 10 }); ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore()); const runtimeContext = buildFoundryRuntimeAuthContext(); const [first, second] = await Promise.all([ - provider.prepareRuntimeAuth?.(runtimeContext), - provider.prepareRuntimeAuth?.(runtimeContext), + prepareRuntimeAuth(runtimeContext), + prepareRuntimeAuth(runtimeContext), ]); expect(execFileMock).toHaveBeenCalledTimes(1); - expect(first?.apiKey).toBe("deduped-token"); - expect(second?.apiKey).toBe("deduped-token"); + expect(requireRuntimeAuthResult(first).apiKey).toBe("deduped-token"); + expect(requireRuntimeAuthResult(second).apiKey).toBe("deduped-token"); }); it("clears failed refresh state so later concurrent retries succeed", async () => { const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); mockAzureCliLoginFailure(10); mockAzureCliToken({ accessToken: "recovered-token", expiresInMs: 10 * 60_000, delayMs: 10 }); ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore()); @@ -328,19 +356,19 @@ describe("microsoft-foundry plugin", () => { const runtimeContext = buildFoundryRuntimeAuthContext(); const failed = await Promise.allSettled([ - provider.prepareRuntimeAuth?.(runtimeContext), - provider.prepareRuntimeAuth?.(runtimeContext), + prepareRuntimeAuth(runtimeContext), + prepareRuntimeAuth(runtimeContext), ]); - expect(failed.filter((result) => result.status !== "rejected")).toEqual([]); + expect(failed.every((result) => result.status === "rejected")).toBe(true); expect(execFileMock).toHaveBeenCalledTimes(1); const [first, second] = await Promise.all([ - provider.prepareRuntimeAuth?.(runtimeContext), - provider.prepareRuntimeAuth?.(runtimeContext), + prepareRuntimeAuth(runtimeContext), + prepareRuntimeAuth(runtimeContext), ]); expect(execFileMock).toHaveBeenCalledTimes(2); - expect(first?.apiKey).toBe("recovered-token"); - expect(second?.apiKey).toBe("recovered-token"); + expect(requireRuntimeAuthResult(first).apiKey).toBe("recovered-token"); + expect(requireRuntimeAuthResult(second).apiKey).toBe("recovered-token"); }); it("refreshes again when a cached token is too close to expiry", async () => { diff --git a/extensions/microsoft/microsoft.live.test.ts b/extensions/microsoft/microsoft.live.test.ts index 48c6c5e02f6..8bef656d11b 100644 --- a/extensions/microsoft/microsoft.live.test.ts +++ b/extensions/microsoft/microsoft.live.test.ts @@ -9,6 +9,6 @@ describeLive("microsoft plugin live", () => { const voices = await listMicrosoftVoices(); expect(voices.length).toBeGreaterThan(100); - expect(voices.some((voice) => voice.id === "en-US-MichelleNeural")).toBe(true); + expect(voices.map((voice) => voice.id)).toContain("en-US-MichelleNeural"); }, 60_000); }); diff --git a/extensions/minimax/speech-provider.test.ts b/extensions/minimax/speech-provider.test.ts index 96087adf1ac..42ecba258ca 100644 --- a/extensions/minimax/speech-provider.test.ts +++ b/extensions/minimax/speech-provider.test.ts @@ -21,6 +21,26 @@ function clearMinimaxAuthEnv() { describe("buildMinimaxSpeechProvider", () => { const provider = buildMinimaxSpeechProvider(); + function resolveProviderConfig( + params: Parameters>[0], + ): ReturnType> { + const resolveConfig = provider.resolveConfig; + if (!resolveConfig) { + throw new Error("MiniMax speech provider did not expose config resolution"); + } + return resolveConfig(params); + } + + function parseDirectiveToken( + params: Parameters>[0], + ): ReturnType> { + const parseToken = provider.parseDirectiveToken; + if (!parseToken) { + throw new Error("MiniMax speech provider did not expose directive parsing"); + } + return parseToken(params); + } + describe("metadata", () => { it("has correct id and label", () => { expect(provider.id).toBe("minimax"); @@ -107,14 +127,14 @@ describe("buildMinimaxSpeechProvider", () => { delete process.env.MINIMAX_API_HOST; delete process.env.MINIMAX_TTS_MODEL; delete process.env.MINIMAX_TTS_VOICE_ID; - const config = provider.resolveConfig!({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); + const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); expect(config.baseUrl).toBe("https://api.minimax.io"); expect(config.model).toBe("speech-2.8-hd"); expect(config.voiceId).toBe("English_expressive_narrator"); }); it("reads from providers.minimax in rawConfig", () => { - const config = provider.resolveConfig!({ + const config = resolveProviderConfig({ rawConfig: { providers: { minimax: { @@ -142,7 +162,7 @@ describe("buildMinimaxSpeechProvider", () => { process.env.MINIMAX_API_HOST = "https://api.minimax.io/anthropic"; process.env.MINIMAX_TTS_MODEL = "speech-01-240228"; process.env.MINIMAX_TTS_VOICE_ID = "Chinese (Mandarin)_Gentle_Boy"; - const config = provider.resolveConfig!({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); + const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); expect(config.baseUrl).toBe("https://api.minimax.io"); expect(config.model).toBe("speech-01-240228"); expect(config.voiceId).toBe("Chinese (Mandarin)_Gentle_Boy"); @@ -150,7 +170,7 @@ describe("buildMinimaxSpeechProvider", () => { it("derives the TTS host from minimax-portal OAuth config", () => { delete process.env.MINIMAX_API_HOST; - const config = provider.resolveConfig!({ + const config = resolveProviderConfig({ rawConfig: {}, cfg: { models: { @@ -178,7 +198,7 @@ describe("buildMinimaxSpeechProvider", () => { }; it("handles voice key", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "voice", value: "Chinese (Mandarin)_Warm_Girl", policy, @@ -188,13 +208,13 @@ describe("buildMinimaxSpeechProvider", () => { }); it("handles voiceid key", () => { - const result = provider.parseDirectiveToken!({ key: "voiceid", value: "test_voice", policy }); + const result = parseDirectiveToken({ key: "voiceid", value: "test_voice", policy }); expect(result.handled).toBe(true); expect(result.overrides?.voiceId).toBe("test_voice"); }); it("handles model key", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "model", value: "speech-01-240228", policy, @@ -204,50 +224,50 @@ describe("buildMinimaxSpeechProvider", () => { }); it("handles speed key with valid value", () => { - const result = provider.parseDirectiveToken!({ key: "speed", value: "1.5", policy }); + const result = parseDirectiveToken({ key: "speed", value: "1.5", policy }); expect(result.handled).toBe(true); expect(result.overrides?.speed).toBe(1.5); }); it("warns on invalid speed", () => { - const result = provider.parseDirectiveToken!({ key: "speed", value: "5.0", policy }); + const result = parseDirectiveToken({ key: "speed", value: "5.0", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.overrides).toBeUndefined(); }); it("handles vol key", () => { - const result = provider.parseDirectiveToken!({ key: "vol", value: "3", policy }); + const result = parseDirectiveToken({ key: "vol", value: "3", policy }); expect(result.handled).toBe(true); expect(result.overrides?.vol).toBe(3); }); it("warns on vol=0 (exclusive minimum)", () => { - const result = provider.parseDirectiveToken!({ key: "vol", value: "0", policy }); + const result = parseDirectiveToken({ key: "vol", value: "0", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); }); it("handles volume alias", () => { - const result = provider.parseDirectiveToken!({ key: "volume", value: "5", policy }); + const result = parseDirectiveToken({ key: "volume", value: "5", policy }); expect(result.handled).toBe(true); expect(result.overrides?.vol).toBe(5); }); it("handles pitch key", () => { - const result = provider.parseDirectiveToken!({ key: "pitch", value: "-3", policy }); + const result = parseDirectiveToken({ key: "pitch", value: "-3", policy }); expect(result.handled).toBe(true); expect(result.overrides?.pitch).toBe(-3); }); it("warns on out-of-range pitch", () => { - const result = provider.parseDirectiveToken!({ key: "pitch", value: "20", policy }); + const result = parseDirectiveToken({ key: "pitch", value: "20", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); }); it("returns handled=false for unknown keys", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "unknown_key", value: "whatever", policy, @@ -256,7 +276,7 @@ describe("buildMinimaxSpeechProvider", () => { }); it("suppresses voice when policy disallows it", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "voice", value: "test", policy: { ...policy, allowVoice: false }, @@ -266,7 +286,7 @@ describe("buildMinimaxSpeechProvider", () => { }); it("suppresses model when policy disallows it", () => { - const result = provider.parseDirectiveToken!({ + const result = parseDirectiveToken({ key: "model", value: "test", policy: { ...policy, allowModelId: false }, @@ -329,7 +349,10 @@ describe("buildMinimaxSpeechProvider", () => { expect(mockFetch).toHaveBeenCalledOnce(); const [url, init] = mockFetch.mock.calls[0]; expect(url).toBe("https://api.minimaxi.com/v1/t2a_v2"); - const body = JSON.parse(init!.body as string); + if (!init?.body) { + throw new Error("Expected MiniMax TTS fetch init body"); + } + const body = JSON.parse(init.body as string); expect(body.model).toBe("speech-2.8-hd"); expect(body.text).toBe("Hello world"); expect(body.voice_setting.voice_id).toBe("English_expressive_narrator"); @@ -485,7 +508,11 @@ describe("buildMinimaxSpeechProvider", () => { describe("listVoices", () => { it("returns known voices", async () => { - const voices = await provider.listVoices!({} as never); + const listVoices = provider.listVoices; + if (!listVoices) { + throw new Error("Expected MiniMax provider listVoices"); + } + const voices = await listVoices({} as never); expect(voices.length).toBeGreaterThan(0); expect(voices[0].id).toBe("English_expressive_narrator"); }); diff --git a/extensions/moonshot/media-understanding-provider.test.ts b/extensions/moonshot/media-understanding-provider.test.ts index 85b90068d4a..73e0d46af8b 100644 --- a/extensions/moonshot/media-understanding-provider.test.ts +++ b/extensions/moonshot/media-understanding-provider.test.ts @@ -28,27 +28,45 @@ describe("describeMoonshotVideo", () => { expect(result.text).toBe("video ok"); expect(result.model).toBe("kimi-k2.6"); expect(url).toBe("https://api.moonshot.ai/v1/chat/completions"); - expect(init?.method).toBe("POST"); - expect(init?.signal).toBeInstanceOf(AbortSignal); + if (!init) { + throw new Error("expected Moonshot request init"); + } + expect(init.method).toBe("POST"); + expect(init.signal).toBeInstanceOf(AbortSignal); - const headers = new Headers(init?.headers); + const headers = new Headers(init.headers); expect(headers.get("authorization")).toBe("Bearer moonshot-test"); expect(headers.get("content-type")).toBe("application/json"); expect(headers.get("x-trace")).toBe("1"); - const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as { + expect(init.body).toBeTypeOf("string"); + if (typeof init.body !== "string") { + throw new Error("expected Moonshot JSON request body"); + } + const body = JSON.parse(init.body) as { model?: string; messages?: Array<{ content?: Array<{ type?: string; text?: string; video_url?: { url?: string } }>; }>; }; expect(body.model).toBe("kimi-k2.6"); - expect(body.messages?.[0]?.content?.[0]).toMatchObject({ + const content = body.messages?.[0]?.content; + if (!content) { + throw new Error("expected Moonshot user content"); + } + expect(content[0]).toMatchObject({ type: "text", text: "Describe the video.", }); - expect(body.messages?.[0]?.content?.[1]?.type).toBe("video_url"); - expect(body.messages?.[0]?.content?.[1]?.video_url?.url).toBe( + const videoContent = content[1]; + if (!videoContent) { + throw new Error("expected Moonshot video content"); + } + expect(videoContent.type).toBe("video_url"); + if (!videoContent.video_url) { + throw new Error("expected Moonshot video URL payload"); + } + expect(videoContent.video_url.url).toBe( `data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`, ); }); diff --git a/extensions/moonshot/provider-catalog.test.ts b/extensions/moonshot/provider-catalog.test.ts index f4a65b4d042..4644acf91ad 100644 --- a/extensions/moonshot/provider-catalog.test.ts +++ b/extensions/moonshot/provider-catalog.test.ts @@ -6,6 +6,32 @@ import { MOONSHOT_CN_BASE_URL, } from "./api.js"; +type MoonshotProvider = ReturnType; +type MoonshotModel = MoonshotProvider["models"][number]; + +function requireMoonshotModel(provider: MoonshotProvider, modelId: string): MoonshotModel { + const model = provider.models.find((candidate) => candidate.id === modelId); + if (!model) { + throw new Error(`expected Moonshot model ${modelId}`); + } + return model; +} + +function requireFirstMoonshotModel(provider: MoonshotProvider): MoonshotModel { + const model = provider.models[0]; + if (!model) { + throw new Error("expected first Moonshot model"); + } + return model; +} + +function requireMoonshotCompat(model: MoonshotModel): NonNullable { + if (!model.compat) { + throw new Error(`expected Moonshot model ${model.id} compat`); + } + return model.compat; +} + describe("moonshot provider catalog", () => { it("builds the bundled Moonshot provider defaults", () => { const provider = buildMoonshotProvider(); @@ -19,13 +45,13 @@ describe("moonshot provider catalog", () => { "kimi-k2-thinking-turbo", "kimi-k2-turbo", ]); - expect(provider.models.find((model) => model.id === "kimi-k2.6")?.cost).toEqual({ + expect(requireMoonshotModel(provider, "kimi-k2.6").cost).toEqual({ input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0, }); - expect(provider.models.find((model) => model.id === "kimi-k2.5")?.cost).toEqual({ + expect(requireMoonshotModel(provider, "kimi-k2.5").cost).toEqual({ input: 0.6, output: 3, cacheRead: 0.1, @@ -35,18 +61,24 @@ describe("moonshot provider catalog", () => { it("opts native Moonshot baseUrls into streaming usage only inside the extension", () => { const defaultProvider = applyMoonshotNativeStreamingUsageCompat(buildMoonshotProvider()); - expect(defaultProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + expect( + requireMoonshotCompat(requireFirstMoonshotModel(defaultProvider)).supportsUsageInStreaming, + ).toBe(true); const cnProvider = applyMoonshotNativeStreamingUsageCompat({ ...buildMoonshotProvider(), baseUrl: MOONSHOT_CN_BASE_URL, }); - expect(cnProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + expect( + requireMoonshotCompat(requireFirstMoonshotModel(cnProvider)).supportsUsageInStreaming, + ).toBe(true); const customProvider = applyMoonshotNativeStreamingUsageCompat({ ...buildMoonshotProvider(), baseUrl: "https://proxy.example.com/v1", }); - expect(customProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); + expect( + "supportsUsageInStreaming" in (requireFirstMoonshotModel(customProvider).compat ?? {}), + ).toBe(false); }); }); diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ace547990..308895a744f 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -639,7 +639,7 @@ describe("msteams attachments", () => { }); // Should have hit the original host, NOT graph shares. expect(calledUrls).toContain(directUrl); - expect(calledUrls.filter((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toEqual([]); + expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false); }); }); diff --git a/extensions/msteams/src/channel.message-adapter.test.ts b/extensions/msteams/src/channel.message-adapter.test.ts index 9c29f545488..a4d5b65b1f4 100644 --- a/extensions/msteams/src/channel.message-adapter.test.ts +++ b/extensions/msteams/src/channel.message-adapter.test.ts @@ -24,6 +24,37 @@ vi.mock("./channel.runtime.js", () => ({ import { msteamsPlugin } from "./channel.js"; +type MSTeamsMessageAdapter = NonNullable; +type MSTeamsMessageSender = NonNullable; + +function requireMSTeamsMessageAdapter(): MSTeamsMessageAdapter { + const adapter = msteamsPlugin.message; + if (!adapter) { + throw new Error("Expected msteams channel message adapter"); + } + return adapter; +} + +function requireTextSender( + adapter: MSTeamsMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected msteams message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: MSTeamsMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected msteams message adapter media sender"); + } + return media; +} + const cfg = { channels: { msteams: { @@ -50,12 +81,9 @@ describe("msteams channel message adapter", () => { }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = msteamsPlugin.message; - if (!adapter?.send?.text || !adapter.send.media) { - throw new Error("expected msteams channel message adapter with text and media senders"); - } - const sendText = adapter.send.text; - const sendMedia = adapter.send.media; + const adapter = requireMSTeamsMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); expect(adapter.durableFinal?.capabilities?.replyTo).toBeUndefined(); expect(adapter.durableFinal?.capabilities?.thread).toBeUndefined(); @@ -116,39 +144,40 @@ describe("msteams channel message adapter", () => { }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = msteamsPlugin.message; + const adapter = requireMSTeamsMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "msteamsMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.capabilities?.nativeStreaming).toBe(true); + expect(adapter.live?.capabilities?.nativeStreaming).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, nativeStreaming: () => { - expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.previewReceipt).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "msteamsMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, previewReceipt: () => { - expect(adapter!.live?.capabilities?.nativeStreaming).toBe(true); + expect(adapter.live?.capabilities?.nativeStreaming).toBe(true); }, }, }); diff --git a/extensions/msteams/src/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts index d7d92071cad..e45d31e4d48 100644 --- a/extensions/msteams/src/conversation-store-fs.test.ts +++ b/extensions/msteams/src/conversation-store-fs.test.ts @@ -56,7 +56,14 @@ describe("msteams conversation store (fs-only)", () => { expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]); expect(await store.get("19:old@thread.tacv2")).toBeNull(); - expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull(); + const legacyConversation = await store.get("19:legacy@thread.tacv2"); + if (!legacyConversation) { + throw new Error("expected migrated legacy Teams conversation"); + } + if (!legacyConversation.conversation) { + throw new Error("expected migrated legacy Teams conversation payload"); + } + expect(legacyConversation.conversation.id).toBe("19:legacy@thread.tacv2"); await store.upsert("19:new@thread.tacv2", { ...ref, diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 3db22f851a4..18a5b0f1823 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -463,7 +463,7 @@ describe("msteams monitor handler authz", () => { ); }); - it("does not crash when channelData.tenant is missing and stores no tenantId", async () => { + it("stores no tenantId when channelData.tenant is missing", async () => { const { conversationStore, deps } = createDeps({ channels: { msteams: { diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index 11d4f6ef412..85ee1b5dc47 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -205,6 +205,20 @@ function createConfig(port: number): OpenClawConfig { } as OpenClawConfig; } +function updateMSTeamsConfig( + cfg: OpenClawConfig, + patch: NonNullable["msteams"]>, +): void { + const msteams = cfg.channels?.msteams; + if (!cfg.channels || !msteams) { + throw new Error("Expected Microsoft Teams config fixture"); + } + cfg.channels.msteams = { + ...msteams, + ...patch, + }; +} + function createRuntime(): RuntimeEnv { return { log: vi.fn(), @@ -262,7 +276,9 @@ describe("monitorMSTeamsProvider lifecycle", () => { abort.abort(); const result = await task; - expect(result.app).not.toBeNull(); + if (!result.app) { + throw new Error("expected Teams monitor app after startup abort"); + } await expect(result.shutdown()).resolves.toBeUndefined(); }); @@ -331,8 +347,7 @@ describe("monitorMSTeamsProvider lifecycle", () => { it("does not resolve user allowlists by display name unless name matching is enabled", async () => { const abort = new AbortController(); const cfg = createConfig(0); - cfg.channels!.msteams = { - ...cfg.channels!.msteams!, + updateMSTeamsConfig(cfg, { allowFrom: ["Alice", "user:40a1a0ed-4ff2-4164-a219-55518990c197"], groupAllowFrom: ["Bob", "msteams:user:50a1a0ed-4ff2-4164-a219-55518990c198"], teams: { @@ -342,7 +357,7 @@ describe("monitorMSTeamsProvider lifecycle", () => { }, }, }, - }; + }); resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockResolvedValueOnce([ { input: "Product/Roadmap", @@ -394,12 +409,11 @@ describe("monitorMSTeamsProvider lifecycle", () => { const abort = new AbortController(); const cfg = createConfig(0); - cfg.channels!.msteams = { - ...cfg.channels!.msteams!, + updateMSTeamsConfig(cfg, { dangerouslyAllowNameMatching: true, allowFrom: ["Alice"], groupAllowFrom: ["Bob"], - }; + }); const task = monitorMSTeamsProvider({ cfg, diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts index 2b11ffd6309..629115d16af 100644 --- a/extensions/msteams/src/outbound.test.ts +++ b/extensions/msteams/src/outbound.test.ts @@ -20,6 +20,34 @@ vi.mock("./polls.js", () => ({ import { msteamsOutbound } from "./outbound.js"; +type MSTeamsSendText = NonNullable; +type MSTeamsSendMedia = NonNullable; +type MSTeamsSendPoll = NonNullable; + +function requireSendText(): MSTeamsSendText { + const sendText = msteamsOutbound.sendText; + if (!sendText) { + throw new Error("Expected msteams outbound sendText"); + } + return sendText; +} + +function requireSendMedia(): MSTeamsSendMedia { + const sendMedia = msteamsOutbound.sendMedia; + if (!sendMedia) { + throw new Error("Expected msteams outbound sendMedia"); + } + return sendMedia; +} + +function requireSendPoll(): MSTeamsSendPoll { + const sendPoll = msteamsOutbound.sendPoll; + if (!sendPoll) { + throw new Error("Expected msteams outbound sendPoll"); + } + return sendPoll; +} + describe("msteamsOutbound cfg threading", () => { beforeEach(() => { mocks.sendMessageMSTeams.mockReset(); @@ -46,7 +74,7 @@ describe("msteamsOutbound cfg threading", () => { }, } as OpenClawConfig; - await msteamsOutbound.sendText!({ + await requireSendText()({ cfg, to: "conversation:abc", text: "hello", @@ -68,7 +96,7 @@ describe("msteamsOutbound cfg threading", () => { }, } as OpenClawConfig; - await msteamsOutbound.sendMedia!({ + await requireSendMedia()({ cfg, to: "conversation:abc", text: "photo", @@ -94,7 +122,7 @@ describe("msteamsOutbound cfg threading", () => { }, } as OpenClawConfig; - await msteamsOutbound.sendPoll!({ + await requireSendPoll()({ cfg, to: "conversation:abc", poll: { diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index 484dffb116b..e7f5de56c7d 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -147,7 +147,7 @@ function createSdkStub(): MSTeamsTeamsSdk { } describe("createMSTeamsApp", () => { - it("does not crash with express 5 path-to-regexp (#55161)", async () => { + it("creates app without the Express 5 wildcard route regression (#55161)", async () => { // Regression test for: https://github.com/openclaw/openclaw/issues/55161 // createMSTeamsApp passes a no-op httpServerAdapter to prevent the SDK from // creating its default HttpPlugin (which registers `/api*` — invalid in Express 5). @@ -282,7 +282,7 @@ describe("createBotFrameworkJwtValidator", () => { await expect(validator.validate("Bearer botfw-token")).resolves.toBe(true); const opts = jwtState.verifyCalls[0]?.options as Record; - expect((opts.audience as string[]).includes("https://api.botframework.com")).toBe(true); + expect(opts.audience).toContain("https://api.botframework.com"); }); it("accepts global audience tokens when azp matches the configured app id", async () => { diff --git a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts index 2c0cb17b39f..9ad5f348f45 100644 --- a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts +++ b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts @@ -17,6 +17,16 @@ vi.mock("./monitor-runtime.js", () => ({ const { nextcloudTalkGatewayAdapter } = await import("./gateway.js"); +type NextcloudTalkStartAccount = NonNullable; + +function requireStartAccount(): NextcloudTalkStartAccount { + const startAccount = nextcloudTalkGatewayAdapter.startAccount; + if (!startAccount) { + throw new Error("Expected Nextcloud Talk gateway startAccount"); + } + return startAccount; +} + function buildAccount(): ResolvedNextcloudTalkAccount { return { accountId: "default", @@ -40,7 +50,7 @@ function mockStartedMonitor() { } function startNextcloudAccount(abortSignal?: AbortSignal) { - return nextcloudTalkGatewayAdapter.startAccount!( + return requireStartAccount()( createStartAccountContext({ account: buildAccount(), abortSignal, @@ -56,7 +66,7 @@ describe("nextcloud-talk startAccount lifecycle", () => { it("keeps startAccount pending until abort, then stops the monitor", async () => { const stop = mockStartedMonitor(); const { abort, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: nextcloudTalkGatewayAdapter.startAccount!, + startAccount: requireStartAccount(), account: buildAccount(), }); await expectStopPendingUntilAbort({ diff --git a/extensions/nextcloud-talk/src/setup.test.ts b/extensions/nextcloud-talk/src/setup.test.ts index 24f997a301f..9ac0b0f6cb7 100644 --- a/extensions/nextcloud-talk/src/setup.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -193,23 +193,26 @@ describe("nextcloud talk setup", () => { const applyAccountConfig = nextcloudTalkSetupAdapter.applyAccountConfig; expect(validateInput).toBeTypeOf("function"); expect(applyAccountConfig).toBeTypeOf("function"); + if (!validateInput) { + throw new Error("Expected Nextcloud Talk setup validateInput"); + } expect( - validateInput!({ + validateInput({ accountId: "work", input: { useEnv: true }, } as never), ).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, baseUrl: "", secret: "" }, } as never), ).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env)."); expect( - validateInput!({ + validateInput({ accountId: DEFAULT_ACCOUNT_ID, input: { useEnv: false, secret: "secret", baseUrl: "" }, } as never), diff --git a/extensions/nostr/src/nostr-bus.fuzz.test.ts b/extensions/nostr/src/nostr-bus.fuzz.test.ts index 0eff6ed8da7..3d29c4ab355 100644 --- a/extensions/nostr/src/nostr-bus.fuzz.test.ts +++ b/extensions/nostr/src/nostr-bus.fuzz.test.ts @@ -145,7 +145,7 @@ describe("SeenTracker fuzz", () => { describe("malformed IDs", () => { it("handles empty string IDs", () => { const tracker = createTracker(); - expect(() => tracker.add("")).not.toThrow(); + expect(tracker.add("")).toBeUndefined(); expect(tracker.peek("")).toBe(true); tracker.stop(); }); @@ -153,7 +153,7 @@ describe("SeenTracker fuzz", () => { it("handles very long IDs", () => { const tracker = createTracker(); const longId = "a".repeat(100000); - expect(() => tracker.add(longId)).not.toThrow(); + expect(tracker.add(longId)).toBeUndefined(); expect(tracker.peek(longId)).toBe(true); tracker.stop(); }); @@ -161,7 +161,7 @@ describe("SeenTracker fuzz", () => { it("handles unicode IDs", () => { const tracker = createTracker(); const unicodeId = "事件ID_🎉_тест"; - expect(() => tracker.add(unicodeId)).not.toThrow(); + expect(tracker.add(unicodeId)).toBeUndefined(); expect(tracker.peek(unicodeId)).toBe(true); tracker.stop(); }); @@ -169,7 +169,7 @@ describe("SeenTracker fuzz", () => { it("handles IDs with null bytes", () => { const tracker = createTracker(); const idWithNull = "event\x00id"; - expect(() => tracker.add(idWithNull)).not.toThrow(); + expect(tracker.add(idWithNull)).toBeUndefined(); expect(tracker.peek(idWithNull)).toBe(true); tracker.stop(); }); @@ -178,10 +178,10 @@ describe("SeenTracker fuzz", () => { const tracker = createTracker(); // These should not affect the tracker's internal operation - expect(() => tracker.add("__proto__")).not.toThrow(); - expect(() => tracker.add("constructor")).not.toThrow(); - expect(() => tracker.add("toString")).not.toThrow(); - expect(() => tracker.add("hasOwnProperty")).not.toThrow(); + expect(tracker.add("__proto__")).toBeUndefined(); + expect(tracker.add("constructor")).toBeUndefined(); + expect(tracker.add("toString")).toBeUndefined(); + expect(tracker.add("hasOwnProperty")).toBeUndefined(); expect(tracker.peek("__proto__")).toBe(true); expect(tracker.peek("constructor")).toBe(true); @@ -231,7 +231,7 @@ describe("SeenTracker fuzz", () => { describe("seed edge cases", () => { it("handles empty seed array", () => { const tracker = createTracker(); - expect(() => tracker.seed([])).not.toThrow(); + expect(tracker.seed([])).toBeUndefined(); expect(tracker.size()).toBe(0); tracker.stop(); }); @@ -263,33 +263,29 @@ describe("Metrics fuzz", () => { const metrics = createPlainMetrics(); // Cast to bypass type checking - testing runtime behavior - expect(() => { - metrics.emit("invalid.metric.name" as MetricName); - }).not.toThrow(); + expect(metrics.emit("invalid.metric.name" as MetricName)).toBeUndefined(); }); }); describe("invalid label values", () => { it("handles null relay label", () => { const metrics = createPlainMetrics(); - expect(() => { - metrics.emit("relay.connect", 1, { relay: null as unknown as string }); - }).not.toThrow(); + expect( + metrics.emit("relay.connect", 1, { relay: null as unknown as string }), + ).toBeUndefined(); }); it("handles undefined relay label", () => { const metrics = createPlainMetrics(); - expect(() => { - metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }); - }).not.toThrow(); + expect( + metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }), + ).toBeUndefined(); }); it("handles very long relay URL", () => { const metrics = createPlainMetrics(); const longUrl = "wss://" + "a".repeat(10000) + ".com"; - expect(() => { - metrics.emit("relay.connect", 1, { relay: longUrl }); - }).not.toThrow(); + expect(metrics.emit("relay.connect", 1, { relay: longUrl })).toBeUndefined(); const snapshot = metrics.getSnapshot(); expect(snapshot.relays[longUrl]).toEqual(expect.objectContaining({ connects: 1 })); @@ -299,7 +295,7 @@ describe("Metrics fuzz", () => { describe("extreme values", () => { it("handles NaN value", () => { const metrics = createPlainMetrics(); - expect(() => metrics.emit("event.received", Number.NaN)).not.toThrow(); + expect(metrics.emit("event.received", Number.NaN)).toBeUndefined(); const snapshot = metrics.getSnapshot(); expect(Number.isNaN(snapshot.eventsReceived)).toBe(true); @@ -307,7 +303,7 @@ describe("Metrics fuzz", () => { it("handles Infinity value", () => { const metrics = createPlainMetrics(); - expect(() => metrics.emit("event.received", Infinity)).not.toThrow(); + expect(metrics.emit("event.received", Infinity)).toBeUndefined(); const snapshot = metrics.getSnapshot(); expect(snapshot.eventsReceived).toBe(Infinity); diff --git a/extensions/nostr/src/nostr-bus.integration.test.ts b/extensions/nostr/src/nostr-bus.integration.test.ts index fcf01378d39..782e65a9da9 100644 --- a/extensions/nostr/src/nostr-bus.integration.test.ts +++ b/extensions/nostr/src/nostr-bus.integration.test.ts @@ -386,13 +386,11 @@ describe("Metrics", () => { }); describe("createNoopMetrics", () => { - it("does not throw on emit", () => { + it("ignores emitted metrics", () => { const metrics = createNoopMetrics(); - expect(() => { - metrics.emit("event.received"); - metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY }); - }).not.toThrow(); + expect(metrics.emit("event.received")).toBeUndefined(); + expect(metrics.emit("relay.connect", 1, { relay: TEST_RELAY_URL_PRIMARY })).toBeUndefined(); }); it("returns empty snapshot", () => { diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 28ece3f3a21..90cba862337 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -429,8 +429,8 @@ describe("nostr-profile-http", () => { const data = expectBadRequestResponse(res); // The schema validation catches non-https URLs before SSRF check expect(data.error).toBe("Validation failed"); - expect(data.details).toEqual(expect.any(Array)); - expect(data.details.some((d: string) => d.includes("https"))).toBe(true); + expect(Array.isArray(data.details)).toBe(true); + expect(data.details).toEqual(expect.arrayContaining([expect.stringContaining("https")])); }); it("does not persist if all relays fail", async () => { diff --git a/extensions/nostr/src/nostr-profile.fuzz.test.ts b/extensions/nostr/src/nostr-profile.fuzz.test.ts index fbd577479ee..f2da03faec8 100644 --- a/extensions/nostr/src/nostr-profile.fuzz.test.ts +++ b/extensions/nostr/src/nostr-profile.fuzz.test.ts @@ -58,7 +58,7 @@ describe("profile unicode attacks", () => { // UI should escape or handle this const sanitized = sanitizeProfileForDisplay(result.profile); - expect(sanitized.name).toEqual(expect.any(String)); + expect(sanitized.name).toBe("\u202Eevil\u202C"); }); it("handles bidi embedding in about", () => { diff --git a/extensions/nostr/src/nostr-profile.test.ts b/extensions/nostr/src/nostr-profile.test.ts index 5f1802ecad3..704a89528cf 100644 --- a/extensions/nostr/src/nostr-profile.test.ts +++ b/extensions/nostr/src/nostr-profile.test.ts @@ -256,7 +256,7 @@ describe("validateProfile", () => { const result = validateProfile(profile); expect(result.valid).toBe(false); - expect(result.errors!.some((e) => e.includes("256"))).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining("256")])); }); it("rejects about exceeding 2000 characters", () => { @@ -267,7 +267,7 @@ describe("validateProfile", () => { const result = validateProfile(profile); expect(result.valid).toBe(false); - expect(result.errors!.some((e) => e.includes("2000"))).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining("2000")])); }); it("accepts empty profile", () => { diff --git a/extensions/nvidia/provider-catalog.test.ts b/extensions/nvidia/provider-catalog.test.ts index 79139b400a3..eb0ad971fff 100644 --- a/extensions/nvidia/provider-catalog.test.ts +++ b/extensions/nvidia/provider-catalog.test.ts @@ -14,8 +14,8 @@ describe("nvidia provider catalog", () => { "minimaxai/minimax-m2.5", "z-ai/glm5", ]); - expect(provider.models.every((model) => model.compat?.requiresStringContent === true)).toBe( - true, + expect(provider.models.filter((model) => model.compat?.requiresStringContent !== true)).toEqual( + [], ); }); }); diff --git a/extensions/ollama/ollama.live.test.ts b/extensions/ollama/ollama.live.test.ts index 9c2e9eb1179..3a769c95d40 100644 --- a/extensions/ollama/ollama.live.test.ts +++ b/extensions/ollama/ollama.live.test.ts @@ -213,7 +213,7 @@ describe.skipIf(!LIVE)("ollama live", () => { const error = events.find((event) => (event as { type?: string }).type === "error"); expect(error).toBeUndefined(); - expect(events.some((event) => (event as { type?: string }).type === "done")).toBe(true); + expect(events.map((event) => (event as { type?: string }).type)).toContain("done"); expect(payload?.model).toBe(CHAT_MODEL); expect(payload?.options?.num_ctx).toBe(4096); expect(payload?.options?.top_p).toBe(0.9); diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index dc8eb2603c0..5120f9da5bb 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -25,13 +25,25 @@ describe("Ollama provider", () => { const fetchCallUrls = (fetchMock: ReturnType): string[] => fetchMock.mock.calls.map(([input]) => String(input)); + const countFetchCallUrls = (fetchMock: ReturnType, suffix: string): number => + fetchCallUrls(fetchMock).reduce((count, url) => count + (url.endsWith(suffix) ? 1 : 0), 0); + + const countWarnCallsIncluding = (warnSpy: ReturnType, text: string): number => { + let count = 0; + for (const [message] of warnSpy.mock.calls) { + if (String(message).includes(text)) { + count++; + } + } + return count; + }; + const expectDiscoveryCallCounts = ( fetchMock: ReturnType, params: { tags: number; show: number }, ) => { - const urls = fetchCallUrls(fetchMock); - expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(params.tags); - expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(params.show); + expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(params.tags); + expect(countFetchCallUrls(fetchMock, "/api/show")).toBe(params.show); }; async function withOllamaApiKey(run: () => Promise): Promise { @@ -148,7 +160,7 @@ describe("Ollama provider", () => { env: { OLLAMA_API_KEY: "test-key" }, }); - expect(fetchCallUrls(fetchMock).filter((url) => url.endsWith("/api/tags"))).toHaveLength(1); + expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1); // Native API strips /v1 suffix via resolveOllamaApiBase() expect(provider?.baseUrl).toBe("http://192.168.20.14:11434"); @@ -272,9 +284,7 @@ describe("Ollama provider", () => { env: { VITEST: "", NODE_ENV: "development" }, }); - expect( - warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")).length, - ).toBeGreaterThan(0); + expect(countWarnCallsIncluding(warnSpy, "Ollama")).toBeGreaterThan(0); warnSpy.mockRestore(); }); }); diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 4531b55b3b4..d7b11383fb4 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -266,12 +266,9 @@ describe("ollama setup", () => { allowSecretRefPrompt: false, }); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("127.0.0.1"))).toBe( - false, - ); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("ollama.com"))).toBe( - true, - ); + const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); + expect(requestUrls).not.toEqual(expect.arrayContaining([expect.stringContaining("127.0.0.1")])); + expect(requestUrls).toEqual(expect.arrayContaining([expect.stringContaining("ollama.com")])); }); it("rejects the local marker during cloud-only setup", async () => { @@ -303,8 +300,8 @@ describe("ollama setup", () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]?.[0]).toContain("/api/tags"); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("/api/me"))).toBe( - false, + expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).not.toEqual( + expect.arrayContaining([expect.stringContaining("/api/me")]), ); }); @@ -431,12 +428,9 @@ describe("ollama setup", () => { "qwen3-coder:480b-cloud", "gpt-oss:120b-cloud", ]); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).endsWith("/api/show"))).toBe( - false, - ); - expect( - fetchMock.mock.calls.some((call) => requestUrl(call[0]) === "https://ollama.com/api/tags"), - ).toBe(true); + const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); + expect(requestUrls.some((url) => url.endsWith("/api/show"))).toBe(false); + expect(requestUrls).toContain("https://ollama.com/api/tags"); }); it("uses /api/show context windows when building Ollama model configs", async () => { @@ -702,9 +696,8 @@ describe("ollama setup", () => { }); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).endsWith("/api/pull"))).toBe( - false, - ); + const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0])); + expect(requestUrls.some((url) => url.endsWith("/api/pull"))).toBe(false); expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([ "gemma4:latest", ]); diff --git a/extensions/openai/media-understanding-provider.test.ts b/extensions/openai/media-understanding-provider.test.ts index 602f1498af7..ac9049b7505 100644 --- a/extensions/openai/media-understanding-provider.test.ts +++ b/extensions/openai/media-understanding-provider.test.ts @@ -75,12 +75,9 @@ describe("transcribeOpenAiAudio", () => { expect(form.get("language")).toBe("en"); expect(form.get("prompt")).toBe("hello"); const file = form.get("file") as Blob | { type?: string; name?: string } | null; - expect(file).not.toBeNull(); - if (file) { - expect(file.type).toBe("audio/wav"); - if ("name" in file && typeof file.name === "string") { - expect(file.name).toBe("voice.wav"); - } + expect(file).toEqual(expect.objectContaining({ type: "audio/wav" })); + if (file && "name" in file && typeof file.name === "string") { + expect(file.name).toBe("voice.wav"); } }); diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index 1723a499122..a1a59de1117 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -51,7 +51,10 @@ describe("opencode provider plugin", () => { name: "OpenCode Zen Provider", }); const provider = requireRegisteredProvider(providers, "opencode"); - const resolveThinkingProfile = provider.resolveThinkingProfile!; + const resolveThinkingProfile = provider.resolveThinkingProfile; + if (!resolveThinkingProfile) { + throw new Error("Expected OpenCode provider resolveThinkingProfile"); + } expect( resolveThinkingProfile({ diff --git a/extensions/opencode/provider-policy-api.test.ts b/extensions/opencode/provider-policy-api.test.ts index 4877c1a70d2..4f02914f247 100644 --- a/extensions/opencode/provider-policy-api.test.ts +++ b/extensions/opencode/provider-policy-api.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { resolveThinkingProfile } from "./provider-policy-api.js"; +function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] { + const ids: string[] = []; + for (const level of levels ?? []) { + if (level.id === "xhigh" || level.id === "max") { + ids.push(level.id); + } + } + return ids; +} + describe("opencode provider policy public artifact", () => { it("exposes Claude Opus 4.7 thinking levels without loading the full provider plugin", () => { expect( @@ -24,8 +34,6 @@ describe("opencode provider policy public artifact", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect( - profile.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), - ).toEqual([]); + expect(collectLegacyExtendedLevelIds(profile.levels)).toEqual([]); }); }); diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index 59a83901bf4..2110c40ff0a 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -31,6 +31,29 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, })); +function requireOpenRouterPostBody(): { + messages?: Array<{ content?: unknown }>; +} { + const request = postJsonRequestMock.mock.calls[0]?.[0]; + if (!request) { + throw new Error("expected OpenRouter image generation request"); + } + return request.body as { messages?: Array<{ content?: unknown }> }; +} + +function requireGeneratedImage( + result: Awaited< + ReturnType["generateImage"]> + >, + index: number, +) { + const image = result.images[index]; + if (!image) { + throw new Error(`expected OpenRouter generated image at index ${index}`); + } + return image; +} + describe("openrouter image generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -125,8 +148,9 @@ describe("openrouter image generation provider", () => { }), }), ); - expect(result.images[0]?.buffer.toString()).toBe("png-one"); - expect(result.images[0]?.mimeType).toBe("image/png"); + const image = requireGeneratedImage(result, 0); + expect(image.buffer.toString()).toBe("png-one"); + expect(image.mimeType).toBe("image/png"); expect(release).toHaveBeenCalledOnce(); }); @@ -162,9 +186,7 @@ describe("openrouter image generation provider", () => { cfg: {} as never, }); - const body = postJsonRequestMock.mock.calls[0]?.[0].body as { - messages?: Array<{ content?: unknown }>; - }; + const body = requireOpenRouterPostBody(); expect(body.messages?.[0]?.content).toEqual([ { type: "text", text: "turn this into watercolor" }, { @@ -174,8 +196,9 @@ describe("openrouter image generation provider", () => { }, }, ]); - expect(result.images[0]?.buffer.toString()).toBe("webp-one"); - expect(result.images[0]?.mimeType).toBe("image/webp"); + const image = requireGeneratedImage(result, 0); + expect(image.buffer.toString()).toBe("webp-one"); + expect(image.mimeType).toBe("image/webp"); }); it("extracts image fallbacks from string content and raw b64 parts", () => { diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index da110dab135..043a71de411 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -61,6 +61,46 @@ function releasedVideo(params: { contentType: string; bytes: string }) { }; } +type OpenRouterVideoProvider = ReturnType; +type OpenRouterVideoResult = Awaited>; + +function requireGenerateCapabilities(provider: OpenRouterVideoProvider) { + const capabilities = provider.capabilities.generate; + if (!capabilities) { + throw new Error("expected OpenRouter generate capabilities"); + } + return capabilities; +} + +function requireFetchCallHeaders(index: number): Headers { + const call = fetchWithTimeoutGuardedMock.mock.calls[index]; + if (!call) { + throw new Error(`expected OpenRouter fetch call ${index + 1}`); + } + const init = call[1] as { headers?: HeadersInit } | undefined; + if (!init) { + throw new Error(`expected OpenRouter fetch call ${index + 1} init`); + } + return new Headers(init.headers); +} + +function requireGeneratedVideo(result: OpenRouterVideoResult, index: number) { + const video = result.videos[index]; + if (!video) { + throw new Error(`expected OpenRouter generated video at index ${index}`); + } + return video; +} + +function requireGeneratedVideoBuffer(result: OpenRouterVideoResult, index: number) { + const video = requireGeneratedVideo(result, index); + expect(video.buffer).toBeInstanceOf(Buffer); + if (!video.buffer) { + throw new Error(`expected OpenRouter generated video ${index} buffer`); + } + return { video, buffer: video.buffer }; +} + describe("openrouter video generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -77,12 +117,13 @@ describe("openrouter video generation provider", () => { expectExplicitVideoGenerationCapabilities(provider); expect(provider.id).toBe("openrouter"); expect(provider.defaultModel).toBe("google/veo-3.1-fast"); - expect(provider.capabilities.generate?.supportsAudio).toBe(true); - expect(provider.capabilities.generate?.supportedDurationSeconds).toEqual([4, 6, 8]); - expect(provider.capabilities.generate?.resolutions).toEqual(["720P", "1080P"]); - expect(provider.capabilities.generate?.aspectRatios).toEqual(["16:9", "9:16"]); - expect(provider.capabilities.imageToVideo?.enabled).toBe(true); - expect(provider.capabilities.videoToVideo?.enabled).toBe(false); + const generateCapabilities = requireGenerateCapabilities(provider); + expect(generateCapabilities.supportsAudio).toBe(true); + expect(generateCapabilities.supportedDurationSeconds).toEqual([4, 6, 8]); + expect(generateCapabilities.resolutions).toEqual(["720P", "1080P"]); + expect(generateCapabilities.aspectRatios).toEqual(["16:9", "9:16"]); + expect(provider.capabilities.imageToVideo).toMatchObject({ enabled: true }); + expect(provider.capabilities.videoToVideo).toMatchObject({ enabled: false }); }); it("submits OpenRouter video jobs, polls completion, and downloads the result", async () => { @@ -204,11 +245,7 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-status" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[0]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBe("Bearer openrouter-key"); + expect(requireFetchCallHeaders(0).get("authorization")).toBe("Bearer openrouter-key"); expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( 2, "https://custom.openrouter.test/api/v1/videos/job-123/content?index=0", @@ -217,13 +254,10 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[1]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBe("Bearer openrouter-key"); - expect(result.videos[0]?.buffer?.toString()).toBe("mp4-bytes"); - expect(result.videos[0]?.mimeType).toBe("video/mp4"); + expect(requireFetchCallHeaders(1).get("authorization")).toBe("Bearer openrouter-key"); + const { video, buffer } = requireGeneratedVideoBuffer(result, 0); + expect(buffer.toString()).toBe("mp4-bytes"); + expect(video.mimeType).toBe("video/mp4"); expect(result.metadata).toEqual({ jobId: "job-123", status: "completed", @@ -266,11 +300,7 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-status" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[0]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBeNull(); + expect(requireFetchCallHeaders(0).get("authorization")).toBeNull(); expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( 2, "https://cdn.openrouter.test/video.mp4", @@ -279,11 +309,7 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - expect( - (fetchWithTimeoutGuardedMock.mock.calls[1]?.[1]?.headers as Headers | undefined)?.get( - "authorization", - ), - ).toBeNull(); + expect(requireFetchCallHeaders(1).get("authorization")).toBeNull(); }); it("falls back to the documented content endpoint when a completed job has no output URL", async () => { @@ -313,8 +339,9 @@ describe("openrouter video generation provider", () => { expect.any(Function), expect.objectContaining({ auditContext: "openrouter-video-download" }), ); - expect(result.videos[0]?.buffer?.toString()).toBe("webm-bytes"); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const { video, buffer } = requireGeneratedVideoBuffer(result, 0); + expect(buffer.toString()).toBe("webm-bytes"); + expect(video.fileName).toBe("video-1.webm"); }); it("rejects video reference inputs", async () => { diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 7164fa12c7b..19787a4a192 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -21,6 +21,14 @@ function installQaChannelTestRegistry() { ); } +function expectDispatchedContext(ctx: Record | null): Record { + expect(ctx).toEqual(expect.any(Object)); + if (ctx === null) { + throw new Error("Expected dispatched context"); + } + return ctx; +} + function createMockQaRuntime(params?: { onDispatch?: (ctx: Record) => void; }): PluginRuntime { @@ -382,16 +390,12 @@ describe("qa-channel plugin", () => { timeoutMs: 15_000, }); - expect(dispatchedCtx).not.toBeNull(); - if (!dispatchedCtx) { - throw new Error("expected dispatched context"); - } - const mediaCtx: { + const mediaCtx = expectDispatchedContext(dispatchedCtx) as { MediaPath?: string; MediaPaths?: string[]; MediaType?: string; MediaTypes?: string[]; - } = dispatchedCtx; + }; expect(mediaCtx.MediaPath).toEqual(expect.stringContaining("red-top-blue-bottom")); expect(mediaCtx.MediaType).toBe("image/png"); expect(mediaCtx.MediaPaths).toEqual([mediaCtx.MediaPath]); diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index 1a1af099647..45ee7b56a71 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -8,6 +8,7 @@ const { runQaSuiteFromRuntime, runQaCharacterEval, runQaMultipass, + listTelegramQaScenarioCatalog, runTelegramQaLive, startQaLabServer, writeQaDockerHarnessFiles, @@ -19,6 +20,7 @@ const { runQaSuiteFromRuntime: vi.fn(), runQaCharacterEval: vi.fn(), runQaMultipass: vi.fn(), + listTelegramQaScenarioCatalog: vi.fn(), runTelegramQaLive: vi.fn(), startQaLabServer: vi.fn(), writeQaDockerHarnessFiles: vi.fn(), @@ -45,6 +47,7 @@ vi.mock("./multipass.runtime.js", () => ({ })); vi.mock("./live-transports/telegram/telegram-live.runtime.js", () => ({ + listTelegramQaScenarioCatalog, runTelegramQaLive, })); @@ -111,6 +114,7 @@ describe("qa cli runtime", () => { runQaCharacterEval.mockReset(); runQaManualLane.mockReset(); runQaMultipass.mockReset(); + listTelegramQaScenarioCatalog.mockReset(); runTelegramQaLive.mockReset(); startQaLabServer.mockReset(); writeQaDockerHarnessFiles.mockReset(); @@ -153,6 +157,15 @@ describe("qa cli runtime", () => { observedMessagesPath: "/tmp/telegram/observed.json", scenarios: [], }); + listTelegramQaScenarioCatalog.mockReturnValue([ + { + id: "telegram-status-command", + title: "Telegram status command reply", + defaultEnabled: true, + rationale: "status rationale", + regressionRefs: ["openclaw/openclaw#74698"], + }, + ]); startQaLabServer.mockResolvedValue({ baseUrl: "http://127.0.0.1:58000", runSelfCheck: vi.fn().mockResolvedValue({ @@ -297,6 +310,22 @@ describe("qa cli runtime", () => { ); }); + it("prints telegram scenario catalog without starting the live lane", async () => { + await runQaTelegramCommand({ + repoRoot: "/tmp/openclaw-repo", + providerMode: "mock-openai", + listScenarios: true, + }); + + expect(listTelegramQaScenarioCatalog).toHaveBeenCalledWith("mock-openai"); + expect(runTelegramQaLive).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining( + "telegram-status-command\tdefault\tTelegram status command reply\tstatus rationale refs=openclaw/openclaw#74698", + ), + ); + }); + it("sets a failing exit code when telegram scenarios fail", async () => { const priorExitCode = process.exitCode; process.exitCode = undefined; diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts index 760f803d55b..a28dafee5f0 100644 --- a/extensions/qa-lab/src/cli.test.ts +++ b/extensions/qa-lab/src/cli.test.ts @@ -384,7 +384,7 @@ describe("qa cli registration", () => { const optionNames = telegram?.options.map((option) => option.long) ?? []; expect(optionNames).toEqual( - expect.arrayContaining(["--credential-source", "--credential-role"]), + expect.arrayContaining(["--credential-source", "--credential-role", "--list-scenarios"]), ); }); @@ -434,12 +434,23 @@ describe("qa cli registration", () => { fastMode: false, allowFailures: false, scenarioIds: [], + listScenarios: false, sutAccountId: "sut", credentialSource: undefined, credentialRole: undefined, }); }); + it("forwards --list-scenarios for telegram runs", async () => { + await program.parseAsync(["node", "openclaw", "qa", "telegram", "--list-scenarios"]); + + expect(runQaTelegramCommand).toHaveBeenCalledWith( + expect.objectContaining({ + listScenarios: true, + }), + ); + }); + it("forwards --allow-failures for telegram runs", async () => { await program.parseAsync(["node", "openclaw", "qa", "telegram", "--allow-failures"]); diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index b984e088621..8498fa9d9fc 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -590,7 +590,7 @@ describe("buildQaRuntimeEnv", () => { expect(processKill).toHaveBeenCalledWith(-12345, "SIGTERM"); expect(processKill).toHaveBeenCalledWith(-12345, "SIGKILL"); } - expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect([child.exitCode, child.signalCode]).not.toEqual([null, null]); }); it("treats bind collisions as retryable gateway startup errors", () => { @@ -972,10 +972,6 @@ describe("qa bundled plugin dir", () => { expect(stagedRoot).toBe( path.join(repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot)), ); - expect(stagedRoot).not.toBeNull(); - if (!stagedRoot) { - throw new Error("expected staged runtime root"); - } await expect(readFile(path.join(stagedRoot, "package.json"), "utf8")).resolves.toContain( '"name": "openclaw"', ); @@ -999,7 +995,11 @@ describe("qa bundled plugin dir", () => { "shared-chunk-abc123.js", ), ); - expect(sharedChunkStat.isFile() || sharedChunkStat.isSymbolicLink()).toBe(true); + if (sharedChunkStat.isFile()) { + expect(sharedChunkStat.isFile()).toBe(true); + } else { + expect(sharedChunkStat.isSymbolicLink()).toBe(true); + } }); it("preserves dist-runtime-only root chunks when dist also exists", async () => { @@ -1074,7 +1074,11 @@ describe("qa bundled plugin dir", () => { "runtime-chunk.js", ), ); - expect(runtimeChunkStat.isFile() || runtimeChunkStat.isSymbolicLink()).toBe(true); + if (runtimeChunkStat.isFile()) { + expect(runtimeChunkStat.isFile()).toBe(true); + } else { + expect(runtimeChunkStat.isSymbolicLink()).toBe(true); + } }); it("rejects invalid bundled plugin ids before staging paths are built", async () => { diff --git a/extensions/qa-lab/src/gateway-rpc-client.test.ts b/extensions/qa-lab/src/gateway-rpc-client.test.ts index 1ac7bb4b8e2..027140d995b 100644 --- a/extensions/qa-lab/src/gateway-rpc-client.test.ts +++ b/extensions/qa-lab/src/gateway-rpc-client.test.ts @@ -16,6 +16,22 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({ import { startQaGatewayRpcClient } from "./gateway-rpc-client.js"; +function expectRequestResolver( + callback: ((value: { ok: boolean }) => void) | null, +): (value: { ok: boolean }) => void { + if (callback === null) { + throw new Error("Expected first request resolver callback to be captured"); + } + return callback; +} + +function expectReleaseCallback(callback: (() => void) | null): () => void { + if (callback === null) { + throw new Error("Expected first request release callback to be captured"); + } + return callback; +} + describe("startQaGatewayRpcClient", () => { beforeEach(() => { gatewayRpcMock.reset(); @@ -145,8 +161,7 @@ describe("startQaGatewayRpcClient", () => { }, ); - expect(resolveFirst).not.toBeNull(); - resolveFirst!({ ok: true }); + expectRequestResolver(resolveFirst)({ ok: true }); await expect(firstRequest).resolves.toEqual({ ok: true }); }); @@ -174,8 +189,7 @@ describe("startQaGatewayRpcClient", () => { expect(gatewayRpcMock.callGatewayFromCli).toHaveBeenCalledTimes(1); - expect(releaseFirst).not.toBeNull(); - releaseFirst!(); + expectReleaseCallback(releaseFirst)(); await expect(firstRequest).resolves.toEqual({ ok: true }); await expect(secondRequest).resolves.toEqual({ ok: true }); diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index c6a44ad1d82..e46adbdf335 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -35,6 +35,15 @@ const captureMock = vi.hoisted(() => { return acc; }, {}), ).map(([value, count]) => ({ value, count })); + const countMatching = (values: T[], predicate: (value: T) => boolean) => { + let count = 0; + for (const value of values) { + if (predicate(value)) { + count += 1; + } + } + return count; + }; const store = { upsertSession(session: Record) { @@ -46,7 +55,7 @@ const captureMock = vi.hoisted(() => { listSessions(limit: number) { return sessions.slice(0, limit).map((session) => Object.assign({}, session, { - eventCount: events.filter((event) => event.sessionId === session.id).length, + eventCount: countMatching(events, (event) => event.sessionId === session.id), }), ); }, @@ -59,7 +68,7 @@ const captureMock = vi.hoisted(() => { return { sessionId, totalEvents: selected.length, - unlabeledEventCount: metas.filter((meta) => !meta.provider && !meta.model).length, + unlabeledEventCount: countMatching(metas, (meta) => !meta.provider && !meta.model), providers: countValues(metas.map((meta) => meta.provider as string | undefined)), apis: countValues(metas.map((meta) => meta.api as string | undefined)), models: countValues(metas.map((meta) => meta.model as string | undefined)), @@ -673,7 +682,7 @@ describe("qa-lab server", () => { const snapshot = (await (await fetchWithRetry(`${lab.baseUrl}/api/state`)).json()) as { messages: Array<{ direction: string }>; }; - expect(snapshot.messages.filter((message) => message.direction === "outbound")).toHaveLength(0); + expect(snapshot.messages.some((message) => message.direction === "outbound")).toBe(false); }); it("exposes structured outcomes and can attach control-ui after startup", async () => { diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts index f2bcf0fd072..16a65c2480e 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.test.ts @@ -10,12 +10,14 @@ describe("resolveLiveTransportQaRunOptions", () => { providerMode: "live-frontier", primaryModel: " ", alternateModel: "", + listScenarios: true, }), ).toMatchObject({ repoRoot: path.resolve("/tmp/openclaw-repo"), providerMode: "live-frontier", primaryModel: undefined, alternateModel: undefined, + listScenarios: true, }); }); }); diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts index ed8eed64e73..348f33119b8 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.runtime.ts @@ -31,6 +31,7 @@ export function resolveLiveTransportQaRunOptions( fastMode: opts.fastMode, allowFailures: opts.allowFailures, scenarioIds: opts.scenarioIds, + listScenarios: opts.listScenarios, sutAccountId: opts.sutAccountId, credentialSource: opts.credentialSource?.trim(), credentialRole: opts.credentialRole?.trim(), diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts index a0d9737dcdc..bdde85766f6 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts @@ -12,6 +12,7 @@ export type LiveTransportQaCommandOptions = { fastMode?: boolean; allowFailures?: boolean; scenarioIds?: string[]; + listScenarios?: boolean; sutAccountId?: string; credentialSource?: string; credentialRole?: string; @@ -24,6 +25,7 @@ type LiveTransportQaCommanderOptions = { model?: string; altModel?: string; scenario?: string[]; + listScenarios?: boolean; fast?: boolean; allowFailures?: boolean; sutAccount?: string; @@ -61,6 +63,7 @@ function mapLiveTransportQaCommanderOptions( fastMode: opts.fast, allowFailures: opts.allowFailures, scenarioIds: opts.scenario, + listScenarios: opts.listScenarios, sutAccountId: opts.sutAccount, credentialSource: opts.credentialSource, credentialRole: opts.credentialRole, @@ -72,6 +75,7 @@ function registerLiveTransportQaCli(params: { commandName: string; credentialOptions?: LiveTransportQaCredentialCliOptions; description: string; + listScenariosHelp?: string; outputDirHelp: string; scenarioHelp: string; sutAccountHelp: string; @@ -94,6 +98,10 @@ function registerLiveTransportQaCli(params: { ) .option("--sut-account ", params.sutAccountHelp, "sut"); + if (params.listScenariosHelp) { + command.option("--list-scenarios", params.listScenariosHelp, false); + } + if (params.credentialOptions) { command.option( "--credential-source ", @@ -114,6 +122,7 @@ export function createLiveTransportQaCliRegistration(params: { commandName: string; credentialOptions?: LiveTransportQaCredentialCliOptions; description: string; + listScenariosHelp?: string; outputDirHelp: string; scenarioHelp: string; sutAccountHelp: string; @@ -127,6 +136,7 @@ export function createLiveTransportQaCliRegistration(params: { commandName: params.commandName, credentialOptions: params.credentialOptions, description: params.description, + listScenariosHelp: params.listScenariosHelp, outputDirHelp: params.outputDirHelp, scenarioHelp: params.scenarioHelp, sutAccountHelp: params.sutAccountHelp, diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts index c07e4ea8d04..a18f9d3a18b 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.runtime.ts @@ -3,10 +3,21 @@ import { printLiveTransportQaArtifacts, resolveLiveTransportQaRunOptions, } from "../shared/live-transport-cli.runtime.js"; -import { runTelegramQaLive } from "./telegram-live.runtime.js"; +import { listTelegramQaScenarioCatalog, runTelegramQaLive } from "./telegram-live.runtime.js"; export async function runQaTelegramCommand(opts: LiveTransportQaCommandOptions) { const runOptions = resolveLiveTransportQaRunOptions(opts); + if (runOptions.listScenarios) { + for (const scenario of listTelegramQaScenarioCatalog(runOptions.providerMode)) { + const defaultLabel = scenario.defaultEnabled ? "default" : "optional"; + const refs = + scenario.regressionRefs.length > 0 ? ` refs=${scenario.regressionRefs.join(",")}` : ""; + process.stdout.write( + `${scenario.id}\t${defaultLabel}\t${scenario.title}\t${scenario.rationale}${refs}\n`, + ); + } + return; + } const result = await runTelegramQaLive(runOptions); printLiveTransportQaArtifacts("Telegram QA", { report: result.reportPath, diff --git a/extensions/qa-lab/src/live-transports/telegram/cli.ts b/extensions/qa-lab/src/live-transports/telegram/cli.ts index b0f2c0de177..d7638a25945 100644 --- a/extensions/qa-lab/src/live-transports/telegram/cli.ts +++ b/extensions/qa-lab/src/live-transports/telegram/cli.ts @@ -25,6 +25,7 @@ export const telegramQaCliRegistration: LiveTransportQaCliRegistration = "Credential role for convex auth: maintainer or ci (default: ci in CI, maintainer otherwise)", }, description: "Run the manual Telegram live QA lane against a private bot-to-bot group harness", + listScenariosHelp: "Print available Telegram scenario ids and exit", outputDirHelp: "Telegram QA artifact directory", scenarioHelp: "Run only the named Telegram QA scenario (repeatable)", sutAccountHelp: "Temporary Telegram account id inside the QA gateway config", diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index af531c34560..c2a01327962 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -330,9 +330,12 @@ describe("telegram live qa runtime", () => { "telegram-commands-command", "telegram-tools-compact-command", "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", "telegram-context-command", "telegram-current-session-status-tool", "telegram-mentioned-message-reply", + "telegram-reply-chain-exact-marker", "telegram-stream-final-single-message", "telegram-long-final-reuses-preview", "telegram-long-final-three-chunks", @@ -343,24 +346,55 @@ describe("telegram live qa runtime", () => { "telegram-commands-command", "telegram-tools-compact-command", "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", "telegram-context-command", "telegram-current-session-status-tool", "telegram-mentioned-message-reply", + "telegram-reply-chain-exact-marker", "telegram-stream-final-single-message", "telegram-long-final-reuses-preview", "telegram-long-final-three-chunks", "telegram-mention-gating", ]); + expect( + scenarios.find((scenario) => scenario.id === "telegram-status-command")?.buildRun("sut_bot") + .input, + ).toBe("/status@sut_bot"); + expect( + scenarios.find((scenario) => scenario.id === "telegram-status-command")?.buildRun("sut_bot") + .expectedTextIncludes, + ).toEqual(["OpenClaw", "Model:", "Session:", "Activation:"]); + expect( + scenarios + .find((scenario) => scenario.id === "telegram-other-bot-command-gating") + ?.buildRun("sut_bot"), + ).toMatchObject({ + expectReply: false, + input: "/status@OpenClawQaOtherBot", + }); expect( scenarios .find((scenario) => scenario.id === "telegram-current-session-status-tool") - ?.buildRun("sut_bot").expectedTextIncludes, - ).toEqual(["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"]); + ?.buildRun("sut_bot"), + ).toMatchObject({ + expectedTextIncludes: ["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"], + replyToLatestSutMessage: true, + }); expect( scenarios .find((scenario) => scenario.id === "telegram-mentioned-message-reply") ?.buildRun("sut_bot").replyToLatestSutMessage, ).toBe(true); + expect( + scenarios + .find((scenario) => scenario.id === "telegram-reply-chain-exact-marker") + ?.buildRun("sut_bot"), + ).toMatchObject({ + expectedJoinedSutTextIncludes: ["QA-TELEGRAM-REPLY-CHAIN-OK"], + expectedSutMessageCount: 1, + replyToLatestSutMessage: true, + }); expect( scenarios .find((scenario) => scenario.id === "telegram-stream-final-single-message") @@ -393,17 +427,60 @@ describe("telegram live qa runtime", () => { }); }); - it("keeps bot-to-bot plain mentions out of the default Telegram live set", () => { - expect(__testing.findScenario().map((scenario) => scenario.id)).toEqual([ + it("keeps mock-scripted Telegram checks out of the default live-frontier set", () => { + expect( + __testing.findScenario(undefined, "live-frontier").map((scenario) => scenario.id), + ).toEqual([ "telegram-help-command", "telegram-commands-command", "telegram-tools-compact-command", "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", "telegram-context-command", + "telegram-mentioned-message-reply", "telegram-mention-gating", ]); }); + it("adds deterministic model-scripted checks to the default mock-openai set", () => { + expect(__testing.findScenario(undefined, "mock-openai").map((scenario) => scenario.id)).toEqual( + [ + "telegram-help-command", + "telegram-commands-command", + "telegram-tools-compact-command", + "telegram-whoami-command", + "telegram-status-command", + "telegram-other-bot-command-gating", + "telegram-context-command", + "telegram-mentioned-message-reply", + "telegram-reply-chain-exact-marker", + "telegram-stream-final-single-message", + "telegram-long-final-reuses-preview", + "telegram-mention-gating", + ], + ); + }); + + it("lists default status and regression refs in the Telegram scenario catalog", () => { + const catalog = __testing.listTelegramQaScenarioCatalog("mock-openai"); + expect(catalog.find((scenario) => scenario.id === "telegram-status-command")).toMatchObject({ + defaultEnabled: true, + regressionRefs: ["openclaw/openclaw#74698"], + }); + expect( + catalog.find((scenario) => scenario.id === "telegram-current-session-status-tool"), + ).toMatchObject({ + defaultEnabled: false, + }); + expect( + catalog.find((scenario) => scenario.id === "telegram-stream-final-single-message"), + ).toMatchObject({ + defaultEnabled: true, + regressionRefs: ["openclaw/openclaw#39905"], + }); + }); + it("tracks Telegram live coverage against the shared transport contract", () => { expect(__testing.TELEGRAM_QA_STANDARD_SCENARIO_IDS).toEqual([ "canary", @@ -419,7 +496,7 @@ describe("telegram live qa runtime", () => { }); it("asserts long Telegram final replies reuse the streamed preview message", () => { - expect(() => + expect( __testing.assertTelegramScenarioMessageSet({ expectedJoinedSutTextIncludes: ["TELEGRAM-LONG-FINAL-BEGIN", "TELEGRAM-LONG-FINAL-END"], expectedSutMessageCount: 2, @@ -457,7 +534,7 @@ describe("telegram live qa runtime", () => { }, ], }), - ).not.toThrow(); + ).toBeUndefined(); expect(() => __testing.assertTelegramScenarioMessageSet({ @@ -514,7 +591,7 @@ describe("telegram live qa runtime", () => { }); it("accepts legitimate three-chunk Telegram final replies", () => { - expect(() => + expect( __testing.assertTelegramScenarioMessageSet({ expectedJoinedSutTextIncludes: [ "TELEGRAM-LONG-FINAL-3CHUNK-BEGIN", @@ -569,7 +646,7 @@ describe("telegram live qa runtime", () => { }, ], }), - ).not.toThrow(); + ).toBeUndefined(); }); it("matches scenario replies by thread or exact marker", () => { @@ -639,7 +716,7 @@ describe("telegram live qa runtime", () => { }); it("validates expected Telegram reply markers", () => { - expect(() => + expect( __testing.assertTelegramScenarioReply({ expectedTextIncludes: ["🧭 Identity", "Channel: telegram"], message: { @@ -656,7 +733,7 @@ describe("telegram live qa runtime", () => { mediaKinds: [], }, }), - ).not.toThrow(); + ).toBeUndefined(); expect(() => __testing.assertTelegramScenarioReply({ expectedTextIncludes: ["Use /tools verbose for descriptions."], diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index 8e17e1e36b9..93d10769d61 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -13,6 +13,7 @@ import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js"; import { defaultQaModelForMode, normalizeQaProviderMode, + type QaProviderMode, type QaProviderModeInput, } from "../../run-config.js"; import { @@ -46,11 +47,14 @@ type TelegramQaScenarioId = | "telegram-commands-command" | "telegram-tools-compact-command" | "telegram-whoami-command" + | "telegram-status-command" + | "telegram-other-bot-command-gating" | "telegram-context-command" | "telegram-current-session-status-tool" | "telegram-stream-final-single-message" | "telegram-long-final-three-chunks" | "telegram-long-final-reuses-preview" + | "telegram-reply-chain-exact-marker" | "telegram-mentioned-message-reply" | "telegram-mention-gating"; @@ -69,6 +73,9 @@ type TelegramQaScenarioRun = { type TelegramQaScenarioDefinition = LiveTransportScenarioDefinition & { buildRun: (sutUsername: string) => TelegramQaScenarioRun; defaultEnabled?: boolean; + defaultProviderModes?: readonly QaProviderMode[]; + regressionRefs?: readonly string[]; + rationale: string; }; type TelegramObservedMessage = { @@ -231,6 +238,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-help-command", standardId: "help-command", title: "Telegram help command reply", + rationale: "Canary-grade native command reply path.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -241,6 +249,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-commands-command", title: "Telegram commands list reply", + rationale: "Native command catalog must render in Telegram group replies.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -251,6 +260,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-tools-compact-command", title: "Telegram tools compact reply", + rationale: "Tool catalog rendering catches command dispatch plus model-tool inventory drift.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -261,6 +271,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-whoami-command", title: "Telegram whoami reply", + rationale: "Identity command proves Telegram channel context is attached to native commands.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -268,9 +279,32 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ expectedTextIncludes: ["🧭 Identity", "Channel: telegram"], }), }, + { + id: "telegram-status-command", + title: "Telegram status command reply", + rationale: "Recent Telegram group regressions broke /status while normal chat still worked.", + regressionRefs: ["openclaw/openclaw#74698"], + timeoutMs: 45_000, + buildRun: (sutUsername) => ({ + expectReply: true, + input: `/status@${sutUsername}`, + expectedTextIncludes: ["OpenClaw", "Model:", "Session:", "Activation:"], + }), + }, + { + id: "telegram-other-bot-command-gating", + title: "Telegram command addressed to another bot is ignored", + rationale: "Bot-to-bot groups must not let commands addressed to another bot wake the SUT.", + timeoutMs: 8_000, + buildRun: () => ({ + expectReply: false, + input: "/status@OpenClawQaOtherBot", + }), + }, { id: "telegram-context-command", title: "Telegram context reply", + rationale: "Context command exercises native command routing into Telegram-specific help text.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ expectReply: true, @@ -282,29 +316,49 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-current-session-status-tool", title: "Telegram current session_status tool call", defaultEnabled: false, + rationale: + "Opt-in threaded probe for current Telegram group session resolution through model tools.", timeoutMs: 60_000, buildRun: (sutUsername) => ({ expectReply: true, input: `@${sutUsername} Telegram current session_status QA check. Call session_status with sessionKey set to current, then reply with the exact QA marker and resolved session key.`, expectedTextIncludes: ["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"], + replyToLatestSutMessage: true, }), }, { id: "telegram-mentioned-message-reply", title: "Telegram mentioned message gets a reply", - defaultEnabled: false, + rationale: "Bot-to-bot group mention routing must produce a threaded SUT reply.", timeoutMs: 45_000, buildRun: (sutUsername) => ({ - allowAnySutReply: true, expectReply: true, input: `@${sutUsername} Telegram QA mention routing check. Reply with a short acknowledgement.`, replyToLatestSutMessage: true, }), }, + { + id: "telegram-reply-chain-exact-marker", + title: "Telegram reply-chain exact marker", + defaultProviderModes: ["mock-openai"], + rationale: "Mock-backed reply-chain check proves quoted bot-to-bot follow-ups keep threading.", + timeoutMs: 45_000, + buildRun: (sutUsername) => ({ + expectReply: true, + input: `@${sutUsername} Telegram reply-chain marker QA. Reply exactly: QA-TELEGRAM-REPLY-CHAIN-OK`, + expectedTextIncludes: ["QA-TELEGRAM-REPLY-CHAIN-OK"], + expectedJoinedSutTextIncludes: ["QA-TELEGRAM-REPLY-CHAIN-OK"], + expectedSutMessageCount: 1, + replyToLatestSutMessage: true, + settleMs: 4_000, + }), + }, { id: "telegram-stream-final-single-message", title: "Telegram streamed final stays one message", - defaultEnabled: false, + defaultProviderModes: ["mock-openai"], + rationale: "Regression guard for duplicate final replies from Telegram streaming paths.", + regressionRefs: ["openclaw/openclaw#39905"], timeoutMs: 45_000, buildRun: (sutUsername) => ({ allowAnySutReply: true, @@ -320,7 +374,9 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ { id: "telegram-long-final-reuses-preview", title: "Telegram long final reuses the preview message", - defaultEnabled: false, + defaultProviderModes: ["mock-openai"], + rationale: "Regression guard for long streamed finals leaving stale preview messages behind.", + regressionRefs: ["openclaw/openclaw#39905"], timeoutMs: 60_000, buildRun: (sutUsername) => ({ allowAnySutReply: true, @@ -337,6 +393,8 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-long-final-three-chunks", title: "Telegram three-chunk final keeps only final chunks", defaultEnabled: false, + rationale: "Opt-in stress probe for Telegram long final chunk accounting.", + regressionRefs: ["openclaw/openclaw#39905"], timeoutMs: 60_000, buildRun: (sutUsername) => ({ allowAnySutReply: true, @@ -356,6 +414,7 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ id: "telegram-mention-gating", standardId: "mention-gating", title: "Telegram group message without mention does not trigger", + rationale: "Required group mention gate should suppress ordinary group chatter.", timeoutMs: 8_000, buildRun: () => { const token = `TELEGRAM_QA_NOMENTION_${randomUUID().slice(0, 8).toUpperCase()}`; @@ -1020,11 +1079,26 @@ function buildObservedMessagesArtifact(params: { }); } -function findScenario(ids?: string[]) { +function shouldRunTelegramScenarioByDefault( + scenario: TelegramQaScenarioDefinition, + providerMode: QaProviderMode, +) { + if (scenario.defaultEnabled === false) { + return false; + } + return !scenario.defaultProviderModes || scenario.defaultProviderModes.includes(providerMode); +} + +function findScenario( + ids?: string[], + providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE, +) { const scenarios = ids && ids.length > 0 ? TELEGRAM_QA_SCENARIOS - : TELEGRAM_QA_SCENARIOS.filter((scenario) => scenario.defaultEnabled !== false); + : TELEGRAM_QA_SCENARIOS.filter((scenario) => + shouldRunTelegramScenarioByDefault(scenario, providerMode), + ); return selectLiveTransportScenarios({ ids, laneLabel: "Telegram", @@ -1032,6 +1106,18 @@ function findScenario(ids?: string[]) { }); } +export function listTelegramQaScenarioCatalog( + providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE, +) { + return TELEGRAM_QA_SCENARIOS.map((scenario) => ({ + id: scenario.id, + title: scenario.title, + defaultEnabled: shouldRunTelegramScenarioByDefault(scenario, providerMode), + rationale: scenario.rationale, + regressionRefs: [...(scenario.regressionRefs ?? [])], + })); +} + function matchesTelegramScenarioReply(params: { groupId: string; allowAnySutReply?: boolean; @@ -1340,7 +1426,7 @@ export async function runTelegramQaLive(params: { const primaryModel = params.primaryModel?.trim() || defaultQaModelForMode(providerMode); const alternateModel = params.alternateModel?.trim() || defaultQaModelForMode(providerMode, true); const sutAccountId = params.sutAccountId?.trim() || "sut"; - const scenarios = findScenario(params.scenarioIds); + const scenarios = findScenario(params.scenarioIds, providerMode); const progressEnabled = shouldLogTelegramQaLiveProgress(); writeTelegramQaProgress( progressEnabled, @@ -1754,6 +1840,7 @@ export const __testing = { assertTelegramScenarioReply, classifyCanaryReply, findScenario, + listTelegramQaScenarioCatalog, matchesTelegramScenarioReply, normalizeTelegramObservedMessage, parseTelegramQaProgressBooleanEnv, diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts index feceed1e25d..81aac13e491 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts @@ -102,7 +102,8 @@ describe("mantis visual task runtime", () => { ]), ); expect(stagedVideoPath).not.toBe(finalVideoPath); - expect(path.basename(stagedVideoPath ?? "")).toBe(path.basename(finalVideoPath)); + expect(path.basename(stagedVideoPath ?? "")).toContain(path.basename(finalVideoPath)); + expect(path.basename(stagedVideoPath ?? "")).toMatch(/\.part$/); await expect(fs.stat(stagedVideoPath ?? "")).rejects.toThrow(); await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png"); await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4"); diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 26c3b06e2e5..c1a699a90c2 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -1853,6 +1853,99 @@ describe("qa mock openai server", () => { }); }); + it("lets the latest exact marker prompt beat stale Telegram session_status history", async () => { + const server = await startQaMockOpenAiServer({ + host: "127.0.0.1", + port: 0, + }); + cleanups.push(async () => { + await server.stop(); + }); + + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: false, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "Telegram current session_status QA check. Call session_status with sessionKey set to current.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: "Telegram reply-chain marker QA. Reply exactly: QA-TELEGRAM-REPLY-CHAIN-OK", + }, + ], + }, + ], + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + output: [ + { + content: [{ text: "QA-TELEGRAM-REPLY-CHAIN-OK" }], + }, + ], + }); + }); + + it("does not repeat stale Telegram session_status for later ordinary prompts", async () => { + const server = await startQaMockOpenAiServer({ + host: "127.0.0.1", + port: 0, + }); + cleanups.push(async () => { + await server.stop(); + }); + + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: false, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "Telegram current session_status QA check. Call session_status with sessionKey set to current.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: "@sut Telegram QA mention routing check. Reply with a short acknowledgement.", + }, + ], + }, + ], + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(JSON.stringify(payload)).not.toContain("QA-TELEGRAM-CURRENT-SESSION"); + }); + it("uses exact marker directives from request context when the latest user text is generic", async () => { const server = await startQaMockOpenAiServer({ host: "127.0.0.1", diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index c775b3127c0..c27bffdc0c2 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -1421,7 +1421,16 @@ async function buildResponsesPayload( exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-FALLBACK-OK", ); } - if (QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(allInputText)) { + if (/\bmarker\b/i.test(prompt) && exactReplyDirective) { + return buildAssistantEvents(exactReplyDirective); + } + if (/\bmarker\b/i.test(prompt) && exactMarkerDirective) { + return buildAssistantEvents(exactMarkerDirective); + } + const isTelegramCurrentSessionStatusTurn = + QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(prompt) || + (Boolean(toolOutput) && QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(allInputText)); + if (isTelegramCurrentSessionStatusTurn) { if (!toolOutput && hasDeclaredTool(body, "session_status")) { return buildToolCallEventsWithArgs("session_status", { sessionKey: "current" }); } diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts index 8f13bd06303..e56e5146a27 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts @@ -16,6 +16,14 @@ function jsonResponse(payload: unknown, status = 200) { }); } +function requireFirstFetchInput(fetchImpl: ReturnType): RequestInfo | URL { + const input = fetchImpl.mock.calls[0]?.[0] as RequestInfo | URL | undefined; + if (!input) { + throw new Error("expected fetch input"); + } + return input; +} + describe("qa credential admin runtime", () => { it("adds a credential set through the admin endpoint", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => @@ -112,7 +120,9 @@ describe("qa credential admin runtime", () => { fetchImpl, }); - expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://127.0.0.1:3210/qa-credentials/v1/admin/list"); + expect(requireFirstFetchInput(fetchImpl)).toBe( + "http://127.0.0.1:3210/qa-credentials/v1/admin/list", + ); }); it("rejects unsafe endpoint-prefix overrides", async () => { diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index 0f4cdace6b7..1ffcc031f4c 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -938,8 +938,8 @@ describe("matrix live qa scenarios", () => { const topology = scenarioTesting.buildMatrixQaTopologyForScenarios({ defaultRoomName: "OpenClaw Matrix QA run", scenarios: [ - MATRIX_QA_SCENARIOS.find((scenario) => scenario.id === "matrix-e2ee-basic-reply")!, - MATRIX_QA_SCENARIOS.find((scenario) => scenario.id === "matrix-e2ee-thread-follow-up")!, + requireMatrixQaScenario("matrix-e2ee-basic-reply"), + requireMatrixQaScenario("matrix-e2ee-thread-follow-up"), ], }); diff --git a/extensions/qa-matrix/src/substrate/harness.runtime.test.ts b/extensions/qa-matrix/src/substrate/harness.runtime.test.ts index 39873474285..e25546ca8c9 100644 --- a/extensions/qa-matrix/src/substrate/harness.runtime.test.ts +++ b/extensions/qa-matrix/src/substrate/harness.runtime.test.ts @@ -45,6 +45,16 @@ function createContainerNetworkRunCommand(calls?: string[]) { }; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("matrix harness runtime", () => { it("writes a pinned Tuwunel compose file and redacted manifest", async () => { const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-")); @@ -180,7 +190,7 @@ describe("matrix harness runtime", () => { return { ok: input === "http://127.0.0.1:28008/_matrix/client/versions" && - fetchCalls.filter((url) => url === input).length > 1, + countMatching(fetchCalls, (url) => url === input) > 1, }; }), sleepImpl: vi.fn(async () => {}), @@ -208,7 +218,7 @@ describe("matrix harness runtime", () => { return { ok: input === "http://172.18.0.10:8008/_matrix/client/versions" && - fetchCalls.filter((url) => url === input).length > 1, + countMatching(fetchCalls, (url) => url === input) > 1, }; }), sleepImpl: vi.fn(async () => {}), diff --git a/extensions/qianfan/index.test.ts b/extensions/qianfan/index.test.ts index a25d323efe6..db793f3d58f 100644 --- a/extensions/qianfan/index.test.ts +++ b/extensions/qianfan/index.test.ts @@ -12,6 +12,13 @@ import { QIANFAN_DEFAULT_MODEL_REF, } from "./onboard.js"; +function expectRecord(value: T | undefined, label: string): T { + if (!value) { + throw new Error(`Expected ${label}`); + } + return value; +} + describe("qianfan provider plugin", () => { it("registers Qianfan with api-key auth wizard metadata", async () => { const provider = await registerSingleProviderPlugin(qianfanPlugin); @@ -25,9 +32,10 @@ describe("qianfan provider plugin", () => { expect(provider.docsPath).toBe("/providers/qianfan"); expect(provider.envVars).toEqual(["QIANFAN_API_KEY"]); expect(provider.auth).toHaveLength(1); - expect(resolved).not.toBeNull(); - expect(resolved?.provider.id).toBe("qianfan"); - expect(resolved?.method.id).toBe("api-key"); + expect(resolved).toMatchObject({ + provider: { id: "qianfan" }, + method: { id: "api-key" }, + }); }); it("builds the static Qianfan model catalog", async () => { @@ -36,11 +44,17 @@ describe("qianfan provider plugin", () => { expect(catalogProvider.api).toBe("openai-completions"); expect(catalogProvider.baseUrl).toBe("https://qianfan.baidubce.com/v2"); - expect(catalogProvider.models?.map((model) => model.id)).toEqual([ + const models = expectRecord(catalogProvider.models, "Qianfan catalog models"); + expect(models.map((model) => model.id)).toEqual([ "deepseek-v3.2", "ernie-5.0-thinking-preview", ]); - expect(catalogProvider.models?.find((model) => model.id === "deepseek-v3.2")).toMatchObject({ + expect( + expectRecord( + models.find((model) => model.id === "deepseek-v3.2"), + "deepseek model", + ), + ).toMatchObject({ name: "DEEPSEEK V3.2", reasoning: true, input: ["text"], @@ -48,7 +62,10 @@ describe("qianfan provider plugin", () => { maxTokens: 32768, }); expect( - catalogProvider.models?.find((model) => model.id === "ernie-5.0-thinking-preview"), + expectRecord( + models.find((model) => model.id === "ernie-5.0-thinking-preview"), + "ernie model", + ), ).toMatchObject({ name: "ERNIE-5.0-Thinking-Preview", reasoning: true, @@ -67,25 +84,34 @@ describe("qianfan provider plugin", () => { }, }); - expect(cfg.models?.providers?.qianfan).toMatchObject({ + const modelsConfig = expectRecord(cfg.models, "models config"); + const providers = expectRecord(modelsConfig.providers, "model providers"); + const providerConfig = expectRecord(providers.qianfan, "Qianfan provider config"); + expect(providerConfig).toMatchObject({ api: "openai-completions", baseUrl: "https://qianfan.baidubce.com/v2", }); - expect(cfg.models?.providers?.qianfan?.models?.map((model) => model.id)).toEqual([ + const providerModels = expectRecord(providerConfig.models, "Qianfan provider models"); + expect(providerModels.map((model) => model.id)).toEqual([ "deepseek-v3.2", "ernie-5.0-thinking-preview", ]); - expect(cfg.agents?.defaults?.models?.[QIANFAN_DEFAULT_MODEL_REF]?.alias).toBe("QIANFAN"); - expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( - "anthropic/claude-opus-4-6", + const agentsConfig = expectRecord(cfg.agents, "agents config"); + const agentDefaults = expectRecord(agentsConfig.defaults, "agent defaults"); + const agentModelAliases = expectRecord(agentDefaults.models, "agent model aliases"); + const qianfanAlias = expectRecord( + agentModelAliases[QIANFAN_DEFAULT_MODEL_REF], + "Qianfan model alias", ); + expect(qianfanAlias.alias).toBe("QIANFAN"); + expect(resolveAgentModelPrimaryValue(agentDefaults.model)).toBe("anthropic/claude-opus-4-6"); }); it("sets Qianfan as the agent primary model in full onboarding mode", () => { const cfg = applyQianfanConfig({}); - expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe( - QIANFAN_DEFAULT_MODEL_REF, - ); + const agentsConfig = expectRecord(cfg.agents, "agents config"); + const agentDefaults = expectRecord(agentsConfig.defaults, "agent defaults"); + expect(resolveAgentModelPrimaryValue(agentDefaults.model)).toBe(QIANFAN_DEFAULT_MODEL_REF); }); }); diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts index 3c5d79b2ad0..45c1ee4e7d7 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts @@ -37,7 +37,12 @@ describe("QQBot framework slash commands", () => { expect.arrayContaining(["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]), ); for (const commandName of ["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]) { - expect(commands.find((command) => command.name === commandName)?.c2cOnly).toBe(true); + expect(commands).toContainEqual( + expect.objectContaining({ + name: commandName, + c2cOnly: true, + }), + ); } }); @@ -60,7 +65,12 @@ describe("QQBot framework slash commands", () => { const commands = registry.getFrameworkCommands(); expect(commands.map((command) => command.name)).toEqual(["private-admin", "shared-admin"]); - expect(commands.find((command) => command.name === "private-admin")?.c2cOnly).toBe(true); + expect(commands).toContainEqual( + expect.objectContaining({ + name: "private-admin", + c2cOnly: true, + }), + ); expect(commands.find((command) => command.name === "shared-admin")?.c2cOnly).toBeUndefined(); }); diff --git a/extensions/qqbot/src/engine/gateway/message-queue.test.ts b/extensions/qqbot/src/engine/gateway/message-queue.test.ts index 5e56dd9a4b8..f898a5c1540 100644 --- a/extensions/qqbot/src/engine/gateway/message-queue.test.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.test.ts @@ -14,6 +14,13 @@ function groupMsg(overrides: Partial = {}): QueuedMessage { }; } +function requireMergeMetadata(message: QueuedMessage): NonNullable { + if (!message.merge) { + throw new Error("expected QQBot merged message metadata"); + } + return message.merge; +} + describe("engine/gateway/message-queue", () => { describe("mergeGroupMessages", () => { it("returns the single message unchanged", () => { @@ -28,8 +35,9 @@ describe("engine/gateway/message-queue", () => { groupMsg({ senderName: "B", content: "yo" }), ]); expect(merged.content).toBe("[A]: hi\n[B]: yo"); - expect(merged.merge?.count).toBe(2); - expect(merged.merge?.messages).toHaveLength(2); + const merge = requireMergeMetadata(merged); + expect(merge.count).toBe(2); + expect(merge.messages).toHaveLength(2); }); it("takes messageId / msgIdx / timestamp from the last message", () => { @@ -62,7 +70,10 @@ describe("engine/gateway/message-queue", () => { ], }), ]); - expect(merged.attachments?.map((a) => a.url)).toEqual(["a", "b", "c"]); + if (!merged.attachments) { + throw new Error("expected QQBot merged attachments"); + } + expect(merged.attachments.map((a) => a.url)).toEqual(["a", "b", "c"]); }); it("deduplicates mentions by member/user openid", () => { @@ -70,7 +81,10 @@ describe("engine/gateway/message-queue", () => { groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Y" }] }), groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Z" }] }), ]); - expect(merged.mentions?.map((m) => m.member_openid)).toEqual(["X", "Y", "Z"]); + if (!merged.mentions) { + throw new Error("expected QQBot merged mentions"); + } + expect(merged.mentions.map((m) => m.member_openid)).toEqual(["X", "Y", "Z"]); }); it("flags merged turn as @bot when ANY source was GROUP_AT_MESSAGE_CREATE", () => { @@ -161,7 +175,7 @@ describe("engine/gateway/message-queue", () => { it("group overflow drops bot messages first (via processor)", async () => { const seen: QueuedMessage[] = []; - let gate!: (value?: unknown) => void; + let gate: ((value?: unknown) => void) | undefined; const blocker = new Promise((res) => { gate = res; }); @@ -188,6 +202,9 @@ describe("engine/gateway/message-queue", () => { const peerQueueIds = q.getSnapshot("group:G1"); expect(peerQueueIds.senderPending).toBe(3); // Release the processor and drain. + if (!gate) { + throw new Error("Expected QQBot queue gate callback to be initialized"); + } gate(); await new Promise((res) => setTimeout(res, 0)); const seenIds = seen.map((m) => m.messageId); @@ -197,7 +214,9 @@ describe("engine/gateway/message-queue", () => { // varies; we only assert the bot message id never appeared.) const mergedCall = seen.find((m) => (m.merge?.count ?? 0) > 1); if (mergedCall) { - expect(mergedCall.merge?.messages.map((m) => m.messageId)).not.toContain("B1"); + expect(requireMergeMetadata(mergedCall).messages.map((m) => m.messageId)).not.toContain( + "B1", + ); } else { expect(seenIds).not.toContain("B1"); } @@ -207,7 +226,7 @@ describe("engine/gateway/message-queue", () => { // Use a processor that never resolves so enqueued messages stay // buffered behind a single active worker — then clearUserQueue // should drop the rest. - let release!: () => void; + let release: (() => void) | undefined; const blocker = new Promise((res) => { release = res; }); @@ -222,6 +241,9 @@ describe("engine/gateway/message-queue", () => { expect(q.getSnapshot("group:G1").senderPending).toBeGreaterThanOrEqual(0); const dropped = q.clearUserQueue("group:G1"); expect(dropped).toBeGreaterThanOrEqual(0); + if (!release) { + throw new Error("Expected QQBot queue release callback to be initialized"); + } release(); }); }); diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts index 03792b6d12e..6b25ddd7a22 100644 --- a/extensions/qqbot/src/engine/utils/audio.test.ts +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -197,10 +197,9 @@ describe("engine/utils/audio", () => { const pcm = Buffer.from([0x01, 0x00, 0x02, 0x00]); const wav = buildMinimalWav(pcm, 24000, 1); const result = parseWavFallback(wav); - expect(result).not.toBeNull(); - expect(result!.length).toBe(4); - expect(result![0]).toBe(0x01); - expect(result![1]).toBe(0x00); + expect(result?.length).toBe(4); + expect(result?.[0]).toBe(0x01); + expect(result?.[1]).toBe(0x00); }); it("returns null for buffers shorter than 44 bytes", () => { @@ -230,10 +229,12 @@ describe("engine/utils/audio", () => { const wav = buildMinimalWav(stereoPcm, 24000, 2); const result = parseWavFallback(wav); - expect(result).not.toBeNull(); + if (!result) { + throw new Error("expected downmixed WAV fallback result"); + } // mono output: 2 samples × 2 bytes = 4 bytes - expect(result!.length).toBe(4); - const outView = new DataView(result!.buffer, result!.byteOffset); + expect(result.length).toBe(4); + const outView = new DataView(result.buffer, result.byteOffset); expect(outView.getInt16(0, true)).toBe(150); // (100+200)/2 expect(outView.getInt16(2, true)).toBe(-150); // (-100+-200)/2 }); @@ -243,8 +244,7 @@ describe("engine/utils/audio", () => { const pcm48k = Buffer.alloc(8); const wav = buildMinimalWav(pcm48k, 48000, 1); const result = parseWavFallback(wav); - expect(result).not.toBeNull(); - expect(result!.length).toBe(4); // 2 samples × 2 bytes + expect(result?.length).toBe(4); // 2 samples × 2 bytes }); }); }); diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 5e179c69203..1461bcf1d88 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -2,10 +2,18 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + import { resolveSTTConfig, transcribeAudio } from "./stt.js"; describe("engine/utils/stt", () => { afterEach(() => { + fetchWithSsrFGuardMock.mockReset(); vi.unstubAllGlobals(); }); @@ -72,12 +80,13 @@ describe("engine/utils/stt", () => { const audioPath = path.join(tmpDir, "voice.wav"); fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); - const fetchMock = vi.fn(async () => - Response.json({ + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: Response.json({ text: "hello from audio", }), - ); - vi.stubGlobal("fetch", fetchMock); + release, + }); const transcript = await transcribeAudio(audioPath, { channels: { @@ -92,13 +101,17 @@ describe("engine/utils/stt", () => { }); expect(transcript).toBe("hello from audio"); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.example.test/v1/audio/transcriptions", + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( expect.objectContaining({ - method: "POST", - headers: { Authorization: "Bearer secret" }, - body: expect.any(FormData), + url: "https://api.example.test/v1/audio/transcriptions", + auditContext: "qqbot-stt", + init: expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer secret" }, + body: expect.any(FormData), + }), }), ); + expect(release).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/qwen/media-understanding-provider.test.ts b/extensions/qwen/media-understanding-provider.test.ts index 3b747aa1812..67e45141247 100644 --- a/extensions/qwen/media-understanding-provider.test.ts +++ b/extensions/qwen/media-understanding-provider.test.ts @@ -36,25 +36,40 @@ describe("describeQwenVideo", () => { expect(result.model).toBe("qwen-vl-max"); expect(result.text).toBe("first\nsecond"); expect(url).toBe("https://example.com/v1/chat/completions"); - expect(init?.method).toBe("POST"); - expect(init?.signal).toBeInstanceOf(AbortSignal); + if (!init) { + throw new Error("expected Qwen request init"); + } + expect(init.method).toBe("POST"); + expect(init.signal).toBeInstanceOf(AbortSignal); - const headers = new Headers(init?.headers); + const headers = new Headers(init.headers); expect(headers.get("authorization")).toBe("Bearer test-key"); expect(headers.get("content-type")).toBe("application/json"); expect(headers.get("x-other")).toBe("1"); const bodyText = - typeof init?.body === "string" + typeof init.body === "string" ? init.body - : Buffer.isBuffer(init?.body) + : Buffer.isBuffer(init.body) ? init.body.toString("utf8") : ""; + expect(bodyText).not.toBe(""); const body = JSON.parse(bodyText); expect(body.model).toBe("qwen-vl-max"); - expect(body.messages?.[0]?.content?.[0]?.text).toBe("summarize the clip"); - expect(body.messages?.[0]?.content?.[1]?.type).toBe("video_url"); - expect(body.messages?.[0]?.content?.[1]?.video_url?.url).toBe( + const content = body.messages?.[0]?.content; + if (!content) { + throw new Error("expected Qwen user content"); + } + expect(content[0]?.text).toBe("summarize the clip"); + const videoContent = content[1]; + if (!videoContent) { + throw new Error("expected Qwen video content"); + } + expect(videoContent.type).toBe("video_url"); + if (!videoContent.video_url) { + throw new Error("expected Qwen video URL payload"); + } + expect(videoContent.video_url.url).toBe( `data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`, ); }); diff --git a/extensions/qwen/provider-catalog.test.ts b/extensions/qwen/provider-catalog.test.ts index 0f155b0f140..ff3d6cd2bf5 100644 --- a/extensions/qwen/provider-catalog.test.ts +++ b/extensions/qwen/provider-catalog.test.ts @@ -7,15 +7,22 @@ import { QWEN_DEFAULT_MODEL_ID, } from "./api.js"; +type QwenProvider = ReturnType; + +function getQwenModelIds(provider: QwenProvider): string[] { + return provider.models.map((model) => model.id); +} + describe("qwen provider catalog", () => { it("builds the bundled Qwen provider defaults", () => { const provider = buildQwenProvider(); expect(provider.baseUrl).toBe(QWEN_BASE_URL); expect(provider.api).toBe("openai-completions"); - expect(provider.models?.length).toBeGreaterThan(0); - expect(provider.models?.map((model) => model.id)).toContain(QWEN_DEFAULT_MODEL_ID); - expect(provider.models?.map((model) => model.id)).not.toContain("qwen3.6-plus"); + const modelIds = getQwenModelIds(provider); + expect(modelIds.length).toBeGreaterThan(0); + expect(modelIds).toContain(QWEN_DEFAULT_MODEL_ID); + expect(modelIds).not.toContain("qwen3.6-plus"); }); it("only advertises qwen3.6-plus on Standard endpoints", () => { @@ -25,15 +32,21 @@ describe("qwen provider catalog", () => { }); const standard = buildQwenProvider({ baseUrl: QWEN_STANDARD_GLOBAL_BASE_URL }); - expect(coding.models?.map((model) => model.id)).not.toContain("qwen3.6-plus"); - expect(codingTrailingDot.models?.map((model) => model.id)).not.toContain("qwen3.6-plus"); - expect(standard.models?.map((model) => model.id)).toContain("qwen3.6-plus"); + expect(getQwenModelIds(coding)).not.toContain("qwen3.6-plus"); + expect(getQwenModelIds(codingTrailingDot)).not.toContain("qwen3.6-plus"); + expect(getQwenModelIds(standard)).toContain("qwen3.6-plus"); }); it("opts native Qwen baseUrls into streaming usage only inside the extension", () => { const nativeProvider = applyQwenNativeStreamingUsageCompat(buildQwenProvider()); + expect(nativeProvider.models.length).toBeGreaterThan(0); expect( - nativeProvider.models?.every((model) => model.compat?.supportsUsageInStreaming === true), + nativeProvider.models.every((model) => { + if (!model.compat) { + throw new Error(`expected Qwen model ${model.id} compat`); + } + return model.compat.supportsUsageInStreaming === true; + }), ).toBe(true); const customProvider = applyQwenNativeStreamingUsageCompat({ @@ -41,7 +54,9 @@ describe("qwen provider catalog", () => { baseUrl: "https://proxy.example.com/v1", }); expect( - customProvider.models?.some((model) => model.compat?.supportsUsageInStreaming === true), + customProvider.models.some( + (model) => model.compat && model.compat.supportsUsageInStreaming === true, + ), ).toBe(false); }); }); diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index 796d61bad2e..732354e94d7 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -72,7 +72,11 @@ describe("runway video generation provider", () => { fetch, ); expect(result.videos).toHaveLength(1); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const video = result.videos[0]; + if (!video) { + throw new Error("expected Runway generated video"); + } + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ taskId: "task-1", diff --git a/extensions/searxng/src/searxng-search-provider.test.ts b/extensions/searxng/src/searxng-search-provider.test.ts index 20db0fdf142..8675f50e026 100644 --- a/extensions/searxng/src/searxng-search-provider.test.ts +++ b/extensions/searxng/src/searxng-search-provider.test.ts @@ -155,8 +155,12 @@ describe("searxng web search provider", () => { it("persists base URL to plugin config via setConfiguredCredentialValue", () => { const provider = createSearxngWebSearchProvider(); const config = {} as Record; + const setConfiguredCredentialValue = provider.setConfiguredCredentialValue; + if (!setConfiguredCredentialValue) { + throw new Error("Expected SearXNG provider setConfiguredCredentialValue"); + } - provider.setConfiguredCredentialValue!(config, "http://search.local:9000"); + setConfiguredCredentialValue(config, "http://search.local:9000"); expect( ( diff --git a/extensions/senseaudio/media-understanding-provider.test.ts b/extensions/senseaudio/media-understanding-provider.test.ts index a70dd16f68e..60614c682bb 100644 --- a/extensions/senseaudio/media-understanding-provider.test.ts +++ b/extensions/senseaudio/media-understanding-provider.test.ts @@ -78,12 +78,9 @@ describe("transcribeSenseAudioAudio", () => { expect(form.get("language")).toBe("en"); expect(form.get("prompt")).toBe("hello"); const file = form.get("file") as Blob | { type?: string; name?: string } | null; - expect(file).not.toBeNull(); - if (file) { - expect(file.type).toBe("audio/wav"); - if ("name" in file && typeof file.name === "string") { - expect(file.name).toBe("voice.wav"); - } + expect(file).toEqual(expect.objectContaining({ type: "audio/wav" })); + if (file && "name" in file && typeof file.name === "string") { + expect(file.name).toBe("voice.wav"); } }); diff --git a/extensions/signal/src/config-schema.test.ts b/extensions/signal/src/config-schema.test.ts index 062de3275cd..f84d149d2d6 100644 --- a/extensions/signal/src/config-schema.test.ts +++ b/extensions/signal/src/config-schema.test.ts @@ -108,6 +108,8 @@ describe("signal groups schema", () => { }, }); - expect(issues.some((issue) => issue.path.join(".").startsWith("groups"))).toBe(true); + expect(issues.map((issue) => issue.path.join("."))).toContainEqual( + expect.stringMatching(/^groups/), + ); }); }); diff --git a/extensions/signal/src/format.chunking.test.ts b/extensions/signal/src/format.chunking.test.ts index dbea02f3c9a..ef8fffc2ebe 100644 --- a/extensions/signal/src/format.chunking.test.ts +++ b/extensions/signal/src/format.chunking.test.ts @@ -85,7 +85,7 @@ describe("splitSignalFormattedText", () => { // First chunk should contain the bold style const firstChunk = chunks[0]; expect(firstChunk.text).toContain("bold"); - expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true); + expect(firstChunk.styles.map((style) => style.style)).toContain("BOLD"); // The bold style should start at position 0 in the first chunk const boldStyle = requireStyle(firstChunk, "BOLD"); expect(boldStyle.start).toBe(0); @@ -104,7 +104,7 @@ describe("splitSignalFormattedText", () => { if (!chunkWithBold) { throw new Error("chunk containing bold text missing"); } - expect(chunkWithBold.styles.some((s) => s.style === "BOLD")).toBe(true); + expect(chunkWithBold.styles.map((style) => style.style)).toContain("BOLD"); // The bold style should have chunk-local offset (not original text offset) const boldStyle = requireStyle(chunkWithBold, "BOLD"); @@ -211,7 +211,7 @@ describe("splitSignalFormattedText", () => { // Bold should be preserved in first chunk const firstChunk = chunks[0]; if (firstChunk.text.includes("bold")) { - expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true); + expect(firstChunk.styles.map((style) => style.style)).toContain("BOLD"); } }); diff --git a/extensions/signal/src/monitor.tool-result.autostart.test.ts b/extensions/signal/src/monitor.tool-result.autostart.test.ts index be4d5ed4517..a8f74728e5f 100644 --- a/extensions/signal/src/monitor.tool-result.autostart.test.ts +++ b/extensions/signal/src/monitor.tool-result.autostart.test.ts @@ -157,7 +157,7 @@ describe("monitorSignalProvider autostart", () => { setSignalAutoStartConfig(); const abortController = new AbortController(); let exited = false; - let resolveExit!: (value: SignalDaemonExitEvent) => void; + let resolveExit: ((value: SignalDaemonExitEvent) => void) | undefined; const exitedPromise = new Promise((resolve) => { resolveExit = resolve; }); @@ -166,6 +166,9 @@ describe("monitorSignalProvider autostart", () => { return; } exited = true; + if (!resolveExit) { + throw new Error("Expected signal daemon exit resolver to be initialized"); + } resolveExit({ source: "process", code: null, signal: "SIGTERM" }); }); spawnSignalDaemonMock.mockReturnValueOnce( diff --git a/extensions/slack/src/channel.message-adapter.test.ts b/extensions/slack/src/channel.message-adapter.test.ts index ffab986f6ff..7c4a676de45 100644 --- a/extensions/slack/src/channel.message-adapter.test.ts +++ b/extensions/slack/src/channel.message-adapter.test.ts @@ -16,6 +16,45 @@ const cfg = { }, } as OpenClawConfig; +type SlackMessageAdapter = NonNullable; +type SlackMessageSender = NonNullable; + +function requireSlackMessageAdapter(): SlackMessageAdapter { + const adapter = slackPlugin.message; + if (!adapter) { + throw new Error("Expected slack channel message adapter"); + } + return adapter; +} + +function requireTextSender(adapter: SlackMessageAdapter): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected slack message adapter text sender"); + } + return text; +} + +function requireMediaSender( + adapter: SlackMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected slack message adapter media sender"); + } + return media; +} + +function requirePayloadSender( + adapter: SlackMessageAdapter, +): NonNullable { + const payload = adapter.send?.payload; + if (!payload) { + throw new Error("Expected slack message adapter payload sender"); + } + return payload; +} + describe("slack channel message adapter", () => { const sendSlack = vi.fn(); @@ -25,13 +64,10 @@ describe("slack channel message adapter", () => { }); it("backs declared durable-final capabilities with outbound send proofs", async () => { - const adapter = slackPlugin.message; - if (!adapter?.send?.text || !adapter.send.media || !adapter.send.payload) { - throw new Error("expected slack channel message adapter with text/media/payload senders"); - } - const sendText = adapter.send.text; - const sendMedia = adapter.send.media; - const sendPayload = adapter.send.payload; + const adapter = requireSlackMessageAdapter(); + const sendText = requireTextSender(adapter); + const sendMedia = requireMediaSender(adapter); + const sendPayload = requirePayloadSender(adapter); const proveText = async () => { sendSlack.mockClear(); @@ -152,39 +188,40 @@ describe("slack channel message adapter", () => { }); it("backs declared live preview finalizer capabilities with adapter proofs", async () => { - const adapter = slackPlugin.message; + const adapter = requireSlackMessageAdapter(); + const sendText = requireTextSender(adapter); await verifyChannelMessageLiveCapabilityAdapterProofs({ adapterName: "slackMessageAdapter", - adapter: adapter!, + adapter, proofs: { draftPreview: () => { - expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true); }, previewFinalization: () => { - expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true); }, progressUpdates: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, nativeStreaming: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, }, }); await verifyChannelMessageLiveFinalizerProofs({ adapterName: "slackMessageAdapter", - adapter: adapter!, + adapter, proofs: { finalEdit: () => { - expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + expect(adapter.live?.capabilities?.previewFinalization).toBe(true); }, normalFallback: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, discardPending: () => { - expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + expect(adapter.live?.capabilities?.draftPreview).toBe(true); }, }, }); diff --git a/extensions/slack/src/format.test.ts b/extensions/slack/src/format.test.ts index 7d0b3c392b4..bf8ec3517b3 100644 --- a/extensions/slack/src/format.test.ts +++ b/extensions/slack/src/format.test.ts @@ -62,7 +62,7 @@ describe("markdownToSlackMrkdwn", () => { ); }); - it("does not throw when input is undefined at runtime", () => { + it("returns empty text when input is undefined at runtime", () => { expect(markdownToSlackMrkdwn(undefined as unknown as string)).toBe(""); }); diff --git a/extensions/slack/src/http/plugin-routes.test.ts b/extensions/slack/src/http/plugin-routes.test.ts index 54c052f0842..ce8bad42c8c 100644 --- a/extensions/slack/src/http/plugin-routes.test.ts +++ b/extensions/slack/src/http/plugin-routes.test.ts @@ -42,7 +42,7 @@ describe("registerSlackPluginHttpRoutes", () => { }; const api = createApi(cfg, registerHttpRoute); - expect(() => registerSlackPluginHttpRoutes(api)).not.toThrow(); + registerSlackPluginHttpRoutes(api); const paths = registerHttpRoute.mock.calls .map((call) => (call[0] as { path: string }).path) diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index 5487b679d02..2afc8917da5 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -13,6 +13,16 @@ import * as mediaRuntime from "./media.runtime.js"; import { logVerbose } from "./thread.runtime.js"; type FetchMock = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type SlackMediaResult = NonNullable>>; + +function expectSlackMediaResult( + result: Awaited>, +): SlackMediaResult { + if (result === null) { + throw new Error("Expected Slack media result"); + } + return result; +} const fetchRemoteMediaMock = vi.hoisted(() => vi.fn( @@ -134,7 +144,7 @@ async function expectPrivateDownloadRedirect(params: { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); + expectSlackMediaResult(result); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://files.slack.com/download.jpg"); expect(mockFetch.mock.calls[1]?.[0]).toBe(params.redirectedUrl); @@ -376,7 +386,7 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); + expectSlackMediaResult(result); const fetchOptions = fetchRemoteMediaMock.mock.calls[0]?.[0]; expect(fetchOptions?.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS); expect(fetchOptions?.requestInit?.signal).toBeInstanceOf(AbortSignal); @@ -481,8 +491,8 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result?.[0]?.path).toBe("/tmp/page.html"); + const media = expectSlackMediaResult(result); + expect(media[0]?.path).toBe("/tmp/page.html"); }); it("overrides video/* MIME to audio/* for slack_audio voice messages", async () => { @@ -512,8 +522,8 @@ describe("resolveSlackMedia", () => { maxBytes: 16 * 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(1); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(1); // saveMediaBuffer should receive the overridden audio/mp4 expect(saveMediaBufferMock).toHaveBeenCalledWith( expect.any(Buffer), @@ -523,7 +533,7 @@ describe("resolveSlackMedia", () => { ); // Returned contentType must be the overridden value, not the // re-detected video/mp4 from saveMediaBuffer - expect(result![0]?.contentType).toBe("audio/mp4"); + expect(media[0]?.contentType).toBe("audio/mp4"); }); it("preserves original MIME for non-voice Slack files", async () => { @@ -549,15 +559,15 @@ describe("resolveSlackMedia", () => { maxBytes: 16 * 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(1); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(1); expect(saveMediaBufferMock).toHaveBeenCalledWith( expect.any(Buffer), "video/mp4", "inbound", 16 * 1024 * 1024, ); - expect(result![0]?.contentType).toBe("video/mp4"); + expect(media[0]?.contentType).toBe("video/mp4"); }); it("falls through to next file when first file returns error", async () => { @@ -584,8 +594,8 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(1); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(1); expect(mockFetch).toHaveBeenCalledTimes(2); }); @@ -628,11 +638,12 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).toHaveLength(2); - expect(result![0].path).toBe("/tmp/a.jpg"); - expect(result![0].placeholder).toBe("[Slack file: a.jpg (fileId: FA)]"); - expect(result![1].path).toBe("/tmp/b.png"); - expect(result![1].placeholder).toBe("[Slack file: b.png (fileId: FB)]"); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(2); + expect(media[0].path).toBe("/tmp/a.jpg"); + expect(media[0].placeholder).toBe("[Slack file: a.jpg (fileId: FA)]"); + expect(media[1].path).toBe("/tmp/b.png"); + expect(media[1].placeholder).toBe("[Slack file: b.png (fileId: FB)]"); }); it("caps downloads to 8 files for large multi-attachment messages", async () => { @@ -659,8 +670,8 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); - expect(result).toHaveLength(8); + const media = expectSlackMediaResult(result); + expect(media).toHaveLength(8); expect(saveMediaBufferMock).toHaveBeenCalledTimes(8); expect(mockFetch).toHaveBeenCalledTimes(8); }); @@ -687,7 +698,7 @@ describe("resolveSlackMedia", () => { maxBytes: 1024 * 1024, }); - expect(result).not.toBeNull(); + expectSlackMediaResult(result); expect(runtimeFetchSpy).toHaveBeenCalled(); expect(runtimeFetchSpy.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); expect( diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 689ee830b07..33ce73fff83 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -80,6 +80,17 @@ let mockedDispatchSequence: Array<{ mediaUrls?: string[]; }; }> = []; + +function countFinalDispatches(): number { + let count = 0; + for (const entry of mockedDispatchSequence) { + if (entry.kind === "final") { + count++; + } + } + return count; +} + let mockedProgressEvents: string[] = []; let mockedReplyOptionEvents: Array< | { @@ -624,7 +635,7 @@ vi.mock("../reply.runtime.js", () => ({ return { queuedFinal: false, counts: { - final: mockedDispatchSequence.filter((entry) => entry.kind === "final").length, + final: countFinalDispatches(), }, }; }, @@ -971,7 +982,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { it("suppresses reasoning payloads before Slack native streaming delivery", async () => { mockedNativeStreaming = true; mockedDispatchSequence = [ - { kind: "block", payload: { text: "Reasoning:\n_hidden_", isReasoning: true } }, + { kind: "block", payload: { text: "hidden", isReasoning: true } }, { kind: "final", payload: { text: FINAL_REPLY_TEXT } }, ]; diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 9567edd1a12..c81b362f3c0 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -1981,10 +1981,12 @@ describe("prepareSlackMessage sender prefix", () => { const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001"); - expect(result).not.toBeNull(); - const body = result?.ctxPayload.Body ?? ""; + if (!result) { + throw new Error("expected Slack sender prefix message"); + } + const body = result.ctxPayload.Body; expect(body).toContain("Alice (U1): <@BOT> (Bek) hello"); - expect(result?.ctxPayload.RawBody).toBe("<@BOT> (Bek) hello"); + expect(result.ctxPayload.RawBody).toBe("<@BOT> (Bek) hello"); }); it("keeps raw Slack mention tokens when user lookup cannot resolve them", async () => { @@ -1997,10 +1999,12 @@ describe("prepareSlackMessage sender prefix", () => { const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001"); - expect(result).not.toBeNull(); - const body = result?.ctxPayload.Body ?? ""; + if (!result) { + throw new Error("expected Slack sender prefix message"); + } + const body = result.ctxPayload.Body; expect(body).toContain("Alice (U1): <@BOT> hello"); - expect(result?.ctxPayload.RawBody).toBe("<@BOT> hello"); + expect(result.ctxPayload.RawBody).toBe("<@BOT> hello"); }); it("caps Slack mention username lookups per inbound message and leaves overflow mentions raw", async () => { @@ -2103,8 +2107,9 @@ describe("prepareSlackMessage sender prefix", () => { const result = await prepareSenderPrefixMessage(ctx, "<@BOT> /new", "1700000000.0002"); - expect(result).not.toBeNull(); - expect(result?.ctxPayload.CommandAuthorized).toBe(true); + expect(result).toMatchObject({ + ctxPayload: { CommandAuthorized: true }, + }); }); }); @@ -2182,7 +2187,9 @@ describe("slack thread.requireExplicitMention", () => { message, opts: { source: "message" }, }); - expect(result).not.toBeNull(); + if (!result) { + throw new Error("expected Slack thread reply message"); + } }); it("allows thread reply without explicit mention when requireExplicitMention is false (default)", async () => { @@ -2209,6 +2216,8 @@ describe("slack thread.requireExplicitMention", () => { message, opts: { source: "message" }, }); - expect(result).not.toBeNull(); + if (!result) { + throw new Error("expected Slack thread reply message"); + } }); }); diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 58f0d6d7989..79f910636d9 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -287,10 +287,13 @@ function findFirstActionsBlock(payload: { blocks?: Array<{ type: string }> }) { } function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected Slack slash deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -810,7 +813,7 @@ describe("Slack native command argument menus", () => { options?: Array<{ text?: { text?: string }; value?: string }>; }; const optionTexts = (optionsPayload.options ?? []).map((option) => option.text?.text ?? ""); - expect(optionTexts.some((text) => text.includes("Period 12"))).toBe(true); + expect(optionTexts).toEqual(expect.arrayContaining([expect.stringContaining("Period 12")])); }); it("tracks accepted external_select option requests", async () => { @@ -910,7 +913,7 @@ describe("Slack native command argument menus", () => { ); }); - it("treats malformed percent-encoding as an invalid button (no throw)", async () => { + it("treats malformed percent-encoding as an invalid button", async () => { await runArgMenuAction(argMenuHandler, { action: { value: "cmdarg|%E0%A4%A|mode|on|U1" }, includeRespond: false, @@ -1226,7 +1229,8 @@ describe("slack slash command session metadata", () => { }; expect(call.ctx?.OriginatingChannel).toBe("slack"); expect(call.ctx?.GroupSpace).toBe("T1"); - expect(call.sessionKey).toEqual(expect.any(String)); + expect(call.sessionKey).toBeTypeOf("string"); + expect(call.sessionKey).not.toBe(""); }); it("awaits session metadata persistence before dispatch", async () => { diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index 58a96067101..b85e5a08185 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -178,7 +178,7 @@ describe("sendMessageSlack file upload with user IDs", () => { it("serializes concurrent sends to the same Slack target", async () => { const client = createUploadTestClient(); - let resolveFirst!: () => void; + let resolveFirst: (() => void) | undefined; client.chat.postMessage.mockImplementation(async (payload: { text?: string }) => { if (payload.text === "first") { await new Promise((resolve) => { @@ -204,6 +204,9 @@ describe("sendMessageSlack file upload with user IDs", () => { await Promise.resolve(); expect(client.chat.postMessage).toHaveBeenCalledTimes(1); + if (!resolveFirst) { + throw new Error("Expected first Slack send release callback to be initialized"); + } resolveFirst(); await expect(first).resolves.toMatchObject({ diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index d5c55db21bb..d305342faeb 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -121,7 +121,11 @@ describe("Synology Chat TLS verification defaults", () => { mockSuccessResponse(); await settleTimers(invoke()); const httpsRequest = vi.mocked(https.request); - expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true }); + const firstCall = httpsRequest.mock.calls[0]; + if (!firstCall) { + throw new Error("expected Synology Chat HTTPS request"); + } + expect(firstCall[1]).toMatchObject({ rejectUnauthorized: true }); }); }); @@ -153,7 +157,11 @@ describe("sendMessage", () => { mockSuccessResponse(); await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", undefined, true)); const httpsRequest = vi.mocked(https.request); - expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: false }); + const firstCall = httpsRequest.mock.calls[0]; + if (!firstCall) { + throw new Error("expected Synology Chat HTTPS request"); + } + expect(firstCall[1]).toMatchObject({ rejectUnauthorized: false }); }); }); @@ -376,6 +384,10 @@ describe("fetchChatUsers", () => { await fetchChatUsers(freshUrl); const httpsGet = vi.mocked(https.get); - expect(httpsGet.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true }); + const firstCall = httpsGet.mock.calls[0]; + if (!firstCall) { + throw new Error("expected Synology Chat HTTPS get"); + } + expect(firstCall[1]).toMatchObject({ rejectUnauthorized: true }); }); }); diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index a69a801f705..07330148fe9 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -16,6 +16,16 @@ type TestLog = { error: (...args: unknown[]) => void; }; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function makeAccount( overrides: Partial = {}, ): ResolvedSynologyChatAccount { @@ -240,8 +250,8 @@ describe("createWebhookHandler", () => { await new Promise((resolve) => setTimeout(resolve, 0)); // Default maxInFlightPerKey is 8; 12 total requests leaves 4 rejected with 429. - expect(responses.filter((res) => res._status === 0)).toHaveLength(8); - expect(responses.filter((res) => res._status === 429)).toHaveLength(4); + expect(countMatching(responses, (res) => res._status === 0)).toBe(8); + expect(countMatching(responses, (res) => res._status === 429)).toBe(4); for (const req of requests) { req.emit("end"); diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 2ed1cbba227..5c8ddbe33bf 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -23,7 +23,7 @@ function warningLines(): string[] { } function expectNoMissingDefaultWarning() { - expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe(true); + expect(warningLines().some((line) => line.includes("accounts.default is missing"))).toBe(false); } function resolveAccountWithEnv( diff --git a/extensions/telegram/src/agent-config.ts b/extensions/telegram/src/agent-config.ts new file mode 100644 index 00000000000..74cb9da2a89 --- /dev/null +++ b/extensions/telegram/src/agent-config.ts @@ -0,0 +1,21 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; + +type ReasoningDefault = "on" | "stream" | "off"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "").trim().toLowerCase(); + return normalized || DEFAULT_AGENT_ID; +} + +export function resolveTelegramConfigReasoningDefault( + cfg: OpenClawConfig, + agentId: string, +): ReasoningDefault { + const id = normalizeAgentId(agentId); + const agentDefault = cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry?.id) === id, + )?.reasoningDefault; + return agentDefault ?? cfg.agents?.defaults?.reasoningDefault ?? "off"; +} diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 896c7dd6930..77007aa6e10 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -165,7 +165,6 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { }, }); - expect(ctx).not.toBeNull(); expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123"); diff --git a/extensions/telegram/src/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts index c42ea232b21..090fcb47176 100644 --- a/extensions/telegram/src/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -81,7 +81,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); @@ -104,7 +103,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { }, ); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); @@ -136,7 +134,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { }, ); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); @@ -173,7 +170,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { }), }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBe(42); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42"); }); @@ -187,7 +183,6 @@ describe("buildTelegramMessageContext dm thread sessions", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); @@ -213,11 +208,13 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); + if (!ctx) { + throw new Error("expected Telegram non-forum group context"); + } // Session key should NOT include :topic:42 - expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); + expect(ctx.ctxPayload.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); // MessageThreadId should be undefined (not a forum) - expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); + expect(ctx.ctxPayload.MessageThreadId).toBeUndefined(); }); it("keeps same session for regular group with and without message_thread_id", async () => { @@ -238,8 +235,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctxWithThread).not.toBeNull(); - expect(ctxWithoutThread).not.toBeNull(); // Both messages should use the same session key expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey); }); @@ -261,7 +256,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { sessionRuntime: { resolveStorePath }, }); - expect(ctx).not.toBeNull(); expect(ctx?.isForum).toBe(false); expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); expect(resolveStorePath).toHaveBeenCalledTimes(1); @@ -277,7 +271,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); // Session key SHOULD include :topic:99 for forums expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99"); expect(ctx?.ctxPayload?.MessageThreadId).toBe(99); @@ -297,7 +290,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); }); @@ -320,7 +312,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { sessionRuntime: null, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); }); @@ -362,7 +353,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { from: { id: 42, first_name: "Alice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); } finally { await fs.rm(tempDir, { recursive: true, force: true }); @@ -410,7 +400,6 @@ describe("buildTelegramMessageContext group sessions without forum", () => { sessionRuntime: null, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.TopicName).toBe("Deployments"); } finally { await fs.rm(tempDir, { recursive: true, force: true }); diff --git a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index 955e8f3d998..3d079be9076 100644 --- a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -67,7 +67,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 }, }); - expect(ctx).not.toBeNull(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram DM topic context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:1234", threadId: "42" }); @@ -80,7 +82,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 }, }); - expect(ctx).not.toBeNull(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram DM context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:1234" }); @@ -97,7 +101,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 resolveGroupActivation: () => true, }); - expect(ctx).not.toBeNull(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram forum topic context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:-1001234567890:topic:99", threadId: "99" }); @@ -113,7 +119,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 resolveGroupActivation: () => true, }); - expect(ctx).not.toBeNull(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram General topic context payload"); + } expect(recordInboundSessionMock).toHaveBeenCalled(); expectRecordedRoute({ to: "telegram:-1001234567890:topic:1", threadId: "1" }); diff --git a/extensions/telegram/src/bot-message-context.reactions.test.ts b/extensions/telegram/src/bot-message-context.reactions.test.ts index 88c939cb089..8c4b2ba809b 100644 --- a/extensions/telegram/src/bot-message-context.reactions.test.ts +++ b/extensions/telegram/src/bot-message-context.reactions.test.ts @@ -101,7 +101,6 @@ describe("buildTelegramMessageContext reactions", () => { }), }); - expect(ctx).not.toBeNull(); expect(ctx?.ackReactionPromise).toBeNull(); expect(ctx?.statusReactionController).toBeNull(); expect(createStatusReactionController).not.toHaveBeenCalled(); diff --git a/extensions/telegram/src/bot-message-context.require-mention.test.ts b/extensions/telegram/src/bot-message-context.require-mention.test.ts index deed88a9901..6154f0cbc77 100644 --- a/extensions/telegram/src/bot-message-context.require-mention.test.ts +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -56,7 +56,9 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx).not.toBeNull(); + if (!ctx) { + throw new Error("expected Telegram context when topic disables requireMention"); + } }); it("lets explicit topic requireMention=false override mention activation", async () => { @@ -72,7 +74,9 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx).not.toBeNull(); + if (!ctx?.ctxPayload) { + throw new Error("expected Telegram context payload when topic disables requireMention"); + } expect(resolveGroupActivation).toHaveBeenCalledWith( expect.objectContaining({ chatId: -1001234567890, @@ -107,6 +111,8 @@ describe("buildTelegramMessageContext requireMention precedence", () => { }), }); - expect(ctx).not.toBeNull(); + if (!ctx) { + throw new Error("expected Telegram context when topic config keeps agent"); + } }); }); diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 930e5ce4b11..e7d7da22046 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -45,6 +45,13 @@ type FinalizedTelegramInboundContext = ReturnType< typeof import("./bot-message-context.session.runtime.js").finalizeInboundContext >; +export type TelegramInboundContextPayload = FinalizedTelegramInboundContext & { + From: string; + To: string; + ChatType: string; + RawBody: string; +}; + type TelegramMessageContextSessionRuntime = typeof import("./bot-message-context.session.runtime.js"); @@ -175,7 +182,7 @@ export async function buildTelegramInboundContextPayload(params: { topicName?: string; sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides; }): Promise<{ - ctxPayload: FinalizedTelegramInboundContext; + ctxPayload: TelegramInboundContextPayload; skillFilter: string[] | undefined; turn: { storePath: string; diff --git a/extensions/telegram/src/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts index db38e7921df..e2b131bfc77 100644 --- a/extensions/telegram/src/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -119,7 +119,6 @@ describe("buildTelegramMessageContext thread binding override", () => { senderId: "42", }), ); - expect(ctx).not.toBeNull(); expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-2"); diff --git a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts index 93cae5ee7de..0a90778d18a 100644 --- a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -63,7 +63,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { it("uses group-level agent when no topic agentId is set", async () => { const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3"); }); @@ -72,7 +71,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:"); expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3"); }); @@ -98,7 +96,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { topicConfig: { agentId: " ", systemPrompt: "Be nice" }, }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); }); @@ -113,7 +110,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:"); }); @@ -138,7 +134,6 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { }), }); - expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:"); }); }); diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index b634ec9cb8f..b1cbc798e7c 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -409,6 +409,16 @@ describe("dispatchTelegramMessage draft streaming", () => { }); } + function createReasoningDefaultContext(): TelegramMessageContext { + loadSessionStore.mockReturnValue({ + s1: {}, + }); + return createContext({ + ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], + route: { agentId: "ops" } as unknown as TelegramMessageContext["route"], + }); + } + it("streams drafts in private threads and forwards thread id", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); @@ -458,6 +468,38 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("keeps retained overflow draft previews", async () => { + const draftStream = createDraftStream(); + const bot = createBot(); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Hello" }); + await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext(), bot }); + + const streamParams = createTelegramDraftStream.mock.calls[0]?.[0] as Parameters< + NonNullable + >[0]; + streamParams.onSupersededPreview?.({ + messageId: 17, + textSnapshot: "first page", + retain: true, + }); + expect(bot.api.deleteMessage).not.toHaveBeenCalled(); + + streamParams.onSupersededPreview?.({ + messageId: 18, + textSnapshot: "stale page", + }); + await vi.waitFor(() => expect(bot.api.deleteMessage).toHaveBeenCalledWith(123, 18)); + }); + it("queues final Telegram replies through outbound delivery when available", async () => { deliverInboundReplyWithMessageSendContext.mockResolvedValue({ status: "handled_visible", @@ -1149,6 +1191,33 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("streams reasoning from configured defaults", async () => { + const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ + answerMessageId: 2001, + reasoningMessageId: 3001, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onReasoningStream?.({ text: "Thinking" }); + await dispatcherOptions.deliver({ text: "Answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ + context: createReasoningDefaultContext(), + cfg: { + agents: { + defaults: { reasoningDefault: "off" }, + list: [{ id: "Ops", reasoningDefault: "stream" }], + }, + }, + }); + + expect(reasoningDraftStream.update).toHaveBeenCalledWith("Reasoning:\n_Thinking_"); + expect(answerDraftStream.update).toHaveBeenCalledWith("Answer"); + }); + it("suppresses reasoning-only finals without raw text fallback", async () => { setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { @@ -1162,6 +1231,41 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(editMessageTelegram).not.toHaveBeenCalled(); }); + it("does not add silent fallback when source delivery is message-tool-only", async () => { + setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 }); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "agent:main:telegram:direct:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }, + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(sendMessageTelegram).not.toHaveBeenCalled(); + }); + it("shows compacting reaction during auto-compaction and resumes thinking", async () => { const statusReactionController = { setThinking: vi.fn(async () => {}), @@ -1200,11 +1304,11 @@ describe("dispatchTelegramMessage draft streaming", () => { }); it("does not supersede the same session for unauthorized abort-looking commands", async () => { - let releaseFirstFinal!: () => void; + let releaseFirstFinal: (() => void) | undefined; const firstFinalGate = new Promise((resolve) => { releaseFirstFinal = resolve; }); - let resolveStreamVisible!: () => void; + let resolveStreamVisible: (() => void) | undefined; const streamVisible = new Promise((resolve) => { resolveStreamVisible = resolve; }); @@ -1213,6 +1317,9 @@ describe("dispatchTelegramMessage draft streaming", () => { messageId: 1001, onUpdate: (text) => { if (text === "Old reply partial") { + if (!resolveStreamVisible) { + throw new Error("Expected Telegram stream-visible resolver to be initialized"); + } resolveStreamVisible(); } }, @@ -1263,6 +1370,9 @@ describe("dispatchTelegramMessage draft streaming", () => { await unauthorizedReplyDelivered; + if (!releaseFirstFinal) { + throw new Error("Expected first Telegram final release callback to be initialized"); + } releaseFirstFinal(); await Promise.all([firstPromise, unauthorizedPromise]); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a61bf47adf0..d27460123a2 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -42,6 +42,7 @@ import { logVerbose, sleepWithAbort, } from "openclaw/plugin-sdk/runtime-env"; +import { resolveTelegramConfigReasoningDefault } from "./agent-config.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import { @@ -214,8 +215,9 @@ function resolveTelegramReasoningLevel(params: { telegramDeps: TelegramBotDeps; }): TelegramReasoningLevel { const { cfg, sessionKey, agentId, telegramDeps } = params; + const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId); if (!sessionKey) { - return "off"; + return configDefault; } try { const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId }); @@ -224,13 +226,13 @@ function resolveTelegramReasoningLevel(params: { }); const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; const level = entry?.reasoningLevel; - if (level === "on" || level === "stream") { + if (level === "on" || level === "stream" || level === "off") { return level; } } catch { - // Fall through to default. + return "off"; } - return "off"; + return configDefault; } const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300; @@ -440,6 +442,9 @@ export const dispatchTelegramMessage = async ({ minInitialChars: draftMinInitialChars, renderText: renderStreamText, onSupersededPreview: (superseded) => { + if (superseded.retain) { + return; + } void bot.api.deleteMessage(chatId, superseded.messageId).catch((err: unknown) => { logVerbose( `telegram: superseded ${laneName} stream cleanup failed (${superseded.messageId}): ${String(err)}`, @@ -572,8 +577,11 @@ export const dispatchTelegramMessage = async ({ segments: SplitLaneSegment[]; suppressedReasoningOnly: boolean; }; - const splitTextIntoLaneSegments = (text?: string): SplitLaneSegmentsResult => { - const split = splitTelegramReasoningText(text); + const splitTextIntoLaneSegments = ( + text?: string, + isReasoning?: boolean, + ): SplitLaneSegmentsResult => { + const split = splitTelegramReasoningText(text, isReasoning); const segments: SplitLaneSegment[] = []; const suppressReasoning = resolvedReasoningLevel === "off"; if (split.reasoningText && !suppressReasoning) { @@ -635,8 +643,8 @@ export const dispatchTelegramMessage = async ({ lane.lastPartialText = text; laneStream.update(text); }; - const ingestDraftLaneSegments = async (text: string | undefined) => { - const split = splitTextIntoLaneSegments(text); + const ingestDraftLaneSegments = async (text: string | undefined, isReasoning?: boolean) => { + const split = splitTextIntoLaneSegments(text, isReasoning); for (const segment of split.segments) { if (segment.lane === "answer") { await prepareAnswerLaneForText(); @@ -1035,7 +1043,7 @@ export const dispatchTelegramMessage = async ({ | { buttons?: TelegramInlineButtons } | undefined )?.buttons; - const split = splitTextIntoLaneSegments(payload.text); + const split = splitTextIntoLaneSegments(payload.text, payload.isReasoning); const segments = split.segments; const reply = resolveSendableOutboundReplyParts(payload); const _hasMedia = reply.hasMedia; @@ -1190,7 +1198,7 @@ export const dispatchTelegramMessage = async ({ resetDraftLaneState(reasoningLane); splitReasoningOnNextStream = false; } - await ingestDraftLaneSegments(payload.text); + await ingestDraftLaneSegments(payload.text, true); }) : undefined, onAssistantMessageStart: answerLane.stream diff --git a/extensions/telegram/src/bot-message.test.ts b/extensions/telegram/src/bot-message.test.ts index 56bf99da122..9743cbbf15d 100644 --- a/extensions/telegram/src/bot-message.test.ts +++ b/extensions/telegram/src/bot-message.test.ts @@ -3,10 +3,22 @@ import type { TelegramBotDeps } from "./bot-deps.js"; const buildTelegramMessageContext = vi.hoisted(() => vi.fn()); const dispatchTelegramMessage = vi.hoisted(() => vi.fn()); +const telegramInboundInfo = vi.hoisted(() => vi.fn()); const upsertChannelPairingRequest = vi.hoisted(() => vi.fn(async () => ({ code: "PAIRCODE", created: true })), ); +vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ + createSubsystemLogger: () => ({ + child: () => ({ + info: telegramInboundInfo, + }), + }), + danger: (message: string) => message, + logVerbose: vi.fn(), + shouldLogVerbose: () => false, +})); + vi.mock("./bot-message-context.js", () => ({ buildTelegramMessageContext, })); @@ -16,15 +28,18 @@ vi.mock("./bot-message-dispatch.js", () => ({ })); let createTelegramMessageProcessor: typeof import("./bot-message.js").createTelegramMessageProcessor; +let formatTelegramInboundLogLine: typeof import("./bot-message.js").formatTelegramInboundLogLine; describe("telegram bot message processor", () => { beforeAll(async () => { - ({ createTelegramMessageProcessor } = await import("./bot-message.js")); + ({ createTelegramMessageProcessor, formatTelegramInboundLogLine } = + await import("./bot-message.js")); }); beforeEach(() => { buildTelegramMessageContext.mockClear(); dispatchTelegramMessage.mockClear(); + telegramInboundInfo.mockClear(); upsertChannelPairingRequest.mockClear(); }); @@ -76,10 +91,7 @@ describe("telegram bot message processor", () => { sendMessage: ReturnType, ) { const runtimeError = vi.fn(); - buildTelegramMessageContext.mockResolvedValue({ - sendTyping: vi.fn().mockResolvedValue(undefined), - ...context, - }); + buildTelegramMessageContext.mockResolvedValue(createMessageContext(context)); dispatchTelegramMessage.mockRejectedValue(new Error("dispatch exploded")); const processMessage = createTelegramMessageProcessor({ ...baseDeps, @@ -89,13 +101,29 @@ describe("telegram bot message processor", () => { return { processMessage, runtimeError }; } + function createMessageContext(context: Record = {}) { + return { + chatId: 123, + ctxPayload: { + From: "telegram:123", + To: "telegram:123", + ChatType: "direct", + RawBody: "hello there", + }, + primaryCtx: { me: { username: "openclaw_bot" } }, + route: { sessionKey: "agent:main:main" }, + sendTyping: vi.fn().mockResolvedValue(undefined), + ...context, + }; + } + it("dispatches when context is available", async () => { const sendTyping = vi.fn().mockResolvedValue(undefined); - buildTelegramMessageContext.mockResolvedValue({ - chatId: 123, - route: { sessionKey: "agent:main:main" }, - sendTyping, - }); + buildTelegramMessageContext.mockResolvedValue( + createMessageContext({ + sendTyping, + }), + ); const processMessage = createTelegramMessageProcessor(baseDeps); await processSampleMessage(processMessage); @@ -105,6 +133,9 @@ describe("telegram bot message processor", () => { expect(sendTyping.mock.invocationCallOrder[0]).toBeLessThan( dispatchTelegramMessage.mock.invocationCallOrder[0], ); + expect(telegramInboundInfo).toHaveBeenCalledWith( + "Inbound message telegram:123 -> @openclaw_bot (direct, 11 chars)", + ); }); it("skips dispatch when no context is produced", async () => { @@ -112,15 +143,36 @@ describe("telegram bot message processor", () => { const processMessage = createTelegramMessageProcessor(baseDeps); await processSampleMessage(processMessage); expect(dispatchTelegramMessage).not.toHaveBeenCalled(); + expect(telegramInboundInfo).not.toHaveBeenCalled(); + }); + + it("formats Telegram inbound summaries without message content", () => { + expect( + formatTelegramInboundLogLine({ + from: "telegram:123", + to: "@openclaw_bot", + chatType: "direct", + body: "secret message", + }), + ).toBe("Inbound message telegram:123 -> @openclaw_bot (direct, 14 chars)"); + expect( + formatTelegramInboundLogLine({ + from: "telegram:group:-100", + to: "@openclaw_bot", + chatType: "group", + body: "", + mediaType: "image/jpeg", + }), + ).toBe("Inbound message telegram:group:-100 -> @openclaw_bot (group, image/jpeg, 13 chars)"); }); it("keeps dispatch running when the early typing cue fails", async () => { const sendTyping = vi.fn().mockRejectedValue(new Error("typing failed")); - buildTelegramMessageContext.mockResolvedValue({ - chatId: 123, - route: { sessionKey: "agent:main:main" }, - sendTyping, - }); + buildTelegramMessageContext.mockResolvedValue( + createMessageContext({ + sendTyping, + }), + ); const processMessage = createTelegramMessageProcessor(baseDeps); await processSampleMessage(processMessage); diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index f69d2c561b2..544ab337106 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -1,6 +1,11 @@ import type { ReplyToMode } from "openclaw/plugin-sdk/config-types"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-types"; -import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + createSubsystemLogger, + danger, + logVerbose, + shouldLogVerbose, +} from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramBotDeps } from "./bot-deps.js"; import { @@ -15,7 +20,19 @@ import { buildTelegramThreadParams } from "./bot/helpers.js"; import type { TelegramContext, TelegramStreamMode } from "./bot/types.js"; import type { TelegramReplyChainEntry } from "./message-cache.js"; -/** Dependencies injected once when creating the message processor. */ +const telegramInboundLog = createSubsystemLogger("gateway/channels/telegram").child("inbound"); + +export function formatTelegramInboundLogLine(params: { + from: string; + to: string; + chatType: string; + body: string; + mediaType?: string; +}): string { + const kindLabel = params.mediaType ? `, ${params.mediaType}` : ""; + return `Inbound message ${params.from} -> ${params.to} (${params.chatType}${kindLabel}, ${params.body.length} chars)`; +} + type TelegramMessageProcessorDeps = Omit< BuildTelegramMessageContextParams, "primaryCtx" | "allMedia" | "storeAllowFrom" | "options" @@ -113,6 +130,17 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep void context.sendTyping().catch((err) => { logVerbose(`telegram early typing cue failed for chat ${context.chatId}: ${String(err)}`); }); + telegramInboundLog.info( + formatTelegramInboundLogLine({ + from: context.ctxPayload.From, + to: context.primaryCtx.me?.username + ? `@${context.primaryCtx.me.username}` + : context.ctxPayload.To, + chatType: context.ctxPayload.ChatType, + body: context.ctxPayload.RawBody, + mediaType: allMedia[0]?.contentType, + }), + ); try { await dispatchTelegramMessage({ context, @@ -140,9 +168,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep "Something went wrong while processing your request. Please try again.", buildTelegramThreadParams(context.threadSpec), ); - } catch { - // Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid. - } + } catch {} } }; }; diff --git a/extensions/telegram/src/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts index 24cf08e5916..33ca4990253 100644 --- a/extensions/telegram/src/bot-native-command-menu.test.ts +++ b/extensions/telegram/src/bot-native-command-menu.test.ts @@ -69,8 +69,8 @@ describe("bot-native-command-menu", () => { 0, ); expect(totalText).toBeLessThanOrEqual(TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET); - expect(result.commandsToRegister.every((command) => command.description.length <= 56)).toBe( - true, + expect(result.commandsToRegister.filter((command) => command.description.length > 56)).toEqual( + [], ); }); diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index ff3204b7a3b..cdb5416a352 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -82,7 +82,7 @@ describe("registerTelegramNativeCommands skill allowlist integration", () => { const registeredCommands = await waitForRegisteredCommands(setMyCommands); - expect(registeredCommands.some((entry) => entry.command === "alpha_skill")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "beta_skill")).toBe(false); + expect(registeredCommands.map((entry) => entry.command)).toContain("alpha_skill"); + expect(registeredCommands.map((entry) => entry.command)).not.toContain("beta_skill"); }); }); diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index a883f377925..cbd2e6288f8 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -194,8 +194,9 @@ describe("registerTelegramNativeCommands", () => { }); const registeredCommands = await waitForRegisteredCommands(setMyCommands); - expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false); + const registeredCommandNames = registeredCommands.map((entry) => entry.command); + expect(registeredCommandNames).toContain("export_session"); + expect(registeredCommandNames).not.toContain("export-session"); const registeredHandlers = command.mock.calls.map(([name]) => name); expect(registeredHandlers).toContain("export_session"); @@ -230,16 +231,17 @@ describe("registerTelegramNativeCommands", () => { const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands.length).toBeGreaterThan(0); + const registeredCommandNames = registeredCommands.map((entry) => entry.command); for (const entry of registeredCommands) { expect(entry.command.includes("-")).toBe(false); expect(TELEGRAM_COMMAND_NAME_PATTERN.test(entry.command)).toBe(true); } - expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "custom_backup")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "plugin_status")).toBe(true); - expect(registeredCommands.some((entry) => entry.command === "plugin-status")).toBe(false); - expect(registeredCommands.some((entry) => entry.command === "custom-bad")).toBe(false); + expect(registeredCommandNames).toEqual( + expect.arrayContaining(["export_session", "custom_backup", "plugin_status"]), + ); + expect(registeredCommandNames).not.toContain("plugin-status"); + expect(registeredCommandNames).not.toContain("custom-bad"); }); it("prefixes native command menu callback data so callback handlers can preserve native routing", async () => { diff --git a/extensions/telegram/src/bot-update-tracker.test.ts b/extensions/telegram/src/bot-update-tracker.test.ts index 2352bdf1f8a..7c8d24080d3 100644 --- a/extensions/telegram/src/bot-update-tracker.test.ts +++ b/extensions/telegram/src/bot-update-tracker.test.ts @@ -15,10 +15,13 @@ async function flushTrackerMicrotasks() { } function deferred() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + if (!resolve) { + throw new Error("Expected tracker deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/telegram/src/bot.command-menu.test.ts b/extensions/telegram/src/bot.command-menu.test.ts index 926e425972c..45f0ebe6239 100644 --- a/extensions/telegram/src/bot.command-menu.test.ts +++ b/extensions/telegram/src/bot.command-menu.test.ts @@ -23,10 +23,13 @@ let createTelegramBot: ( const loadConfig = getLoadConfigMock(); function createSignal() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected command sync signal resolver to be initialized"); + } return { promise, resolve }; } @@ -46,6 +49,16 @@ function resolveSkillCommands(config: Parameters["skillCommands"]; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("createTelegramBot command menu", () => { beforeAll(async () => { ({ normalizeTelegramCommandName } = await import("./command-config.js")); @@ -172,7 +185,8 @@ describe("createTelegramBot command menu", () => { } expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" }); expect(registered).not.toContainEqual({ command: "status", description: "Custom status" }); - expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]); + expect(registered.find((command) => command.command === "status")).toEqual(nativeStatus); + expect(countMatching(registered, (command) => command.command === "status")).toBe(1); expect(errorSpy).toHaveBeenCalled(); }); @@ -211,6 +225,6 @@ describe("createTelegramBot command menu", () => { { command: "custom_generate", description: "Create an image" }, ]); const reserved = new Set(listNativeCommandSpecs().map((command) => command.name)); - expect(registered.filter((command) => reserved.has(command.command))).toEqual([]); + expect(registered.some((command) => reserved.has(command.command))).toBe(false); }); }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index f0bc091c6aa..0884886b941 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -202,7 +202,7 @@ describe("createTelegramBot", () => { const errorHandler = catchMock.mock.calls[0]?.[0]; expect(errorHandler).toBeTypeOf("function"); - expect(() => errorHandler?.(new Error("handler boom"))).not.toThrow(); + errorHandler?.(new Error("handler boom")); expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("telegram bot error:")); }); @@ -356,7 +356,7 @@ describe("createTelegramBot", () => { }); const events: string[] = []; - let releaseTopicTurn!: () => void; + let releaseTopicTurn: (() => void) | undefined; const topicGate = new Promise((resolve) => { releaseTopicTurn = resolve; }); @@ -395,6 +395,9 @@ describe("createTelegramBot", () => { expect(events).toEqual(["busy:start", "status"]); + if (!releaseTopicTurn) { + throw new Error("Expected Telegram topic turn release callback to be initialized"); + } releaseTopicTurn(); await busyPromise; expect(events).toEqual(["busy:start", "status", "busy:end"]); @@ -413,7 +416,7 @@ describe("createTelegramBot", () => { }); const startedBodies: string[] = []; - let releaseFirstTurn!: () => void; + let releaseFirstTurn: (() => void) | undefined; const firstTurnGate = new Promise((resolve) => { releaseFirstTurn = resolve; }); @@ -470,6 +473,9 @@ describe("createTelegramBot", () => { expect(startedBodies[0]).toContain("first message"); expect(sendMessageSpy).not.toHaveBeenCalled(); + if (!releaseFirstTurn) { + throw new Error("Expected first Telegram turn release callback to be initialized"); + } releaseFirstTurn(); await Promise.all([firstPromise, secondPromise]); @@ -523,7 +529,7 @@ describe("createTelegramBot", () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); const startedBodies: string[] = []; - let releaseFirstRun!: () => void; + let releaseFirstRun: (() => void) | undefined; const firstRunGate = new Promise((resolve) => { releaseFirstRun = resolve; }); @@ -617,6 +623,9 @@ describe("createTelegramBot", () => { expect(startedBodies).toHaveLength(1); expect(sendMessageSpy).not.toHaveBeenCalled(); + if (!releaseFirstRun) { + throw new Error("Expected first Telegram run release callback to be initialized"); + } releaseFirstRun(); await Promise.all([firstFlush, secondFlush]); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 6f7e74d6315..b31f9584c3e 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -50,10 +50,13 @@ const EYES_EMOJI = "\u{1F440}"; const HEART_EMOJI = "\u{2764}\u{FE0F}"; function createSignal() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected Telegram bot signal resolver to be initialized"); + } return { promise, resolve }; } @@ -1766,7 +1769,8 @@ describe("createTelegramBot", () => { mediaRef: "telegram:file/root-photo-1", }), ]); - expect(payload.ReplyChain?.[1]?.mediaPath).toBeTruthy(); + expect(payload.ReplyChain?.[1]?.mediaPath).toBeTypeOf("string"); + expect(payload.ReplyChain?.[1]?.mediaPath).not.toBe(""); expect(getFileSpy).toHaveBeenCalledWith("root-photo-1"); expect(mediaFetch).toHaveBeenCalledTimes(1); }); diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index b6a9da89908..637102e1abe 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -181,6 +181,16 @@ function resolveMediaWithDefaults( }); } +function requireResolvedMedia( + result: Awaited>, + label: string, +) { + if (!result) { + throw new Error(`expected ${label} media result`); + } + return result; +} + async function expectTransientGetFileRetrySuccess() { const getFile = setupTransientGetFileRetry(); const promise = resolveMediaWithDefaults(makeCtx("voice", getFile)); @@ -294,7 +304,7 @@ describe("resolveMedia getFile retry", () => { it("still retries transient errors even after encountering file too big in different call", async () => { const result = await expectTransientGetFileRetrySuccess(); // Should retry transient errors. - expect(result).not.toBeNull(); + expect(result?.path).toBe("/tmp/file_0.oga"); }); it("retries getFile for stickers on transient failure", async () => { @@ -368,7 +378,7 @@ describe("resolveMedia getFile retry", () => { transport: callerTransport, }); - expect(result).not.toBeNull(); + expect(result?.path).toBe("/tmp/file_42---uuid.pdf"); expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ fetchImpl: callerFetch, @@ -402,7 +412,7 @@ describe("resolveMedia getFile retry", () => { transport: callerTransport, }); - expect(result).not.toBeNull(); + expect(result?.path).toBe("/tmp/file_0.webp"); expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ fetchImpl: callerFetch, @@ -628,7 +638,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "my-song.mp3", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "audio filename"); }); it("passes video.file_name to saveMediaBuffer", async () => { @@ -653,7 +663,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "presentation.mp4", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "video filename"); }); it("falls back to fetched.fileName when telegram file_name is absent", async () => { @@ -670,7 +680,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "file_42.pdf", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "fetched filename fallback"); }); it("falls back to filePath when neither telegram nor fetched fileName is available", async () => { @@ -687,7 +697,7 @@ describe("resolveMedia original filename preservation", () => { MAX_MEDIA_BYTES, "documents/file_42.pdf", ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "file path fallback"); }); it("allows a configured custom apiRoot host while keeping the hostname allowlist", async () => { @@ -708,7 +718,7 @@ describe("resolveMedia original filename preservation", () => { }, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "custom apiRoot allowlist"); }); it("opts into private-network Telegram media downloads only when explicitly configured", async () => { @@ -727,7 +737,7 @@ describe("resolveMedia original filename preservation", () => { }, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "private network opt-in"); }); it("constructs correct download URL with custom apiRoot for documents", async () => { @@ -744,7 +754,7 @@ describe("resolveMedia original filename preservation", () => { url: `${customApiRoot}/file/bot${BOT_TOKEN}/documents/file_42.pdf`, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "custom apiRoot document URL"); }); it("constructs correct download URL with custom apiRoot for stickers", async () => { @@ -769,6 +779,6 @@ describe("resolveMedia original filename preservation", () => { url: `${customApiRoot}/file/bot${BOT_TOKEN}/stickers/file_0.webp`, }), ); - expect(result).not.toBeNull(); + requireResolvedMedia(result, "custom apiRoot sticker URL"); }); }); diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 0526e72894a..20028fe6e4e 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -427,7 +427,8 @@ describe("deliverReplies", () => { }); expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]).toBeTypeOf("string"); + expect(sendMessage.mock.calls[0]?.[1]).not.toBe(""); expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); }); @@ -445,7 +446,8 @@ describe("deliverReplies", () => { }); expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]).toBeTypeOf("string"); + expect(sendMessage.mock.calls[0]?.[1]).not.toBe(""); expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); }); diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index 9a75bbe8b26..ef4c7ce8be9 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -225,7 +225,6 @@ describe("normalizeForwardedContext", () => { date: 123, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Ada Lovelace (@ada)"); expect(ctx?.fromType).toBe("user"); expect(ctx?.fromId).toBe("42"); @@ -238,7 +237,6 @@ describe("normalizeForwardedContext", () => { const ctx = normalizeForwardedContext({ forward_origin: { type: "hidden_user", sender_user_name: "Hidden Name", date: 456 }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Hidden Name"); expect(ctx?.fromType).toBe("hidden_user"); expect(ctx?.fromTitle).toBe("Hidden Name"); @@ -260,7 +258,6 @@ describe("normalizeForwardedContext", () => { message_id: 42, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Tech News (Editor)"); expect(ctx?.fromType).toBe("channel"); expect(ctx?.fromId).toBe("-1001234"); @@ -285,7 +282,6 @@ describe("normalizeForwardedContext", () => { author_signature: "Admin", }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("Discussion Group (Admin)"); expect(ctx?.fromType).toBe("chat"); expect(ctx?.fromId).toBe("-1005678"); @@ -305,7 +301,6 @@ describe("normalizeForwardedContext", () => { message_id: 1, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.fromSignature).toBe("New Sig"); expect(ctx?.from).toBe("My Channel (New Sig)"); }); @@ -320,7 +315,6 @@ describe("normalizeForwardedContext", () => { message_id: 1, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.fromSignature).toBeUndefined(); expect(ctx?.from).toBe("Updates"); }); @@ -334,7 +328,6 @@ describe("normalizeForwardedContext", () => { message_id: 1, }, } as any); - expect(ctx).not.toBeNull(); expect(ctx?.from).toBe("News"); expect(ctx?.fromSignature).toBeUndefined(); expect(ctx?.fromChatType).toBe("channel"); @@ -364,7 +357,6 @@ describe("describeReplyTarget", () => { from: { id: 42, first_name: "Alice", is_bot: false }, }, } as any); - expect(result).not.toBeNull(); expect(result?.body).toBe("Original message"); expect(result?.sender).toBe("Alice"); expect(result?.id).toBe("1"); @@ -493,7 +485,6 @@ describe("describeReplyTarget", () => { }, }, } as any); - expect(result).not.toBeNull(); expect(result?.body).toBe("This is the forwarded content"); expect(result?.id).toBe("2"); expect(result?.forwardedFrom).toMatchObject({ @@ -524,7 +515,6 @@ describe("describeReplyTarget", () => { }, }, } as any); - expect(result).not.toBeNull(); expect(result?.forwardedFrom).toMatchObject({ from: "Tech News (Editor)", fromType: "channel", @@ -584,7 +574,6 @@ describe("describeReplyTarget", () => { }, }, } as any); - expect(result).not.toBeNull(); expect(result?.id).toBe("4"); expect(result?.forwardedFrom?.from).toBe("Eve Stone (@eve)"); expect(result?.forwardedFrom?.fromType).toBe("user"); diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index 41002246a86..cad4fc4ab16 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -389,6 +389,63 @@ describe("createTelegramDraftStream", () => { }); }); + it("continues in a new message when rendered preview crosses maxChars", async () => { + const api = createMockDraftApi(); + api.sendMessage + .mockResolvedValueOnce({ message_id: 17 }) + .mockResolvedValueOnce({ message_id: 42 }); + const stream = createDraftStream(api, { maxChars: 20 }); + + stream.update("Hello world"); + await stream.flush(); + stream.update("Hello world foo bar baz qux"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello world", undefined); + expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "foo bar baz qux", undefined); + }); + + it("splits a first oversized rendered preview into chained messages", async () => { + const api = createMockDraftApi(); + api.sendMessage + .mockResolvedValueOnce({ message_id: 17 }) + .mockResolvedValueOnce({ message_id: 42 }); + const stream = createDraftStream(api, { maxChars: 10 }); + + stream.update("1234567890ABCDEFGHIJ"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "1234567890", undefined); + expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "ABCDEFGHIJ", undefined); + }); + + it("retains overflow preview pages", async () => { + const api = createMockDraftApi(); + api.sendMessage + .mockResolvedValueOnce({ message_id: 17 }) + .mockResolvedValueOnce({ message_id: 42 }); + const onSupersededPreview = vi.fn(); + const stream = createDraftStream(api, { + maxChars: 20, + onSupersededPreview, + }); + + stream.update("Hello world"); + await stream.flush(); + stream.update("Hello world foo bar baz qux"); + await stream.flush(); + + expect(onSupersededPreview).toHaveBeenCalledWith({ + messageId: 17, + textSnapshot: "Hello world", + parseMode: undefined, + visibleSinceMs: expect.any(Number), + retain: true, + }); + }); + it("enforces maxChars after renderText expansion", async () => { const api = createMockDraftApi(); const warn = vi.fn(); diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index f9ab22c88d7..9a532fc5255 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -53,8 +53,38 @@ type SupersededTelegramPreview = { textSnapshot: string; parseMode?: "HTML"; visibleSinceMs?: number; + retain?: boolean; }; +function renderTelegramDraftPreview( + text: string, + renderText: ((text: string) => TelegramDraftPreview) | undefined, +): TelegramDraftPreview { + const trimmed = text.trimEnd(); + return renderText?.(trimmed) ?? { text: trimmed }; +} + +function findTelegramDraftChunkLength( + text: string, + maxChars: number, + renderText: ((text: string) => TelegramDraftPreview) | undefined, +): number { + let best = 0; + let low = 1; + let high = text.length; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const renderedText = renderTelegramDraftPreview(text.slice(0, mid), renderText).text.trimEnd(); + if (renderedText && renderedText.length <= maxChars) { + best = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} + export function createTelegramDraftStream(params: { api: Bot["api"]; chatId: Parameters[0]; @@ -98,6 +128,8 @@ export function createTelegramDraftStream(params: { let lastSentParseMode: "HTML" | undefined; let previewRevision = 0; let generation = 0; + let deliveredTextOffset = 0; + let resetStreamToNewMessage: (options?: { keepPending?: boolean; resetOffset?: boolean }) => void; type PreviewSendParams = { renderedText: string; renderedParseMode: "HTML" | undefined; @@ -198,13 +230,45 @@ export function createTelegramDraftStream(params: { if (!trimmed) { return false; } - const rendered = params.renderText?.(trimmed) ?? { text: trimmed }; + const currentText = trimmed.slice(deliveredTextOffset).trimStart(); + if (!currentText) { + return false; + } + const rendered = renderTelegramDraftPreview(currentText, params.renderText); const renderedText = rendered.text.trimEnd(); const renderedParseMode = rendered.parseMode; if (!renderedText) { return false; } if (renderedText.length > maxChars) { + if (lastDeliveredText.length > deliveredTextOffset) { + const supersededMessageId = streamMessageId; + const supersededTextSnapshot = lastSentText; + const supersededParseMode = lastSentParseMode; + const supersededVisibleSinceMs = streamVisibleSinceMs; + deliveredTextOffset = lastDeliveredText.length; + resetStreamToNewMessage({ keepPending: true, resetOffset: false }); + if (typeof supersededMessageId === "number") { + params.onSupersededPreview?.({ + messageId: supersededMessageId, + textSnapshot: supersededTextSnapshot, + parseMode: supersededParseMode, + visibleSinceMs: supersededVisibleSinceMs, + retain: true, + }); + } + return await sendOrEditStreamMessage(trimmed); + } + const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText); + if (chunkLength > 0) { + const sent = await sendOrEditStreamMessage( + trimmed.slice(0, deliveredTextOffset) + currentText.slice(0, chunkLength), + ); + if (!sent) { + return false; + } + return await sendOrEditStreamMessage(trimmed); + } streamState.stopped = true; params.warn?.( `telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`, @@ -248,6 +312,24 @@ export function createTelegramDraftStream(params: { sendOrEditStreamMessage, }); + resetStreamToNewMessage = (options) => { + streamState.stopped = false; + streamState.final = false; + generation += 1; + messageSendAttempted = false; + streamMessageId = undefined; + streamVisibleSinceMs = undefined; + lastSentText = ""; + lastSentParseMode = undefined; + if (options?.resetOffset !== false) { + deliveredTextOffset = 0; + } + if (!options?.keepPending) { + loop.resetPending(); + } + loop.resetThrottleWindow(); + }; + const clear = async () => { const messageId = await takeMessageIdAfterStop({ stopForClear, @@ -272,16 +354,7 @@ export function createTelegramDraftStream(params: { }; const forceNewMessage = () => { - streamState.stopped = false; - streamState.final = false; - generation += 1; - messageSendAttempted = false; - streamMessageId = undefined; - streamVisibleSinceMs = undefined; - lastSentText = ""; - lastSentParseMode = undefined; - loop.resetPending(); - loop.resetThrottleWindow(); + resetStreamToNewMessage(); }; const materialize = async (): Promise => { diff --git a/extensions/telegram/src/format.wrap-md.test.ts b/extensions/telegram/src/format.wrap-md.test.ts index 324c1ccdc22..8c5083312ee 100644 --- a/extensions/telegram/src/format.wrap-md.test.ts +++ b/extensions/telegram/src/format.wrap-md.test.ts @@ -9,27 +9,17 @@ import { type TelegramChunk = ReturnType[number]; function expectHtmlChunkLengthsAtMost(chunks: TelegramChunk[], limit: number) { - expect( - chunks - .map((chunk, index) => ({ index, htmlLength: chunk.html.length, text: chunk.text })) - .filter((chunk) => chunk.htmlLength > limit), - ).toEqual([]); + expect(chunks.some((chunk) => chunk.html.length > limit)).toBe(false); } function expectNonBlankTextChunks(chunks: TelegramChunk[]) { - expect( - chunks - .map((chunk, index) => ({ index, text: chunk.text })) - .filter((chunk) => chunk.text.trim().length === 0), - ).toEqual([]); + expect(chunks.some((chunk) => chunk.text.trim().length === 0)).toBe(false); } function expectHtmlChunksWrappedWith(chunks: TelegramChunk[], prefix: string, suffix: string) { expect( - chunks - .map((chunk, index) => ({ index, html: chunk.html })) - .filter((chunk) => !chunk.html.startsWith(prefix) || !chunk.html.endsWith(suffix)), - ).toEqual([]); + chunks.every((chunk) => chunk.html.startsWith(prefix) && chunk.html.endsWith(suffix)), + ).toBe(true); } describe("wrapFileReferencesInHtml", () => { @@ -236,7 +226,6 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { it("gracefully returns the original chunk when tag overhead exceeds the limit", () => { const input = "**ab**"; - expect(() => markdownToTelegramChunks(input, 6)).not.toThrow(); const chunks = markdownToTelegramChunks(input, 6); expect(chunks).toHaveLength(1); expect(chunks[0]?.text).toBe("ab"); diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 75ea412935c..0383c56a0d3 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -170,10 +170,13 @@ const makeAbortRunner = (abort: AbortController, beforeAbort?: () => void): Runn makeRunnerStub({ task: createAbortTask(abort, beforeAbort) }); function createSignal() { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected Telegram monitor signal resolver to be initialized"); + } return { promise, resolve }; } diff --git a/extensions/telegram/src/reasoning-lane-coordinator.ts b/extensions/telegram/src/reasoning-lane-coordinator.ts index 42acc89fb7f..b18e5f92db3 100644 --- a/extensions/telegram/src/reasoning-lane-coordinator.ts +++ b/extensions/telegram/src/reasoning-lane-coordinator.ts @@ -62,7 +62,10 @@ type TelegramReasoningSplit = { answerText?: string; }; -export function splitTelegramReasoningText(text?: string): TelegramReasoningSplit { +export function splitTelegramReasoningText( + text?: string, + isReasoning?: boolean, +): TelegramReasoningSplit { if (typeof text !== "string") { return {}; } @@ -81,6 +84,10 @@ export function splitTelegramReasoningText(text?: string): TelegramReasoningSpli const taggedReasoning = extractThinkingFromTaggedStreamOutsideCode(text); const strippedAnswer = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + if (isReasoning === true) { + return { reasoningText: formatReasoningMessage(taggedReasoning || strippedAnswer || text) }; + } + if (!taggedReasoning && strippedAnswer === text) { return { answerText: text }; } diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index da41451e02c..81dcb6f4bde 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -2002,7 +2002,7 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(4); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); - expect(plainFallbackCalls.every((call) => !String(call?.[1] ?? "").includes("<"))).toBe(true); + expect(plainFallbackCalls.some((call) => String(call?.[1] ?? "").includes("<"))).toBe(false); expect(res.messageId).toBe("91"); }); @@ -2057,7 +2057,7 @@ describe("sendMessageTelegram", () => { }); expect(sendMessage).toHaveBeenCalledTimes(3); - expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === undefined)).toBe(true); + expect(sendMessage.mock.calls.some((call) => call[2]?.parse_mode !== undefined)).toBe(false); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toBe(plainText); expect(res.messageId).toBe("96"); }); diff --git a/extensions/telegram/src/status.test.ts b/extensions/telegram/src/status.test.ts index f212417d96a..7025b15d736 100644 --- a/extensions/telegram/src/status.test.ts +++ b/extensions/telegram/src/status.test.ts @@ -36,9 +36,14 @@ describe("collectTelegramStatusIssues", () => { }), ]), ); - expect(issues.some((issue) => issue.message.includes("privacy mode"))).toBe(true); - expect(issues.some((issue) => issue.message.includes('uses "*"'))).toBe(true); - expect(issues.some((issue) => issue.message.includes("unresolvedGroups=2"))).toBe(true); + const issueMessages = issues.map((issue) => issue.message); + expect(issueMessages).toEqual( + expect.arrayContaining([expect.stringContaining("privacy mode")]), + ); + expect(issueMessages).toEqual(expect.arrayContaining([expect.stringContaining('uses "*"')])); + expect(issueMessages).toEqual( + expect.arrayContaining([expect.stringContaining("unresolvedGroups=2")]), + ); }); it("reports unreachable groups with match metadata", () => { diff --git a/extensions/telegram/src/sticker-cache.test.ts b/extensions/telegram/src/sticker-cache.test.ts index 755970245dc..966bbbdd01a 100644 --- a/extensions/telegram/src/sticker-cache.test.ts +++ b/extensions/telegram/src/sticker-cache.test.ts @@ -59,7 +59,11 @@ describe("sticker-cache", () => { }; stickerCache.cacheSticker(sticker); - expect(stickerCache.getCachedSticker("unique123")).not.toBeNull(); + const cachedSticker = stickerCache.getCachedSticker("unique123"); + if (!cachedSticker) { + throw new Error("expected cached Telegram sticker"); + } + expect(cachedSticker.fileUniqueId).toBe("unique123"); jsonStoreMocks.store.value = null; @@ -146,19 +150,28 @@ describe("sticker-cache", () => { it("finds stickers by description substring", () => { const results = stickerCache.searchStickers("fox"); expect(results).toHaveLength(2); - expect(results.every((s) => s.description.toLowerCase().includes("fox"))).toBe(true); + expect(results.map((sticker) => sticker.fileUniqueId)).toEqual([ + "fox-unique-1", + "fox-unique-2", + ]); }); it("finds stickers by emoji", () => { const results = stickerCache.searchStickers("🦊"); expect(results).toHaveLength(2); - expect(results.every((s) => s.emoji === "🦊")).toBe(true); + expect(results.map((sticker) => sticker.fileUniqueId)).toEqual([ + "fox-unique-1", + "fox-unique-2", + ]); }); it("finds stickers by set name", () => { const results = stickerCache.searchStickers("CuteFoxes"); expect(results).toHaveLength(2); - expect(results.every((s) => s.setName === "CuteFoxes")).toBe(true); + expect(results.map((sticker) => sticker.fileUniqueId)).toEqual([ + "fox-unique-1", + "fox-unique-2", + ]); }); it("respects limit parameter", () => { diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index 8596f1e5b98..d9b84a13f67 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -94,7 +94,7 @@ describe("tlon channel message adapter", () => { const proveReplyThread = async () => { mocks.sendText.mockClear(); - const result = await adapter.send!.text!({ + const result = await sendText({ cfg, to: "chat/~nec/general", text: "threaded", @@ -114,14 +114,14 @@ describe("tlon channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "tlonMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveReplyThread, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); diff --git a/extensions/tlon/src/core.test.ts b/extensions/tlon/src/core.test.ts index 411a18e7c3d..dd7c8673262 100644 --- a/extensions/tlon/src/core.test.ts +++ b/extensions/tlon/src/core.test.ts @@ -26,7 +26,16 @@ const tlonTestPlugin = { }: { cfg: OpenClawConfig; allowFrom: Array | undefined | null; - }) => (allowFrom ?? []).map((entry) => normalizeShip(String(entry))).filter(Boolean), + }) => { + const entries: string[] = []; + for (const entry of allowFrom ?? []) { + const normalized = normalizeShip(String(entry)); + if (normalized) { + entries.push(normalized); + } + } + return entries; + }, }, setup: { resolveAccountId: ({ accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => diff --git a/extensions/together/video-generation-provider.test.ts b/extensions/together/video-generation-provider.test.ts index 27097c183a9..e268f75dd50 100644 --- a/extensions/together/video-generation-provider.test.ts +++ b/extensions/together/video-generation-provider.test.ts @@ -57,7 +57,11 @@ describe("together video generation provider", () => { }), ); expect(result.videos).toHaveLength(1); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated Together video"); + } + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual( expect.objectContaining({ videoId: "video_123", diff --git a/extensions/tts-local-cli/speech-provider.test.ts b/extensions/tts-local-cli/speech-provider.test.ts index 75b09ee8f1c..4537e41e2d3 100644 --- a/extensions/tts-local-cli/speech-provider.test.ts +++ b/extensions/tts-local-cli/speech-provider.test.ts @@ -80,7 +80,18 @@ describe("buildCliSpeechProvider", () => { if (typeof outputPath !== "string") { throw new Error("missing ffmpeg output path"); } - writeFileSync(outputPath, Buffer.from(`converted:${path.extname(outputPath)}`)); + const forcedFormatIndex = args.lastIndexOf("-f"); + const forcedFormat = + forcedFormatIndex >= 0 && typeof args[forcedFormatIndex + 1] === "string" + ? args[forcedFormatIndex + 1] + : undefined; + const extension = + forcedFormat === "s16le" + ? ".pcm" + : forcedFormat + ? `.${forcedFormat}` + : path.extname(outputPath.replace(/\.part$/, "")); + writeFileSync(outputPath, Buffer.from(`converted:${extension}`)); }); }); diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index aece764806c..283057ce748 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -275,11 +275,11 @@ async function convertAudio( const outputPath = path.join(outputDir, outputFileName); const args = ["-y", "-i", inputPath]; if (target === "opus") { - args.push("-c:a", "libopus", "-b:a", "64k"); + args.push("-c:a", "libopus", "-b:a", "64k", "-f", "opus"); } else if (target === "wav") { - args.push("-c:a", "pcm_s16le"); + args.push("-c:a", "pcm_s16le", "-f", "wav"); } else { - args.push("-c:a", "libmp3lame", "-b:a", "128k"); + args.push("-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3"); } await writeExternalFileWithinRoot({ rootDir: outputDir, diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 97bb6989f50..472b769e144 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -36,21 +36,18 @@ describe("getAccountConfig", () => { it("returns account config for valid account ID (multi-account)", () => { const result = getAccountConfig(mockMultiAccountConfig, "default"); - expect(result).not.toBeNull(); expect(result?.username).toBe("testbot"); }); it("returns account config for default account (simplified config)", () => { const result = getAccountConfig(mockSimplifiedConfig, "default"); - expect(result).not.toBeNull(); expect(result?.username).toBe("testbot"); }); it("returns non-default account from multi-account config", () => { const result = getAccountConfig(mockMultiAccountConfig, "secondary"); - expect(result).not.toBeNull(); expect(result?.username).toBe("secondbot"); }); diff --git a/extensions/twitch/src/setup-surface.test.ts b/extensions/twitch/src/setup-surface.test.ts index c1cc263ae22..4b102f3f97f 100644 --- a/extensions/twitch/src/setup-surface.test.ts +++ b/extensions/twitch/src/setup-surface.test.ts @@ -200,8 +200,10 @@ describe("setup surface helpers", () => { ); // Should return config with username and clientId - expect(result).not.toBeNull(); - const defaultAccount = result?.cfg.channels?.twitch?.accounts?.default as + if (!result) { + throw new Error("expected Twitch env-token setup result"); + } + const defaultAccount = result.cfg.channels?.twitch?.accounts?.default as | { username?: string; clientId?: string } | undefined; expect(defaultAccount?.username).toBe("testbot"); diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index cefc333b29a..d11362c5e3c 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -295,7 +295,7 @@ describe("TwitchClientManager", () => { }); it("should handle disconnecting non-existent client gracefully", async () => { - // disconnect doesn't throw, just does nothing + // Missing clients are ignored. await manager.disconnect(testAccount); expect(mockQuit).not.toHaveBeenCalled(); }); @@ -326,7 +326,7 @@ describe("TwitchClientManager", () => { }); it("should handle empty client list gracefully", async () => { - // disconnectAll doesn't throw, just does nothing + // Empty client sets are ignored. await manager.disconnectAll(); expect(mockQuit).not.toHaveBeenCalled(); }); @@ -436,7 +436,6 @@ describe("TwitchClientManager", () => { id: "msg123", }); - expect(capturedMessage).not.toBeNull(); expect(capturedMessage?.username).toBe("testuser"); expect(capturedMessage?.displayName).toBe("TestUser"); expect(capturedMessage?.userId).toBe("12345"); diff --git a/extensions/vercel-ai-gateway/thinking.test.ts b/extensions/vercel-ai-gateway/thinking.test.ts index 58a3d60cb88..54fafefcfb1 100644 --- a/extensions/vercel-ai-gateway/thinking.test.ts +++ b/extensions/vercel-ai-gateway/thinking.test.ts @@ -5,6 +5,16 @@ import { import { describe, expect, it } from "vitest"; import plugin from "./index.js"; +function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] { + const ids: string[] = []; + for (const level of levels ?? []) { + if (level.id === "xhigh" || level.id === "max") { + ids.push(level.id); + } + } + return ids; +} + describe("vercel ai gateway thinking profile", () => { async function getProvider() { const { providers } = await registerProviderPlugin({ @@ -49,9 +59,7 @@ describe("vercel ai gateway thinking profile", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect( - profile?.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), - ).toEqual([]); + expect(collectLegacyExtendedLevelIds(profile?.levels)).toEqual([]); }); it("falls through for unsupported OpenAI or untrusted namespaced refs", async () => { diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 2428e1b8b41..638de35c7cc 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -332,7 +332,7 @@ describe("processEvent (functional)", () => { expect(answeredCallId).toBe("call-2"); }); - it("when hangup throws, logs and does not throw", () => { + it("removes active call even when hangup rejects", () => { const provider = createProvider({ hangupCall: async (): Promise => { throw new Error("provider down"); @@ -348,7 +348,7 @@ describe("processEvent (functional)", () => { from: "+15553333333", }); - expect(() => processEvent(ctx, event)).not.toThrow(); + processEvent(ctx, event); expect(ctx.activeCalls.size).toBe(0); }); diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index ca97f288614..1a6a194a255 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -40,12 +40,15 @@ const createDeferred = (): { resolve: () => void; reject: (error: Error) => void; } => { - let resolve!: () => void; - let reject!: (error: Error) => void; + let resolve: (() => void) | undefined; + let reject: ((error: Error) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; }; @@ -80,10 +83,13 @@ describe("MediaStreamHandler TTS queue", () => { const started: number[] = []; const finished: number[] = []; - let resolveFirst!: () => void; + let resolveFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { resolveFirst = resolve; }); + if (!resolveFirst) { + throw new Error("Expected first TTS gate resolver to be initialized"); + } const first = handler.queueTts("stream-1", async () => { started.push(1); @@ -557,7 +563,6 @@ describe("MediaStreamHandler security hardening", () => { expect(secondSocket.write).toHaveBeenCalledOnce(); expect(secondSocket.destroy).toHaveBeenCalledOnce(); - expect(upgradeCallback).not.toBeNull(); const completeUpgrade = upgradeCallback as ((ws: WebSocket) => void) | null; if (!completeUpgrade) { throw new Error("Expected upgrade callback to be registered"); diff --git a/extensions/voice-call/src/realtime-agent-context.ts b/extensions/voice-call/src/realtime-agent-context.ts index 3d4630658ae..169fa11f7d1 100644 --- a/extensions/voice-call/src/realtime-agent-context.ts +++ b/extensions/voice-call/src/realtime-agent-context.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { buildRealtimeVoiceAgentConsultPolicyInstructions } from "openclaw/plugin-sdk/realtime-voice"; import { root } from "openclaw/plugin-sdk/security-runtime"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; @@ -76,28 +77,6 @@ async function readWorkspaceVoiceContextFiles(params: { return sections; } -function buildConsultPolicyGuidance( - config: Pick, -): string | undefined { - if (config.toolPolicy === "none" || config.consultPolicy === "auto") { - return undefined; - } - if (config.consultPolicy === "always") { - return [ - "Consult behavior:", - "- Call openclaw_agent_consult before every substantive answer.", - "- You may answer directly only for greetings, acknowledgements, brief latency tests, or filler while waiting for the consult result.", - "- After the consult result arrives, speak that result concisely.", - ].join("\n"); - } - return [ - "Consult behavior:", - "- Answer directly for greetings, acknowledgements, simple conversational glue, and brief latency tests.", - "- Call openclaw_agent_consult before answering requests that need facts, memory, current information, tools, workspace state, or the user's OpenClaw-specific context.", - "- Keep spoken replies concise and natural.", - ].join("\n"); -} - export async function buildRealtimeVoiceInstructions(params: { baseInstructions: string; config: VoiceCallConfig; @@ -106,7 +85,7 @@ export async function buildRealtimeVoiceInstructions(params: { }): Promise { const { config } = params; const sections: string[] = [params.baseInstructions]; - const consultGuidance = buildConsultPolicyGuidance(config.realtime); + const consultGuidance = buildRealtimeVoiceAgentConsultPolicyInstructions(config.realtime); if (consultGuidance) { sections.push(consultGuidance); } diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 9a7230fa3c2..ce4b2e192a4 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -443,7 +443,8 @@ describe("verifyPlivoWebhook", () => { ); expect(first.ok).toBe(true); - expect(first.verifiedRequestKey).toEqual(expect.any(String)); + expect(first.verifiedRequestKey).toBeTypeOf("string"); + expect(first.verifiedRequestKey).not.toBe(""); expect(second.ok).toBe(true); expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); expect(second.isReplay).toBe(true); diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 83254d182fd..6792d366829 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -1081,14 +1081,19 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { const server = new VoiceCallWebhookServer(config, manager, twilioProvider); let enteredReads = 0; - let releaseReads!: () => void; - let unblockReadBodies!: () => void; + let releaseReads: (() => void) | undefined; + let unblockReadBodies: (() => void) | undefined; const enteredEightReads = new Promise((resolve) => { releaseReads = resolve; }); const unblockReads = new Promise((resolve) => { unblockReadBodies = resolve; }); + if (!releaseReads || !unblockReadBodies) { + throw new Error("Expected webhook read gates to be initialized"); + } + const releaseEnteredReads = releaseReads; + const unblockStartedReads = unblockReadBodies; const readBodySpy = vi.spyOn( server as unknown as { readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; @@ -1098,7 +1103,7 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { readBodySpy.mockImplementation(async () => { enteredReads += 1; if (enteredReads === 8) { - releaseReads(); + releaseEnteredReads(); } if (enteredReads <= 8) { await unblockReads; @@ -1118,12 +1123,12 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { expect(rejected.status).toBe(429); expect(await rejected.text()).toBe("Too Many Requests"); - unblockReadBodies(); + unblockStartedReads(); const settled = await Promise.all(inFlightRequests); expect(settled.map((response) => response.status)).toEqual(Array(8).fill(200)); } finally { - unblockReadBodies(); + unblockStartedReads(); readBodySpy.mockRestore(); await server.stop(); } @@ -1148,14 +1153,19 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { ).runWebhookPipeline.bind(server); let enteredReads = 0; - let releaseReads!: () => void; - let unblockReadBodies!: () => void; + let releaseReads: (() => void) | undefined; + let unblockReadBodies: (() => void) | undefined; const enteredEightReads = new Promise((resolve) => { releaseReads = resolve; }); const unblockReads = new Promise((resolve) => { unblockReadBodies = resolve; }); + if (!releaseReads || !unblockReadBodies) { + throw new Error("Expected webhook read gates to be initialized"); + } + const releaseEnteredReads = releaseReads; + const unblockStartedReads = unblockReadBodies; const readBodySpy = vi.spyOn( server as unknown as { readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; @@ -1165,7 +1175,7 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { readBodySpy.mockImplementation(async () => { enteredReads += 1; if (enteredReads === 8) { - releaseReads(); + releaseEnteredReads(); } await unblockReads; return "CallSid=CA123&SpeechResult=hello"; @@ -1193,12 +1203,12 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { expect(rejected.body).toBe("Too Many Requests"); expect(readBodySpy).toHaveBeenCalledTimes(8); - unblockReadBodies(); + unblockStartedReads(); const settled = await Promise.all(inFlightRequests); expect(settled.map((response) => response.statusCode)).toEqual(Array(8).fill(200)); } finally { - unblockReadBodies(); + unblockStartedReads(); readBodySpy.mockRestore(); } }); diff --git a/extensions/volcengine/tts.test.ts b/extensions/volcengine/tts.test.ts index 9af0ad8cae7..34ea952b6a5 100644 --- a/extensions/volcengine/tts.test.ts +++ b/extensions/volcengine/tts.test.ts @@ -108,7 +108,11 @@ describe("Volcengine speech provider", () => { }); it("lists voices with locale and gender", async () => { - const voices = await provider.listVoices!({}); + const listVoices = provider.listVoices; + if (!listVoices) { + throw new Error("Expected Volcengine provider listVoices"); + } + const voices = await listVoices({}); expect(voices.length).toBeGreaterThan(0); expect(voices[0]).toMatchObject({ locale: "en-US" }); expect(voices[0].gender).toMatch(/^(female|male)$/u); diff --git a/extensions/vydra/video-generation-provider.test.ts b/extensions/vydra/video-generation-provider.test.ts index f1dd7bb83ed..3e4813f81da 100644 --- a/extensions/vydra/video-generation-provider.test.ts +++ b/extensions/vydra/video-generation-provider.test.ts @@ -54,8 +54,13 @@ describe("vydra video-generation provider", () => { "https://www.vydra.ai/api/v1/jobs/job-123", expect.objectContaining({ method: "GET" }), ); - expect(result.videos[0]?.mimeType).toBe("video/webm"); - expect(result.videos[0]?.fileName).toBe("video-1.webm"); + expect(result.videos).toHaveLength(1); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated Vydra video"); + } + expect(video.mimeType).toBe("video/webm"); + expect(video.fileName).toBe("video-1.webm"); expect(result.metadata).toEqual({ jobId: "job-123", videoUrl: "https://cdn.vydra.ai/generated/test.mp4", @@ -112,7 +117,12 @@ describe("vydra video-generation provider", () => { }), }), ); - expect(result.videos[0]?.mimeType).toBe("video/mp4"); + expect(result.videos).toHaveLength(1); + const [video] = result.videos; + if (!video) { + throw new Error("Expected generated Vydra kling video"); + } + expect(video.mimeType).toBe("video/mp4"); expect(result.metadata).toEqual({ jobId: "job-kling", videoUrl: "https://cdn.vydra.ai/generated/kling.mp4", diff --git a/extensions/web-readability/web-content-extractor.test.ts b/extensions/web-readability/web-content-extractor.test.ts index 91f526a8bdd..3eabc9fd860 100644 --- a/extensions/web-readability/web-content-extractor.test.ts +++ b/extensions/web-readability/web-content-extractor.test.ts @@ -25,6 +25,17 @@ const SAMPLE_HTML = ` `; +type ReadabilityResult = Awaited< + ReturnType["extract"]> +>; + +function requireReadabilityResult(result: ReadabilityResult): NonNullable { + if (!result) { + throw new Error("expected readability extraction result"); + } + return result; +} + describe("web readability extractor", () => { it("extracts readable text", async () => { const extractor = createReadabilityWebContentExtractor(); @@ -33,8 +44,9 @@ describe("web readability extractor", () => { url: "https://example.com/article", extractMode: "text", }); - expect(result?.text).toContain("Main content starts here"); - expect(result?.title).toBe("Example Article"); + const extracted = requireReadabilityResult(result); + expect(extracted.text).toContain("Main content starts here"); + expect(extracted.title).toBe("Example Article"); }); it("extracts readable markdown", async () => { @@ -44,7 +56,8 @@ describe("web readability extractor", () => { url: "https://example.com/article", extractMode: "markdown", }); - expect(result?.text).toContain("Main content starts here"); - expect(result?.title).toBe("Example Article"); + const extracted = requireReadabilityResult(result); + expect(extracted.text).toContain("Main content starts here"); + expect(extracted.title).toBe("Example Article"); }); }); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index bf19703872d..2b9c0493d10 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -246,7 +246,10 @@ describe("web auto-reply connection", () => { await vi.waitFor( () => { expect(scripted.getListenerCount()).toBe(1); - expect(getActiveWebListener(accountId)).not.toBeNull(); + const activeListener = getActiveWebListener(accountId); + if (!activeListener) { + throw new Error("expected active WhatsApp web listener"); + } }, { timeout: 250, interval: 2 }, ); @@ -751,6 +754,9 @@ describe("web auto-reply connection", () => { timestamp: 1735689600000, spies, }); + if (!capturedOnMessage) { + throw new Error("Expected WhatsApp web runtime to register onMessage."); + } await sendWebDirectInboundMessage({ onMessage: capturedOnMessage, body: "second", diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index e745bbc7207..11cb361f0ac 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -161,7 +161,7 @@ describe("deliverWebReply", () => { }); it("suppresses payloads flagged as reasoning", async () => { - await expectReplySuppressed({ text: "Reasoning:\n_hidden_", isReasoning: true }); + await expectReplySuppressed({ text: "hidden", isReasoning: true }); }); it("suppresses payloads that start with reasoning prefix text", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor-state.test.ts b/extensions/whatsapp/src/auto-reply/monitor-state.test.ts index a7ec43941e5..b33fe0c6b58 100644 --- a/extensions/whatsapp/src/auto-reply/monitor-state.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor-state.test.ts @@ -58,7 +58,6 @@ describe("createWebChannelStatusController", () => { // The gateway health policy checks `connected === true && lastTransportActivityAt != null` // to decide whether to run stale-socket detection. Both must be present. expect(last.connected).toBe(true); - expect(last.lastTransportActivityAt).not.toBeNull(); - expect(typeof last.lastTransportActivityAt).toBe("number"); + expect(last.lastTransportActivityAt).toBe(1000); }); }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 0a26be224b5..bea5848990e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -699,7 +699,7 @@ describe("whatsapp inbound dispatch", () => { const deliver = getCapturedDeliver(); expect(deliver).toBeTypeOf("function"); - await deliver?.({ text: "Reasoning:\n_hidden_", isReasoning: true }, { kind: "block" }); + await deliver?.({ text: "hidden", isReasoning: true }, { kind: "block" }); await deliver?.( { text: "🧹 Compacting context...", isCompactionNotice: true }, { kind: "block" }, diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts index bd5a46c985d..8f1e5829c8f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts @@ -20,7 +20,7 @@ describe("trackBackgroundTask", () => { it("does not leak unhandled rejections when a tracked task fails", async () => { process.on("unhandledRejection", onUnhandledRejection); const backgroundTasks = new Set>(); - let rejectTask!: (reason?: unknown) => void; + let rejectTask: ((reason?: unknown) => void) | undefined; const task = new Promise((_resolve, reject) => { rejectTask = reject; }); @@ -28,6 +28,9 @@ describe("trackBackgroundTask", () => { trackBackgroundTask(backgroundTasks, task); expect(backgroundTasks.size).toBe(1); + if (!rejectTask) { + throw new Error("Expected tracked task reject callback to be initialized"); + } rejectTask(new Error("boom")); await waitForAsyncCallbacks(); diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index e7ba03eba93..30cbb117989 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -346,7 +346,7 @@ describe("web auto-reply util", () => { expect(isLikelyWhatsAppCryptoError(err)).toBe(true); }); - it("does not throw on circular objects", () => { + it("returns false for circular objects", () => { const circular: Record = {}; circular.self = circular; expect(isLikelyWhatsAppCryptoError(circular)).toBe(false); diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index b0669d2800c..7f1e5a1291a 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -58,9 +58,16 @@ vi.mock("openclaw/plugin-sdk/setup", async () => { ...actual, DEFAULT_ACCOUNT_ID, normalizeAccountId: (value?: string | null) => value?.trim() || DEFAULT_ACCOUNT_ID, - normalizeAllowFromEntries: (entries: string[], normalize: (value: string) => string) => [ - ...new Set(entries.map((entry) => (entry === "*" ? "*" : normalize(entry))).filter(Boolean)), - ], + normalizeAllowFromEntries: (entries: string[], normalize: (value: string) => string) => { + const normalized = new Set(); + for (const entry of entries) { + const value = entry === "*" ? "*" : normalize(entry); + if (value) { + normalized.add(value); + } + } + return [...normalized]; + }, normalizeE164, pathExists: hoisted.pathExists, splitSetupEntries: (raw: string) => diff --git a/extensions/zalo/src/api.test.ts b/extensions/zalo/src/api.test.ts index cdcef5a2dfb..8a13c4d8871 100644 --- a/extensions/zalo/src/api.test.ts +++ b/extensions/zalo/src/api.test.ts @@ -18,8 +18,11 @@ async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => await run("test-token", fetcher); expect(fetcher).toHaveBeenCalledTimes(1); const [, init] = fetcher.mock.calls[0] ?? []; - expect(init?.method).toBe("POST"); - expect(init?.headers).toEqual({ "Content-Type": "application/json" }); + if (!init) { + throw new Error("expected Zalo request init"); + } + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ "Content-Type": "application/json" }); } describe("Zalo API request methods", () => { @@ -67,7 +70,13 @@ describe("Zalo API request methods", () => { await rejected; const [, init] = fetcher.mock.calls[0] ?? []; - expect(init?.signal?.aborted).toBe(true); + if (!init) { + throw new Error("expected Zalo chat action request init"); + } + if (!init.signal) { + throw new Error("expected Zalo chat action abort signal"); + } + expect(init.signal.aborted).toBe(true); } finally { vi.useRealTimers(); } diff --git a/extensions/zalo/src/channel.startup.test.ts b/extensions/zalo/src/channel.startup.test.ts index 11070a51343..8486c631059 100644 --- a/extensions/zalo/src/channel.startup.test.ts +++ b/extensions/zalo/src/channel.startup.test.ts @@ -48,6 +48,17 @@ vi.mock("./channel.runtime.js", () => ({ import { zaloPlugin } from "./channel.js"; +type ZaloGateway = NonNullable; +type ZaloStartAccount = NonNullable; + +function requireStartAccount(): ZaloStartAccount { + const startAccount = zaloPlugin.gateway?.startAccount; + if (!startAccount) { + throw new Error("Expected Zalo gateway startAccount"); + } + return startAccount; +} + function buildAccount(): ResolvedZaloAccount { return { accountId: "default", @@ -76,7 +87,7 @@ describe("zaloPlugin gateway.startAccount", () => { ); const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({ - startAccount: zaloPlugin.gateway!.startAccount!, + startAccount: requireStartAccount(), account: buildAccount(), }); diff --git a/extensions/zalo/src/monitor.polling.media-reply.test.ts b/extensions/zalo/src/monitor.polling.media-reply.test.ts index 9fe4b91fc11..144d487792e 100644 --- a/extensions/zalo/src/monitor.polling.media-reply.test.ts +++ b/extensions/zalo/src/monitor.polling.media-reply.test.ts @@ -79,6 +79,16 @@ function createHostedMediaResponse() { return { headers, res: res as unknown as ServerResponse & { end: ReturnType } }; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("Zalo polling media replies", () => { const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); const recordInboundSessionMock = vi.fn(async () => undefined); @@ -335,9 +345,12 @@ describe("Zalo polling media replies", () => { firstAbort.abort(); await firstRun; - expect(registry.httpRoutes.filter((route) => route.source === "zalo-hosted-media")).toEqual([ + expect(registry.httpRoutes.find((route) => route.source === "zalo-hosted-media")).toEqual( hostedMediaRoute, - ]); + ); + expect( + countMatching(registry.httpRoutes, (route) => route.source === "zalo-hosted-media"), + ).toBe(1); await writeHostedZaloMediaFixture({ id: "def456def456def456def456", diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 4b76cf08027..58a1edfe897 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -426,7 +426,7 @@ describe("handleZaloWebhookRequest", () => { } }); - it("does not throw when replay metadata is partially missing", async () => { + it("accepts replay metadata when optional fields are missing", async () => { const sink = vi.fn(); const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink }); const payload = { diff --git a/extensions/zalo/src/outbound-payload.contract.test.ts b/extensions/zalo/src/outbound-payload.contract.test.ts index 9c94ec59b22..31b90bfeb4d 100644 --- a/extensions/zalo/src/outbound-payload.contract.test.ts +++ b/extensions/zalo/src/outbound-payload.contract.test.ts @@ -18,6 +18,34 @@ vi.mock("./channel.runtime.js", () => ({ sendZaloText: sendZaloTextMock, })); +type ZaloOutbound = NonNullable; +type ZaloSendPayload = NonNullable; +type ZaloMessageSender = NonNullable; + +function requireZaloSendPayload(): ZaloSendPayload { + const sendPayload = zaloPlugin.outbound?.sendPayload; + if (!sendPayload) { + throw new Error("Expected Zalo outbound sendPayload"); + } + return sendPayload; +} + +function requireZaloTextSender(): NonNullable { + const text = zaloMessageAdapter.send?.text; + if (!text) { + throw new Error("Expected Zalo message adapter text sender"); + } + return text; +} + +function requireZaloMediaSender(): NonNullable { + const media = zaloMessageAdapter.send?.media; + if (!media) { + throw new Error("Expected Zalo message adapter media sender"); + } + return media; +} + function createZaloHarness(params: OutboundPayloadHarnessParams) { const sendZalo = vi.fn(); primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults); @@ -33,8 +61,9 @@ function createZaloHarness(params: OutboundPayloadHarnessParams) { text: "", payload: params.payload, }; + const sendPayload = requireZaloSendPayload(); return { - run: async () => await zaloPlugin.outbound!.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: sendZalo, to: ctx.to, }; @@ -67,6 +96,8 @@ describe("Zalo outbound payload contract", () => { }), }, ); + const sendText = requireZaloTextSender(); + const sendMedia = requireZaloMediaSender(); await expect( verifyChannelMessageAdapterCapabilityProofs({ @@ -74,24 +105,24 @@ describe("Zalo outbound payload contract", () => { adapter: zaloMessageAdapter, proofs: { text: async () => { - const result = await zaloMessageAdapter.send?.text?.({ + const result = await sendText({ cfg: {}, to: "123456789", text: "hello", }); - expect(result?.receipt.platformMessageIds).toEqual(["zl-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zl-text-1"]); }, media: async () => { - const result = await zaloMessageAdapter.send?.media?.({ + const result = await sendMedia({ cfg: {}, to: "123456789", text: "image", mediaUrl: "https://example.com/image.png", }); - expect(result?.receipt.platformMessageIds).toEqual(["zl-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zl-media-1"]); }, messageSendingHooks: () => { - expect(zaloMessageAdapter.send?.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }), diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index baa75346eb1..5065017a6a6 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -60,9 +60,13 @@ describe("zalo setup wizard", () => { }); expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.zalo?.enabled).toBe(true); - expect(result.cfg.channels?.zalo?.botToken).toBe("12345689:abc-xyz"); - expect(result.cfg.channels?.zalo?.webhookUrl).toBeUndefined(); + const zaloConfig = result.cfg.channels?.zalo; + if (!zaloConfig) { + throw new Error("expected Zalo config"); + } + expect(zaloConfig.enabled).toBe(true); + expect(zaloConfig.botToken).toBe("12345689:abc-xyz"); + expect(zaloConfig.webhookUrl).toBeUndefined(); }); it("reads the named-account DM policy instead of the channel root", () => { @@ -117,11 +121,18 @@ describe("zalo setup wizard", () => { }); const next = zaloDmPolicy.setPolicy(cfg, "open"); - expect(next.channels?.zalo?.dmPolicy).toBe("disabled"); + const zaloConfig = next.channels?.zalo; + if (!zaloConfig) { + throw new Error("expected Zalo config"); + } + expect(zaloConfig.dmPolicy).toBe("disabled"); const workAccount = next.channels?.zalo?.accounts?.work as | { dmPolicy?: string; allowFrom?: Array } | undefined; - expect(workAccount?.dmPolicy).toBe("open"); + if (!workAccount) { + throw new Error("expected Zalo work account"); + } + expect(workAccount.dmPolicy).toBe("open"); }); it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => { @@ -142,12 +153,19 @@ describe("zalo setup wizard", () => { "work", ); - expect(next.channels?.zalo?.dmPolicy).toBeUndefined(); + const zaloConfig = next.channels?.zalo; + if (!zaloConfig) { + throw new Error("expected Zalo config"); + } + expect(zaloConfig.dmPolicy).toBeUndefined(); const workAccount = next.channels?.zalo?.accounts?.work as | { dmPolicy?: string; allowFrom?: Array } | undefined; - expect(workAccount?.dmPolicy).toBe("open"); - expect(workAccount?.allowFrom).toEqual(["123456789", "*"]); + if (!workAccount) { + throw new Error("expected Zalo work account"); + } + expect(workAccount.dmPolicy).toBe("open"); + expect(workAccount.allowFrom).toEqual(["123456789", "*"]); }); it("uses configured defaultAccount for omitted setup configured state", async () => { diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 8709a58910a..55e19b7f76e 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -29,6 +29,47 @@ function baseCtx(payload: ReplyPayload) { }; } +type ZalouserOutbound = NonNullable; +type ZalouserSendPayload = NonNullable; +type ZalouserMessageAdapter = NonNullable; +type ZalouserMessageSender = NonNullable; + +function requireZalouserSendPayload(): ZalouserSendPayload { + const sendPayload = zalouserPlugin.outbound?.sendPayload; + if (!sendPayload) { + throw new Error("Expected Zalouser outbound sendPayload"); + } + return sendPayload; +} + +function requireZalouserMessageAdapter(): ZalouserMessageAdapter { + const adapter = zalouserPlugin.message; + if (!adapter) { + throw new Error("Expected Zalouser message adapter"); + } + return adapter; +} + +function requireZalouserTextSender( + adapter: ZalouserMessageAdapter, +): NonNullable { + const text = adapter.send?.text; + if (!text) { + throw new Error("Expected Zalouser message adapter text sender"); + } + return text; +} + +function requireZalouserMediaSender( + adapter: ZalouserMessageAdapter, +): NonNullable { + const media = adapter.send?.media; + if (!media) { + throw new Error("Expected Zalouser message adapter media sender"); + } + return media; +} + describe("zalouserPlugin outbound sendPayload", () => { let mockedSend: ReturnType>; @@ -47,8 +88,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("group target delegates with isGroup=true and stripped threadId", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text: "hello group" }), to: "group:1471383327500481391", }); @@ -63,8 +105,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("treats bare numeric targets as direct chats for backward compatibility", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text: "hello" }), to: "987654321", }); @@ -79,8 +122,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("preserves provider-native group ids when sending to raw g- targets", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text: "hello native group" }), to: "g-1471383327500481391", }); @@ -96,8 +140,9 @@ describe("zalouserPlugin outbound sendPayload", () => { it("passes long markdown through once so formatting happens before chunking", async () => { const text = `**${"a".repeat(2501)}**`; mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" } as never); + const sendPayload = requireZalouserSendPayload(); - const result = await zalouserPlugin.outbound!.sendPayload!({ + const result = await sendPayload({ ...baseCtx({ text }), to: "987654321", }); @@ -136,33 +181,34 @@ describe("zalouserPlugin outbound sendPayload", () => { }), }, ); + const adapter = requireZalouserMessageAdapter(); + const sendText = requireZalouserTextSender(adapter); + const sendMedia = requireZalouserMediaSender(adapter); await expect( verifyChannelMessageAdapterCapabilityProofs({ adapterName: "zalouser", - adapter: zalouserPlugin.message!, + adapter, proofs: { text: async () => { - const result = await zalouserPlugin.message?.send?.text?.({ + const result = await sendText({ cfg: {}, to: "user:987654321", text: "hello", }); - expect(result?.receipt.platformMessageIds).toEqual(["zlu-text-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zlu-text-1"]); }, media: async () => { - const result = await zalouserPlugin.message?.send?.media?.({ + const result = await sendMedia({ cfg: {}, to: "user:987654321", text: "image", mediaUrl: "https://example.com/image.png", }); - expect(result?.receipt.platformMessageIds).toEqual(["zlu-media-1"]); + expect(result.receipt.platformMessageIds).toEqual(["zlu-media-1"]); }, messageSendingHooks: () => { - expect(zalouserPlugin.message?.durableFinal?.capabilities?.messageSendingHooks).toBe( - true, - ); + expect(adapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true); }, }, }), @@ -194,8 +240,9 @@ describe("zalouserPlugin outbound payload contract", () => { text: "", payload: params.payload, }; + const sendPayload = requireZalouserSendPayload(); return { - run: async () => await zalouserPlugin.outbound!.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: mockedSend, to: "987654321", }; diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index 846fd0a3f55..67510b26254 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -32,10 +32,18 @@ describe("zalouser setup wizard", () => { dmPolicy?: "pairing" | "allowlist", ) { expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.zalouser?.enabled).toBe(true); - expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + const channelConfig = result.cfg.channels?.zalouser; + if (!channelConfig) { + throw new Error("expected Zalo Personal channel config"); + } + const pluginEntry = result.cfg.plugins?.entries?.zalouser; + if (!pluginEntry) { + throw new Error("expected Zalo Personal plugin entry"); + } + expect(channelConfig.enabled).toBe(true); + expect(pluginEntry.enabled).toBe(true); if (dmPolicy) { - expect(result.cfg.channels?.zalouser?.dmPolicy).toBe(dmPolicy); + expect(channelConfig.dmPolicy).toBe(dmPolicy); } } @@ -98,9 +106,7 @@ describe("zalouser setup wizard", () => { const result = await runSetup({ prompter }); - expect(result.accountId).toBe("default"); - expect(result.cfg.channels?.zalouser?.enabled).toBe(true); - expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true); + expectEnabledDefaultSetup(result); }); it("prompts DM policy before group access in quickstart", async () => { diff --git a/extensions/zalouser/src/zalo-js.credentials.test.ts b/extensions/zalouser/src/zalo-js.credentials.test.ts index b10eaeb5315..3172b085161 100644 --- a/extensions/zalouser/src/zalo-js.credentials.test.ts +++ b/extensions/zalouser/src/zalo-js.credentials.test.ts @@ -8,6 +8,7 @@ import { LoginQRCallbackEventType } from "./zca-constants.js"; const createZaloMock = vi.hoisted(() => vi.fn()); const TEST_MTIME_TICK_MS = 20; +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; vi.mock("./zca-client.js", () => ({ createZalo: createZaloMock, @@ -197,7 +198,7 @@ describe("zalouser credential persistence", () => { const stored = await readStoredCredentials(stateDir, profile); expect(stored.cookie).toEqual(refreshedCookie); expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z"); - expect(stored.lastUsedAt).toEqual(expect.any(String)); + expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE); }); } finally { await rm(stateDir, { recursive: true, force: true }); @@ -262,7 +263,7 @@ describe("zalouser credential persistence", () => { const stored = await readStoredCredentials(stateDir, profile); expect(stored.cookie).toEqual(refreshedCookie); expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z"); - expect(stored.lastUsedAt).toEqual(expect.any(String)); + expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE); }); } finally { await rm(stateDir, { recursive: true, force: true }); diff --git a/package.json b/package.json index d44e5cf8f7b..ac6979e305d 100644 --- a/package.json +++ b/package.json @@ -1775,6 +1775,7 @@ "overrides": { "@aws-sdk/client-bedrock-runtime": "$@aws-sdk/client-bedrock-runtime", "axios": "1.16.0", + "fast-uri": "3.1.2", "follow-redirects": "1.16.0", "ip-address": "10.2.0", "node-domexception": "npm:@nolyfill/domexception@1.0.28", @@ -1791,6 +1792,7 @@ "@hono/node-server": "1.19.14", "@aws-sdk/client-bedrock-runtime": "3.1024.0", "axios": "1.16.0", + "fast-uri": "3.1.2", "follow-redirects": "1.16.0", "defu": "6.1.5", "fast-xml-parser": "5.7.0", diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 0c688b0d2c3..e909a6039da 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -45,6 +45,28 @@ const rootMemoryConfig = (workspaceDir: string): OpenClawConfig => const collectionNames = (resolved: ResolvedMemoryBackendConfig): Set => new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); +function requireQmdConfig( + resolved: ResolvedMemoryBackendConfig, +): NonNullable { + if (!resolved.qmd) { + throw new Error("expected qmd memory backend config"); + } + return resolved.qmd; +} + +function requireQmdCollection( + resolved: ResolvedMemoryBackendConfig, + name: string, +): NonNullable["collections"][number] { + const collection = requireQmdConfig(resolved).collections.find( + (candidate) => candidate.name === name, + ); + if (!collection) { + throw new Error(`expected qmd collection ${name}`); + } + return collection; +} + const customQmdCollections = ( resolved: ResolvedMemoryBackendConfig, ): NonNullable["collections"] => @@ -89,25 +111,23 @@ describe("resolveMemoryBackendConfig", () => { } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); expect(resolved.backend).toBe("qmd"); - expect(resolved.qmd?.collections.length).toBe(2); - expect(resolved.qmd?.command).toBe("qmd"); - expect(resolved.qmd?.searchMode).toBe("search"); - expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0); - expect(resolved.qmd?.update.onBoot).toBe(true); - expect(resolved.qmd?.update.startup).toBe("off"); - expect(resolved.qmd?.update.startupDelayMs).toBe(120_000); - expect(resolved.qmd?.update.waitForBootSync).toBe(false); - expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); - expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000); - expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000); - const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); + const qmd = requireQmdConfig(resolved); + expect(qmd.collections.length).toBe(2); + expect(qmd.command).toBe("qmd"); + expect(qmd.searchMode).toBe("search"); + expect(qmd.update.intervalMs).toBeGreaterThan(0); + expect(qmd.update.onBoot).toBe(true); + expect(qmd.update.startup).toBe("off"); + expect(qmd.update.startupDelayMs).toBe(120_000); + expect(qmd.update.waitForBootSync).toBe(false); + expect(qmd.update.commandTimeoutMs).toBe(30_000); + expect(qmd.update.updateTimeoutMs).toBe(120_000); + expect(qmd.update.embedTimeoutMs).toBe(120_000); + const names = new Set(qmd.collections.map((collection) => collection.name)); expect(names.has("memory-root-main")).toBe(true); expect(names.has("memory-dir-main")).toBe(true); expect(names.has("memory-alt-main")).toBe(false); - const rootCollection = resolved.qmd?.collections.find( - (collection) => collection.name === "memory-root-main", - ); - expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); }); it("keeps uppercase MEMORY.md as the root pattern when only lowercase memory.md exists", () => { @@ -115,10 +135,7 @@ describe("resolveMemoryBackendConfig", () => { withMemoryRootEntries([memoryFileEntry("memory.md")], () => { const cfg = rootMemoryConfig(workspaceDir); const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const rootCollection = resolved.qmd?.collections.find( - (collection) => collection.name === "memory-root-main", - ); - expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); expect(collectionNames(resolved).has("memory-alt-main")).toBe(false); }); }); @@ -128,10 +145,7 @@ describe("resolveMemoryBackendConfig", () => { withMemoryRootEntries([memoryFileEntry("MEMORY.md"), memoryFileEntry("memory.md")], () => { const cfg = rootMemoryConfig(workspaceDir); const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const rootCollection = resolved.qmd?.collections.find( - (collection) => collection.name === "memory-root-main", - ); - expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); expect(collectionNames(resolved).has("memory-alt-main")).toBe(false); }); }); @@ -147,7 +161,7 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.command).toBe("/Applications/QMD Tools/qmd"); + expect(requireQmdConfig(resolved).command).toBe("/Applications/QMD Tools/qmd"); }); it("resolves custom paths relative to workspace", () => { @@ -170,7 +184,12 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("custom-notes")); + const custom = requireQmdConfig(resolved).collections.find((c) => + c.name.startsWith("custom-notes"), + ); + if (!custom) { + throw new Error("expected custom-notes qmd collection"); + } expect(custom).toMatchObject({ path: path.resolve("/workspace/root", "notes") }); }); @@ -347,10 +366,11 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.update.waitForBootSync).toBe(true); - expect(resolved.qmd?.update.commandTimeoutMs).toBe(12_000); - expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000); - expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000); + const update = requireQmdConfig(resolved).update; + expect(update.waitForBootSync).toBe(true); + expect(update.commandTimeoutMs).toBe(12_000); + expect(update.updateTimeoutMs).toBe(480_000); + expect(update.embedTimeoutMs).toBe(360_000); }); it("resolves qmd startup refresh overrides", () => { @@ -367,9 +387,10 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.update.startup).toBe("idle"); - expect(resolved.qmd?.update.startupDelayMs).toBe(45_000); - expect(resolved.qmd?.update.onBoot).toBe(true); + const update = requireQmdConfig(resolved).update; + expect(update.startup).toBe("idle"); + expect(update.startupDelayMs).toBe(45_000); + expect(update.onBoot).toBe(true); }); it("resolves qmd search mode override", () => { @@ -383,7 +404,7 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.searchMode).toBe("vsearch"); + expect(requireQmdConfig(resolved).searchMode).toBe("vsearch"); }); it("resolves qmd mcporter search tool override", () => { @@ -398,8 +419,9 @@ describe("resolveMemoryBackendConfig", () => { }, } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); - expect(resolved.qmd?.searchMode).toBe("query"); - expect(resolved.qmd?.searchTool).toBe("hybrid_search"); + const qmd = requireQmdConfig(resolved); + expect(qmd.searchMode).toBe("query"); + expect(qmd.searchTool).toBe("hybrid_search"); }); }); diff --git a/packages/memory-host-sdk/src/host/batch-http.test.ts b/packages/memory-host-sdk/src/host/batch-http.test.ts index c558a00035c..d2c1876e8d1 100644 --- a/packages/memory-host-sdk/src/host/batch-http.test.ts +++ b/packages/memory-host-sdk/src/host/batch-http.test.ts @@ -4,6 +4,21 @@ vi.mock("./post-json.js", () => ({ postJson: vi.fn(), })); +type RetryOptions = { + attempts: number; + minDelayMs: number; + maxDelayMs: number; + shouldRetry: (err: unknown) => boolean; +}; + +function requireRetryOptions(call: unknown[] | undefined): RetryOptions { + const options = call?.[1] as RetryOptions | undefined; + if (!options) { + throw new Error("expected retry options"); + } + return options; +} + describe("postJsonWithRetry", () => { let postJsonMock: ReturnType>; let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry; @@ -44,20 +59,13 @@ describe("postJsonWithRetry", () => { }), ); - const retryOptions = retryAsyncMock.mock.calls[0]?.[1] as - | { - attempts: number; - minDelayMs: number; - maxDelayMs: number; - shouldRetry: (err: unknown) => boolean; - } - | undefined; - expect(retryOptions?.attempts).toBe(3); - expect(retryOptions?.minDelayMs).toBe(300); - expect(retryOptions?.maxDelayMs).toBe(2000); - expect(retryOptions?.shouldRetry({ status: 429 })).toBe(true); - expect(retryOptions?.shouldRetry({ status: 503 })).toBe(true); - expect(retryOptions?.shouldRetry({ status: 400 })).toBe(false); + const retryOptions = requireRetryOptions(retryAsyncMock.mock.calls[0]); + expect(retryOptions.attempts).toBe(3); + expect(retryOptions.minDelayMs).toBe(300); + expect(retryOptions.maxDelayMs).toBe(2000); + expect(retryOptions.shouldRetry({ status: 429 })).toBe(true); + expect(retryOptions.shouldRetry({ status: 503 })).toBe(true); + expect(retryOptions.shouldRetry({ status: 400 })).toBe(false); }); it("attaches status to non-ok errors", async () => { diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index ad10444ec0f..e50bd950cae 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -16,6 +16,11 @@ import { type MemoryMultimodalSettings, } from "./multimodal.js"; +type FileEntry = NonNullable>>; +type MultimodalIndexingChunk = NonNullable< + Awaited> +>; + let sharedTempRoot = ""; let sharedTempId = 0; @@ -38,6 +43,31 @@ function setupTempDirLifecycle(prefix: string): () => string { return () => tmpDir; } +function expectFileEntry(entry: Awaited>): FileEntry { + if (!entry) { + throw new Error("Expected file entry to be built"); + } + return entry; +} + +function expectMultimodalIndexingChunk( + built: Awaited>, +): MultimodalIndexingChunk { + if (!built) { + throw new Error("Expected multimodal indexing chunk to be built"); + } + return built; +} + +function expectEmbeddingInput( + chunk: MultimodalIndexingChunk["chunk"], +): NonNullable { + if (!chunk.embeddingInput) { + throw new Error("Expected multimodal chunk embedding input"); + } + return chunk.embeddingInput; +} + const multimodal: MemoryMultimodalSettings = { enabled: true, modalities: ["image", "audio"], @@ -108,15 +138,15 @@ describe("memory host SDK package internals", () => { const imagePath = path.join(tmpDir, "diagram.png"); fsSync.writeFileSync(imagePath, Buffer.from("png")); - const entry = await buildFileEntry(imagePath, tmpDir, multimodal); - const built = await buildMultimodalChunkForIndexing(entry!); - expect(built?.chunk.embeddingInput?.parts).toEqual([ + const entry = expectFileEntry(await buildFileEntry(imagePath, tmpDir, multimodal)); + const built = expectMultimodalIndexingChunk(await buildMultimodalChunkForIndexing(entry)); + expect(expectEmbeddingInput(built.chunk).parts).toEqual([ { type: "text", text: "Image file: diagram.png" }, expect.objectContaining({ type: "inline-data", mimeType: "image/png" }), ]); - fsSync.writeFileSync(imagePath, Buffer.alloc(entry!.size + 32, 1)); - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); + fsSync.writeFileSync(imagePath, Buffer.alloc(entry.size + 32, 1)); + await expect(buildMultimodalChunkForIndexing(entry)).resolves.toBeNull(); }); it("chunks mixed text and preserves surrogate pairs", () => { diff --git a/packages/memory-host-sdk/src/host/query-expansion.test.ts b/packages/memory-host-sdk/src/host/query-expansion.test.ts index f1e9bff520e..b7496358392 100644 --- a/packages/memory-host-sdk/src/host/query-expansion.test.ts +++ b/packages/memory-host-sdk/src/host/query-expansion.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { expandQueryForFts, extractKeywords } from "./query-expansion.js"; +function countKeyword(keywords: readonly string[], keyword: string): number { + let count = 0; + for (const candidate of keywords) { + if (candidate === keyword) { + count++; + } + } + return count; +} + describe("extractKeywords", () => { it("extracts keywords from English conversational query", () => { const keywords = extractKeywords("that thing we discussed about the API"); @@ -171,8 +181,7 @@ describe("extractKeywords", () => { it("removes duplicate keywords", () => { const keywords = extractKeywords("test test testing"); - const testCount = keywords.filter((k) => k === "test").length; - expect(testCount).toBe(1); + expect(countKeyword(keywords, "test")).toBe(1); }); describe("with trigram tokenizer", () => { diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index dc768a6d0f4..3e55d89f167 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -6,6 +6,7 @@ import { buildSessionEntry, listSessionFilesForAgent, sessionPathForFile, + type SessionFileEntry, } from "./session-files.js"; let fixtureRoot: string; @@ -36,6 +37,13 @@ afterEach(() => { } }); +function requireSessionEntry(entry: SessionFileEntry | null): SessionFileEntry { + if (!entry) { + throw new Error("expected session entry"); + } + return entry; +} + describe("listSessionFilesForAgent", () => { it("includes reset and deleted transcripts in session file listing", async () => { const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); @@ -110,11 +118,9 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "session.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - + const entry = requireSessionEntry(await buildSessionEntry(filePath)); // The content should have 3 lines (3 message records) - const contentLines = entry!.content.split("\n"); + const contentLines = entry.content.split("\n"); expect(contentLines).toHaveLength(3); expect(contentLines[0]).toContain("User: Hello world"); expect(contentLines[1]).toContain("Assistant: Hi there"); @@ -124,7 +130,7 @@ describe("buildSessionEntry", () => { // Content line 0 → JSONL line 4 (the first user message) // Content line 1 → JSONL line 6 (the assistant message) // Content line 2 → JSONL line 7 (the second user message) - expect(entry!.lineMap).toEqual([4, 6, 7]); + expect(entry.lineMap).toEqual([4, 6, 7]); }); it("returns empty lineMap when no messages are found", async () => { @@ -135,10 +141,9 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "empty-session.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.content).toBe(""); - expect(entry!.lineMap).toEqual([]); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.content).toBe(""); + expect(entry.lineMap).toEqual([]); }); it("indexes usage-counted reset/deleted archives but still skips bak and checkpoint artifacts", async () => { @@ -158,26 +163,24 @@ describe("buildSessionEntry", () => { fsSync.writeFileSync(bakPath, content); fsSync.writeFileSync(checkpointPath, content); - const resetEntry = await buildSessionEntry(resetPath); - const deletedEntry = await buildSessionEntry(deletedPath); - const bakEntry = await buildSessionEntry(bakPath); - const checkpointEntry = await buildSessionEntry(checkpointPath); + const resetEntry = requireSessionEntry(await buildSessionEntry(resetPath)); + const deletedEntry = requireSessionEntry(await buildSessionEntry(deletedPath)); + const bakEntry = requireSessionEntry(await buildSessionEntry(bakPath)); + const checkpointEntry = requireSessionEntry(await buildSessionEntry(checkpointPath)); // Usage-counted archives (reset, deleted) must surface real content so // post-reset memory_search can recover prior session history. - expect(resetEntry?.content).toContain("User: Archived hello"); - expect(resetEntry?.lineMap).toEqual([1]); - expect(deletedEntry?.content).toContain("User: Archived hello"); - expect(deletedEntry?.lineMap).toEqual([1]); + expect(resetEntry.content).toContain("User: Archived hello"); + expect(resetEntry.lineMap).toEqual([1]); + expect(deletedEntry.content).toContain("User: Archived hello"); + expect(deletedEntry.lineMap).toEqual([1]); // .bak and compaction checkpoints remain opaque pre-archive / snapshot // artifacts and stay empty so they do not get double-indexed. - expect(bakEntry).not.toBeNull(); - expect(bakEntry?.content).toBe(""); - expect(bakEntry?.lineMap).toEqual([]); - expect(checkpointEntry).not.toBeNull(); - expect(checkpointEntry?.content).toBe(""); - expect(checkpointEntry?.lineMap).toEqual([]); + expect(bakEntry.content).toBe(""); + expect(bakEntry.lineMap).toEqual([]); + expect(checkpointEntry.content).toBe(""); + expect(checkpointEntry.lineMap).toEqual([]); }); it("keeps cron-run deleted archives opaque when the live session store entry is gone", async () => { @@ -197,12 +200,11 @@ describe("buildSessionEntry", () => { ]; fsSync.writeFileSync(archivePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(archivePath); + const entry = requireSessionEntry(await buildSessionEntry(archivePath)); - expect(entry).not.toBeNull(); - expect(entry?.content).toBe(""); - expect(entry?.lineMap).toEqual([]); - expect(entry?.generatedByCronRun).toBe(true); + expect(entry.content).toBe(""); + expect(entry.lineMap).toEqual([]); + expect(entry.generatedByCronRun).toBe(true); }); it("keeps cron-run reset archives opaque when session metadata preserves the cron key", async () => { @@ -219,12 +221,11 @@ describe("buildSessionEntry", () => { ]; fsSync.writeFileSync(archivePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(archivePath); + const entry = requireSessionEntry(await buildSessionEntry(archivePath)); - expect(entry).not.toBeNull(); - expect(entry?.content).toBe(""); - expect(entry?.lineMap).toEqual([]); - expect(entry?.generatedByCronRun).toBe(true); + expect(entry.content).toBe(""); + expect(entry.lineMap).toEqual([]); + expect(entry.generatedByCronRun).toBe(true); }); it("skips blank lines and invalid JSON without breaking lineMap", async () => { @@ -238,9 +239,8 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "gaps.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.lineMap).toEqual([3, 5]); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.lineMap).toEqual([3, 5]); }); it("strips inbound metadata when a user envelope is split across text blocks", async () => { @@ -268,9 +268,8 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "enveloped-session-array.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.content).toBe("User: Actual user text"); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.content).toBe("User: Actual user text"); }); it("skips inter-session user messages", async () => { @@ -295,9 +294,8 @@ describe("buildSessionEntry", () => { const filePath = path.join(tmpDir, "inter-session-session.jsonl"); fsSync.writeFileSync(filePath, jsonlLines.join("\n")); - const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry!.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up."); - expect(entry!.lineMap).toEqual([2, 3]); + const entry = requireSessionEntry(await buildSessionEntry(filePath)); + expect(entry.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up."); + expect(entry.lineMap).toEqual([2, 3]); }); }); diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index cf1735a0ac5..66b1ccaa2eb 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -53,6 +53,14 @@ class FakeTransport implements OpenClawTransport { } } +function requireTransportCall(calls: readonly RequestCall[], index: number): RequestCall { + const call = calls[index]; + if (!call) { + throw new Error(`Expected transport call ${index}`); + } + return call; +} + describe("OpenClaw SDK", () => { it("runs an agent through the Gateway agent method", async () => { const transport = new FakeTransport({ @@ -423,8 +431,10 @@ describe("OpenClaw SDK", () => { "sessions.abort", "models.authStatus", ]); - expect(transport.calls[1]?.params).toEqual({ runId: "run_without_session" }); - expect(transport.calls[2]?.params).toEqual({ probe: false }); + expect(requireTransportCall(transport.calls, 1).params).toEqual({ + runId: "run_without_session", + }); + expect(requireTransportCall(transport.calls, 2).params).toEqual({ probe: false }); }); it("replays fast run events emitted before the caller starts iterating", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d16ebc67ad..171a85c9763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: '@hono/node-server': 1.19.14 '@aws-sdk/client-bedrock-runtime': 3.1024.0 axios: 1.16.0 + fast-uri: 3.1.2 follow-redirects: 1.16.0 defu: 6.1.5 fast-xml-parser: 5.7.0 @@ -5411,8 +5412,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.1: - resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -11524,7 +11525,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.1 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -12307,7 +12308,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.1: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.0: dependencies: diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index 13e99cc1ff8..729134bb98e 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -10,7 +10,13 @@ vi.mock("../secrets/provider-env-vars.js", () => ({ baseEnv: NodeJS.ProcessEnv, keys: Iterable, ): NodeJS.ProcessEnv => { - const denied = new Set([...keys].map((key) => key.trim().toUpperCase()).filter(Boolean)); + const denied = new Set(); + for (const key of keys) { + const normalized = key.trim().toUpperCase(); + if (normalized) { + denied.add(normalized); + } + } const env = { ...baseEnv }; for (const key of Object.keys(env)) { if (denied.has(key.toUpperCase())) { diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index ab8c14ecca0..16cb9317712 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -87,10 +87,13 @@ async function flushMicrotasks(rounds = 3): Promise { } function createDeferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((next) => { resolve = next; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -2433,9 +2436,10 @@ describe("AcpSessionManager", () => { }), ); expect(options.runtimeMode).toBe("plan"); - expect(extractRuntimeOptionsFromUpserts().some((entry) => entry?.runtimeMode === "plan")).toBe( - true, + const persistedRuntimeModes = extractRuntimeOptionsFromUpserts().map( + (entry) => entry?.runtimeMode, ); + expect(persistedRuntimeModes).toContain("plan"); }); it("reapplies persisted controls on next turn after runtime option updates", async () => { diff --git a/src/acp/permission-relay.test.ts b/src/acp/permission-relay.test.ts new file mode 100644 index 00000000000..17907feff61 --- /dev/null +++ b/src/acp/permission-relay.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + buildAcpPermissionOptions, + buildAcpPermissionRequest, + normalizeGatewayExecApprovalDecisions, + parseGatewayExecApprovalEventData, + parseGatewayExecApprovalRequestEventPayload, + resolveGatewayDecisionFromPermissionOutcome, +} from "./permission-relay.js"; + +describe("ACP permission relay helpers", () => { + it("maps Gateway exec approval decisions to ACP permission options", () => { + expect(buildAcpPermissionOptions(["allow-once", "allow-always", "deny"])).toEqual([ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + { + optionId: "allow-always", + name: "Allow always", + kind: "allow_always", + }, + { + optionId: "deny", + name: "Deny", + kind: "reject_once", + }, + ]); + }); + + it("filters unknown decisions and falls back to allow-once plus deny", () => { + expect(normalizeGatewayExecApprovalDecisions(["allow-once", "bogus", "deny"])).toEqual([ + "allow-once", + "deny", + ]); + expect(normalizeGatewayExecApprovalDecisions(["bogus"])).toEqual(["allow-once", "deny"]); + expect(normalizeGatewayExecApprovalDecisions(undefined)).toEqual(["allow-once", "deny"]); + }); + + it("builds a request_permission payload from Gateway approval data", () => { + const event = parseGatewayExecApprovalEventData({ + phase: "requested", + kind: "exec", + status: "pending", + approvalId: "approval-1", + title: "Command approval requested", + toolCallId: "tool-1", + command: "echo stale", + host: "gateway", + }); + if (!event) { + throw new Error("approval event did not parse"); + } + + expect( + buildAcpPermissionRequest({ + sessionId: "session-1", + event, + details: { + allowedDecisions: ["allow-once", "allow-always", "deny"], + commandText: "echo ok", + host: "gateway", + }, + }), + ).toEqual({ + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Command approval requested", + kind: "execute", + status: "pending", + rawInput: { + name: "exec", + approvalId: "approval-1", + command: "echo ok", + host: "gateway", + }, + _meta: { + toolName: "exec", + approvalId: "approval-1", + }, + }, + options: [ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + { + optionId: "allow-always", + name: "Allow always", + kind: "allow_always", + }, + { + optionId: "deny", + name: "Deny", + kind: "reject_once", + }, + ], + }); + }); + + it("parses Gateway exec.approval.requested payloads", () => { + expect( + parseGatewayExecApprovalRequestEventPayload({ + id: "approval-raw", + request: { + command: "echo raw", + host: "gateway", + sessionKey: "agent:main:main", + }, + }), + ).toEqual({ + approvalId: "approval-raw", + command: "echo raw", + host: "gateway", + }); + + expect(parseGatewayExecApprovalRequestEventPayload({ id: "approval-raw" })).toBeNull(); + expect( + parseGatewayExecApprovalRequestEventPayload({ + id: "approval-raw", + request: { command: "" }, + }), + ).toMatchObject({ approvalId: "approval-raw" }); + }); + + it("maps selected ACP outcomes back to Gateway decisions", () => { + const options = buildAcpPermissionOptions(["allow-once", "allow-always", "deny"]); + + expect( + resolveGatewayDecisionFromPermissionOutcome( + { outcome: { outcome: "selected", optionId: "allow-always" } }, + options, + ), + ).toBe("allow-always"); + expect( + resolveGatewayDecisionFromPermissionOutcome( + { outcome: { outcome: "selected", optionId: "missing" } }, + options, + ), + ).toBeUndefined(); + expect( + resolveGatewayDecisionFromPermissionOutcome({ outcome: { outcome: "cancelled" } }, options), + ).toBeUndefined(); + }); +}); diff --git a/src/acp/permission-relay.ts b/src/acp/permission-relay.ts new file mode 100644 index 00000000000..bf0873f4ab8 --- /dev/null +++ b/src/acp/permission-relay.ts @@ -0,0 +1,166 @@ +import type { + PermissionOption, + RequestPermissionRequest, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk"; + +export type GatewayExecApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export type GatewayExecApprovalEvent = { + approvalId: string; + command?: string; + host?: string; + title?: string; + toolCallId?: string; +}; + +export type GatewayExecApprovalDetails = { + allowedDecisions?: unknown; + commandPreview?: unknown; + commandText?: unknown; + host?: unknown; +}; + +const FALLBACK_EXEC_APPROVAL_DECISIONS = ["allow-once", "deny"] as const; + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeGatewayExecApprovalDecision( + value: unknown, +): GatewayExecApprovalDecision | undefined { + if (value === "allow-once" || value === "allow-always" || value === "deny") { + return value; + } + return undefined; +} + +export function normalizeGatewayExecApprovalDecisions( + value: unknown, +): GatewayExecApprovalDecision[] { + const normalized = Array.isArray(value) + ? value + .map(normalizeGatewayExecApprovalDecision) + .filter((decision): decision is GatewayExecApprovalDecision => Boolean(decision)) + : []; + return normalized.length > 0 ? normalized : [...FALLBACK_EXEC_APPROVAL_DECISIONS]; +} + +export function buildAcpPermissionOptions( + decisions: readonly GatewayExecApprovalDecision[], +): PermissionOption[] { + const unique = new Set(decisions); + const options: PermissionOption[] = []; + if (unique.has("allow-once")) { + options.push({ + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }); + } + if (unique.has("allow-always")) { + options.push({ + optionId: "allow-always", + name: "Allow always", + kind: "allow_always", + }); + } + if (unique.has("deny")) { + options.push({ + optionId: "deny", + name: "Deny", + kind: "reject_once", + }); + } + return options.length > 0 ? options : buildAcpPermissionOptions(FALLBACK_EXEC_APPROVAL_DECISIONS); +} + +export function parseGatewayExecApprovalEventData( + data: Record, +): GatewayExecApprovalEvent | null { + if (data.phase !== "requested" || data.kind !== "exec" || data.status !== "pending") { + return null; + } + const approvalId = readNonEmptyString(data.approvalId); + if (!approvalId) { + return null; + } + return { + approvalId, + command: readNonEmptyString(data.command), + host: readNonEmptyString(data.host), + title: readNonEmptyString(data.title), + toolCallId: readNonEmptyString(data.toolCallId), + }; +} + +export function parseGatewayExecApprovalRequestEventPayload( + payload: Record, +): GatewayExecApprovalEvent | null { + const approvalId = readNonEmptyString(payload.id); + const request = payload.request; + if (!approvalId || !request || typeof request !== "object" || Array.isArray(request)) { + return null; + } + const requestRecord = request as Record; + return { + approvalId, + command: + readNonEmptyString(requestRecord.command) ?? readNonEmptyString(requestRecord.commandPreview), + host: readNonEmptyString(requestRecord.host), + }; +} + +export function buildAcpPermissionRequest(params: { + sessionId: string; + event: GatewayExecApprovalEvent; + details?: GatewayExecApprovalDetails | null; +}): RequestPermissionRequest { + const command = + readNonEmptyString(params.details?.commandText) ?? + readNonEmptyString(params.details?.commandPreview) ?? + params.event.command; + const host = readNonEmptyString(params.details?.host) ?? params.event.host; + const decisions = normalizeGatewayExecApprovalDecisions(params.details?.allowedDecisions); + const rawInput: Record = { + name: "exec", + approvalId: params.event.approvalId, + }; + if (command) { + rawInput.command = command; + } + if (host) { + rawInput.host = host; + } + + return { + sessionId: params.sessionId, + toolCall: { + // Raw approval events can arrive before Gateway emits a tool call id; the + // approval id remains the stable correlation key for those early prompts. + toolCallId: params.event.toolCallId ?? `exec:${params.event.approvalId}`, + title: params.event.title ?? "Command approval requested", + kind: "execute", + status: "pending", + rawInput, + _meta: { + toolName: "exec", + approvalId: params.event.approvalId, + }, + }, + options: buildAcpPermissionOptions(decisions), + }; +} + +export function resolveGatewayDecisionFromPermissionOutcome( + response: RequestPermissionResponse | undefined, + options: readonly PermissionOption[], +): GatewayExecApprovalDecision | undefined { + const outcome = response?.outcome; + if (!outcome || outcome.outcome !== "selected") { + return undefined; + } + const selected = options.find((option) => option.optionId === outcome.optionId); + return normalizeGatewayExecApprovalDecision(selected?.optionId); +} diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 7c10f761c3d..1e0bfe25d24 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -17,6 +17,7 @@ type ResolveGatewayClientBootstrap = (params: unknown) => Promise<{ }>; type GatewayClientOptions = GatewayClientCallbacks & GatewayClientAuth & { + caps?: string[]; url?: string; }; @@ -236,6 +237,21 @@ describe("serveAcpGateway startup", () => { } }); + it("subscribes the Gateway client to run-scoped tool events", async () => { + const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); + + try { + const servePromise = serveAcpGateway({}); + await emitHelloAndWaitForAgentSideConnection(); + + expect(mockState.gatewayOptions[0]?.caps).toEqual(["tool-events"]); + + await stopServeWithSigint(signalHandlers, servePromise); + } finally { + onceSpy.mockRestore(); + } + }); + it("routes logs to stderr before loading gateway config", async () => { const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); diff --git a/src/acp/server.ts b/src/acp/server.ts index f6759e080ad..676f2bc9f83 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -6,7 +6,11 @@ import { getRuntimeConfig } from "../config/config.js"; import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js"; import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; import { GatewayClient } from "../gateway/client.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../gateway/protocol/client-info.js"; import { isMainModule } from "../infra/is-main.js"; import { routeLogsToStderr } from "../logging/console.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -65,6 +69,7 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { void agent?.handleGatewayEvent(evt); }, diff --git a/src/acp/translator.lifecycle.test.ts b/src/acp/translator.lifecycle.test.ts index 3d8c13df641..af2c2346851 100644 --- a/src/acp/translator.lifecycle.test.ts +++ b/src/acp/translator.lifecycle.test.ts @@ -153,6 +153,28 @@ describe("acp translator stable lifecycle handlers", () => { sessionStore.clearAllSessionsForTest(); }); + it("captures ACP client capabilities during initialize", async () => { + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway()); + + expect(agent.supportsClientReadTextFile()).toBe(false); + expect(agent.supportsClientWriteTextFile()).toBe(false); + expect(agent.supportsClientTerminal()).toBe(false); + + await agent.initialize({ + ...createInitializeRequest(), + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: false }, + terminal: true, + }, + clientInfo: { name: "test-client", version: "1.2.3" }, + } as InitializeRequest); + + expect(agent.supportsClientReadTextFile()).toBe(true); + expect(agent.supportsClientWriteTextFile()).toBe(false); + expect(agent.supportsClientTerminal()).toBe(true); + expect(agent.getClientInfo()).toEqual({ name: "test-client", version: "1.2.3" }); + }); + it("lists Gateway sessions through the stable handler with opaque cursors and cwd filtering", async () => { const allRows = [ createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }), @@ -187,7 +209,8 @@ describe("acp translator stable lifecycle handlers", () => { "agent:main:a2", ]); expect(first.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]); - expect(first.nextCursor).toEqual(expect.any(String)); + expect(first.nextCursor).toBeTypeOf("string"); + expect(first.nextCursor).not.toBe(""); expect(second.sessions.map((session) => session.sessionId)).toEqual([ "agent:main:a3", "agent:main:a4", @@ -254,7 +277,8 @@ describe("acp translator stable lifecycle handlers", () => { }); const unfiltered = await agent.listSessions(createListSessionsRequest({ limit: 1 })); - expect(unfiltered.nextCursor).toEqual(expect.any(String)); + expect(unfiltered.nextCursor).toBeTypeOf("string"); + expect(unfiltered.nextCursor).not.toBe(""); await expect( agent.listSessions( createListSessionsRequest({ cwd: "/work/a", cursor: unfiltered.nextCursor }), @@ -264,7 +288,8 @@ describe("acp translator stable lifecycle handlers", () => { const filtered = await agent.listSessions( createListSessionsRequest({ cwd: "/work/a", limit: 1 }), ); - expect(filtered.nextCursor).toEqual(expect.any(String)); + expect(filtered.nextCursor).toBeTypeOf("string"); + expect(filtered.nextCursor).not.toBe(""); await expect( agent.listSessions(createListSessionsRequest({ cursor: filtered.nextCursor })), ).rejects.toThrow(/cursor does not match the cwd filter/i); @@ -311,7 +336,14 @@ describe("acp translator stable lifecycle handlers", () => { const result = await agent.resumeSession(createResumeSessionRequest("agent:main:work")); expect(result.modes?.currentModeId).toBe("adaptive"); - expect(result.configOptions).toEqual(expect.any(Array)); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "adaptive", + }), + ]), + ); expect(sessionStore.getSession("agent:main:work")?.sessionKey).toBe("agent:main:work"); expect(request).not.toHaveBeenCalledWith("sessions.get", expect.anything()); expect(sessionUpdate).toHaveBeenCalledWith({ diff --git a/src/acp/translator.permission-relay.test.ts b/src/acp/translator.permission-relay.test.ts new file mode 100644 index 00000000000..e61b76445ca --- /dev/null +++ b/src/acp/translator.permission-relay.test.ts @@ -0,0 +1,455 @@ +import type { CancelNotification } from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; +import { createInMemorySessionStore } from "./session.js"; +import { AcpGatewayAgent } from "./translator.js"; +import { promptAgent } from "./translator.prompt-harness.test-support.js"; +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; + +vi.mock("./commands.js", () => ({ + getAvailableCommands: () => [], +})); + +const SESSION_ID = "session-1"; +const SECOND_SESSION_ID = "session-2"; +const SESSION_KEY = "agent:main:main"; + +type Harness = { + agent: AcpGatewayAgent; + connection: ReturnType; + promptPromise: ReturnType; + request: ReturnType; + requestPermission: ReturnType; + runId: string; + sessionStore: ReturnType; +}; + +function createApprovalEvent(params: { + approvalId?: string; + runId: string; + sessionKey?: string; + toolCallId?: string; +}): EventFrame { + return { + type: "event", + event: "agent", + payload: { + runId: params.runId, + sessionKey: params.sessionKey ?? SESSION_KEY, + stream: "approval", + data: { + phase: "requested", + kind: "exec", + status: "pending", + title: "Command approval requested", + approvalId: params.approvalId ?? "approval-1", + toolCallId: params.toolCallId, + command: "echo event", + host: "gateway", + }, + }, + } as EventFrame; +} + +function createApprovalRequestEvent(params: { + approvalId?: string; + sessionKey?: string; + command?: string; +}): EventFrame { + return { + type: "event", + event: "exec.approval.requested", + payload: { + id: params.approvalId ?? "approval-1", + createdAtMs: 1, + expiresAtMs: 2, + request: { + command: params.command ?? "echo raw", + host: "gateway", + sessionKey: params.sessionKey ?? SESSION_KEY, + }, + }, + } as EventFrame; +} + +async function createHarness( + params: { + allowedDecisions?: string[]; + requestPermission?: ReturnType; + resolveApproval?: (requestParams?: Record) => unknown; + } = {}, +): Promise { + let runId: string | undefined; + const request = vi.fn(async (method: string, requestParams?: Record) => { + if (method === "chat.send") { + runId = requestParams?.idempotencyKey as string | undefined; + return { status: "started", runId }; + } + if (method === "exec.approval.get") { + return { + id: requestParams?.id, + commandText: "echo hydrated", + allowedDecisions: params.allowedDecisions ?? ["allow-once", "allow-always", "deny"], + host: "gateway", + }; + } + if (method === "exec.approval.resolve" && params.resolveApproval) { + return params.resolveApproval(requestParams); + } + return {}; + }) as ReturnType & GatewayClient["request"]; + const requestPermission = + params.requestPermission ?? + vi.fn(async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } })); + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + cwd: "/tmp", + }); + const connection = createAcpConnection({ requestPermission }); + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { sessionStore }); + const promptPromise = promptAgent(agent, SESSION_ID); + + await vi.waitFor(() => { + if (!runId) { + throw new Error("expected ACP permission relay run id"); + } + }); + + return { + agent, + connection, + promptPromise, + request, + requestPermission, + runId: runId!, + sessionStore, + }; +} + +async function cleanupHarness(harness: Harness): Promise { + await harness.agent.cancel({ sessionId: SESSION_ID } as CancelNotification); + await harness.promptPromise; + harness.sessionStore.clearAllSessionsForTest(); +} + +function approvalResolveCalls(request: ReturnType) { + return request.mock.calls.filter(([method]) => method === "exec.approval.resolve"); +} + +describe("ACP translator permission relay", () => { + it.each([ + ["allow-once", "allow-once"], + ["allow-always", "allow-always"], + ["deny", "deny"], + ])("relays selected %s decisions to Gateway approval resolution", async (optionId, decision) => { + const harness = await createHarness({ + requestPermission: vi.fn(async () => ({ + outcome: { outcome: "selected", optionId }, + })), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(harness.request)).toHaveLength(1); + }); + + expect(harness.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + toolCall: expect.objectContaining({ + toolCallId: "exec:approval-1", + kind: "execute", + rawInput: expect.objectContaining({ + name: "exec", + command: "echo hydrated", + approvalId: "approval-1", + }), + }), + }), + ); + expect(harness.request).toHaveBeenCalledWith("exec.approval.get", { id: "approval-1" }); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision, + }); + + await cleanupHarness(harness); + }); + + it("dedupes repeated approval events for the same approval id", async () => { + const harness = await createHarness(); + const event = createApprovalEvent({ runId: harness.runId, approvalId: "approval-dup" }); + + await harness.agent.handleGatewayEvent(event); + await harness.agent.handleGatewayEvent(event); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(harness.request)).toHaveLength(1); + }); + + await cleanupHarness(harness); + }); + + it("relays exec approval request events before the later agent approval event", async () => { + const harness = await createHarness(); + const approvalId = "approval-raw"; + + await harness.agent.handleGatewayEvent( + createApprovalRequestEvent({ approvalId, command: "echo raw" }), + ); + await harness.agent.handleGatewayEvent( + createApprovalEvent({ runId: harness.runId, approvalId, toolCallId: "tool-late" }), + ); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(harness.request)).toHaveLength(1); + }); + + expect(harness.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + toolCallId: "exec:approval-raw", + rawInput: expect.objectContaining({ + approvalId, + command: "echo hydrated", + }), + }), + }), + ); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: approvalId, + decision: "allow-once", + }); + + await cleanupHarness(harness); + }); + + it("does not bind session-only approval events when multiple prompts share the session key", async () => { + const runIds: string[] = []; + const request = vi.fn(async (method: string, requestParams?: Record) => { + if (method === "chat.send") { + const runId = requestParams?.idempotencyKey as string; + runIds.push(runId); + return { status: "started", runId }; + } + if (method === "exec.approval.get") { + return { + id: requestParams?.id, + commandText: "echo hydrated", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + }; + } + return {}; + }) as ReturnType & GatewayClient["request"]; + const requestPermission = vi.fn(async () => ({ + outcome: { outcome: "selected", optionId: "allow-once" }, + })); + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + cwd: "/tmp", + }); + sessionStore.createSession({ + sessionId: SECOND_SESSION_ID, + sessionKey: SESSION_KEY, + cwd: "/tmp", + }); + const connection = createAcpConnection({ requestPermission }); + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { sessionStore }); + const firstPrompt = promptAgent(agent, SESSION_ID, "first prompt"); + const secondPrompt = promptAgent(agent, SECOND_SESSION_ID, "second prompt"); + + await vi.waitFor(() => { + expect(runIds).toHaveLength(2); + }); + + const approvalId = "approval-shared"; + await agent.handleGatewayEvent(createApprovalRequestEvent({ approvalId })); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(requestPermission).not.toHaveBeenCalled(); + expect(approvalResolveCalls(request)).toHaveLength(0); + + await agent.handleGatewayEvent(createApprovalEvent({ runId: runIds[1], approvalId })); + + await vi.waitFor(() => { + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(approvalResolveCalls(request)).toHaveLength(1); + }); + + expect(requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SECOND_SESSION_ID, + }), + ); + expect(request).toHaveBeenCalledWith("exec.approval.resolve", { + id: approvalId, + decision: "allow-once", + }); + + await agent.cancel({ sessionId: SESSION_ID } as CancelNotification); + await agent.cancel({ sessionId: SECOND_SESSION_ID } as CancelNotification); + await Promise.all([firstPrompt, secondPrompt]); + sessionStore.clearAllSessionsForTest(); + }); + + it("allows approval relay retry when Gateway resolution fails", async () => { + const resolveApproval = vi + .fn() + .mockRejectedValueOnce(new Error("gateway not connected")) + .mockResolvedValueOnce({}); + const harness = await createHarness({ resolveApproval }); + const event = createApprovalEvent({ runId: harness.runId, approvalId: "approval-retry" }); + + await harness.agent.handleGatewayEvent(event); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(resolveApproval).toHaveBeenCalledTimes(1); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + await harness.agent.handleGatewayEvent(event); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(2); + expect(resolveApproval).toHaveBeenCalledTimes(2); + }); + expect(harness.request).toHaveBeenLastCalledWith("exec.approval.resolve", { + id: "approval-retry", + decision: "allow-once", + }); + + await cleanupHarness(harness); + }); + + it("ignores approval events outside the active ACP run", async () => { + const harness = await createHarness(); + + await harness.agent.handleGatewayEvent( + createApprovalEvent({ + runId: "other-run", + sessionKey: "agent:main:other", + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(harness.requestPermission).not.toHaveBeenCalled(); + expect(approvalResolveCalls(harness.request)).toHaveLength(0); + + await cleanupHarness(harness); + }); + + it.each([ + { outcome: { outcome: "cancelled" } }, + { outcome: { outcome: "selected", optionId: "not-a-real-option" } }, + ])("denies cancelled and invalid ACP permission outcomes", async (outcome) => { + const harness = await createHarness({ + requestPermission: vi.fn(async () => outcome), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision: "deny", + }); + }); + + await cleanupHarness(harness); + }); + + it("denies when the ACP client permission request throws", async () => { + const harness = await createHarness({ + requestPermission: vi.fn(async () => { + throw new Error("client closed"); + }), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + expect(harness.request).toHaveBeenCalledWith("exec.approval.resolve", { + id: "approval-1", + decision: "deny", + }); + }); + + await cleanupHarness(harness); + }); + + it("does not allow execution when the prompt is cancelled during client permission UI", async () => { + let resolvePermission!: (value: unknown) => void; + const harness = await createHarness({ + requestPermission: vi.fn( + () => + new Promise((resolve) => { + resolvePermission = resolve; + }), + ), + }); + + await harness.agent.handleGatewayEvent(createApprovalEvent({ runId: harness.runId })); + await vi.waitFor(() => { + expect(harness.requestPermission).toHaveBeenCalledTimes(1); + }); + + await cleanupHarness(harness); + resolvePermission({ outcome: { outcome: "selected", optionId: "allow-once" } }); + + await vi.waitFor(() => { + const decisions = approvalResolveCalls(harness.request).map( + ([, params]) => (params as { decision?: string }).decision, + ); + expect(decisions).toContain("deny"); + expect(decisions).not.toContain("allow-once"); + }); + }); + + it("keeps existing tool streaming behavior unchanged", async () => { + const harness = await createHarness(); + + await harness.agent.handleGatewayEvent({ + type: "event", + event: "agent", + payload: { + runId: harness.runId, + sessionKey: SESSION_KEY, + stream: "tool", + data: { + phase: "start", + name: "exec", + toolCallId: "tool-1", + args: { command: "echo ok" }, + }, + }, + } as EventFrame); + + expect(harness.requestPermission).not.toHaveBeenCalled(); + expect(harness.connection.__sessionUpdateMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + update: expect.objectContaining({ + sessionUpdate: "tool_call", + toolCallId: "tool-1", + status: "in_progress", + }), + }), + ); + + await cleanupHarness(harness); + }); +}); diff --git a/src/acp/translator.stop-reason.test.ts b/src/acp/translator.stop-reason.test.ts index f3e81689d72..92573da7fb5 100644 --- a/src/acp/translator.stop-reason.test.ts +++ b/src/acp/translator.stop-reason.test.ts @@ -203,7 +203,8 @@ describe("acp translator stop reason mapping", () => { const promptPromise = promptAgent(agent, sessionId); await vi.waitFor(() => { - expect(runId).toEqual(expect.any(String)); + expect(runId).toBeTypeOf("string"); + expect(runId).not.toBe(""); }); const capturedRunId = requireValue(runId, "chat.send run id"); diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index 222ee0ef59b..7ea97fa7d7f 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -3,13 +3,22 @@ import { vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; type TestAcpConnection = AgentSideConnection & { + __requestPermissionMock: ReturnType; __sessionUpdateMock: ReturnType; }; -export function createAcpConnection(): TestAcpConnection { +export function createAcpConnection( + params: { + requestPermission?: ReturnType; + } = {}, +): TestAcpConnection { + const requestPermission = + params.requestPermission ?? vi.fn(async () => ({ outcome: { outcome: "cancelled" } })); const sessionUpdate = vi.fn(async () => {}); return { + requestPermission, sessionUpdate, + __requestPermissionMock: requestPermission, __sessionUpdateMock: sessionUpdate, } as unknown as TestAcpConnection; } diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 27fcb73928f..51a1c057779 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -57,6 +57,15 @@ import { inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; +import { + buildAcpPermissionRequest, + parseGatewayExecApprovalEventData, + parseGatewayExecApprovalRequestEventPayload, + resolveGatewayDecisionFromPermissionOutcome, + type GatewayExecApprovalDecision, + type GatewayExecApprovalDetails, + type GatewayExecApprovalEvent, +} from "./permission-relay.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js"; import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; @@ -116,6 +125,20 @@ type PendingPrompt = { toolCalls?: Map; }; +type ClientCapabilityState = { + readTextFile: boolean; + writeTextFile: boolean; + terminal: boolean; +}; + +type PendingApprovalRelay = { + approvalId: string; + runId: string; + sessionId: string; + sessionKey: string; + state: "active" | "completed"; +}; + type PendingToolCall = { kind: ToolKind; locations?: ToolCallLocation[]; @@ -164,6 +187,16 @@ type SessionUsageSnapshot = { used: number; }; +function normalizeClientCapabilities( + capabilities: InitializeRequest["clientCapabilities"] | undefined, +): ClientCapabilityState { + return { + readTextFile: capabilities?.fs?.readTextFile === true, + writeTextFile: capabilities?.fs?.writeTextFile === true, + terminal: capabilities?.terminal === true, + }; +} + function isAdminScopeProvenanceRejection(err: unknown): boolean { if (!(err instanceof Error)) { return false; @@ -531,6 +564,9 @@ export class AcpGatewayAgent implements Agent { private eventLedger: AcpEventLedger; private sessionCreateRateLimiter: FixedWindowRateLimiter; private pendingPrompts = new Map(); + private approvalRelays = new Map(); + private clientCapabilities: ClientCapabilityState = normalizeClientCapabilities(undefined); + private clientInfo: InitializeRequest["clientInfo"] = null; private disconnectTimer: NodeJS.Timeout | null = null; private activeDisconnectContext: DisconnectContext | null = null; private disconnectGeneration = 0; @@ -570,6 +606,22 @@ export class AcpGatewayAgent implements Agent { this.log("ready"); } + supportsClientReadTextFile(): boolean { + return this.clientCapabilities.readTextFile; + } + + supportsClientWriteTextFile(): boolean { + return this.clientCapabilities.writeTextFile; + } + + supportsClientTerminal(): boolean { + return this.clientCapabilities.terminal; + } + + getClientInfo(): InitializeRequest["clientInfo"] { + return this.clientInfo; + } + handleGatewayReconnect(): void { this.log("gateway reconnected"); const disconnectContext = this.activeDisconnectContext; @@ -602,12 +654,18 @@ export class AcpGatewayAgent implements Agent { await this.handleChatEvent(evt); return; } + if (evt.event === "exec.approval.requested") { + this.handleExecApprovalRequestEvent(evt); + return; + } if (evt.event === "agent") { await this.handleAgentEvent(evt); } } - async initialize(_params: InitializeRequest): Promise { + async initialize(params: InitializeRequest): Promise { + this.clientCapabilities = normalizeClientCapabilities(params.clientCapabilities); + this.clientInfo = params.clientInfo ?? null; return { protocolVersion: await getAcpProtocolVersion(), agentCapabilities: { @@ -995,6 +1053,7 @@ export class AcpGatewayAgent implements Agent { if (isGatewayCloseError(err) && this.getPendingPrompt(params.sessionId, runId)) { return; } + this.clearApprovalRelaysForPrompt(params.sessionId, runId, { denyActive: true }); this.pendingPrompts.delete(params.sessionId); this.sessionStore.clearActiveRun(params.sessionId); if (this.pendingPrompts.size === 0) { @@ -1045,6 +1104,11 @@ export class AcpGatewayAgent implements Agent { return; } + if (stream === "approval") { + await this.handleApprovalEvent({ sessionKey, runId, data }); + return; + } + if (stream !== "tool") { return; } @@ -1139,6 +1203,163 @@ export class AcpGatewayAgent implements Agent { } } + private async handleApprovalEvent(params: { + sessionKey: string; + runId?: string; + data: Record; + }): Promise { + const approvalEvent = parseGatewayExecApprovalEventData(params.data); + if (!approvalEvent) { + return; + } + this.startApprovalRelay({ + sessionKey: params.sessionKey, + runId: params.runId, + approvalEvent, + }); + } + + private handleExecApprovalRequestEvent(evt: EventFrame): void { + const payload = evt.payload as Record | undefined; + if (!payload) { + return; + } + const approvalEvent = parseGatewayExecApprovalRequestEventPayload(payload); + if (!approvalEvent) { + return; + } + const request = payload.request as Record | undefined; + const sessionKey = normalizeOptionalString(request?.sessionKey); + if (!sessionKey) { + return; + } + this.startApprovalRelay({ sessionKey, approvalEvent }); + } + + private startApprovalRelay(params: { + sessionKey: string; + runId?: string; + approvalEvent: GatewayExecApprovalEvent; + }): void { + const approvalEvent = params.approvalEvent; + if (this.approvalRelays.has(approvalEvent.approvalId)) { + return; + } + + const pending = params.runId + ? this.findPendingBySessionKey(params.sessionKey, params.runId) + : this.findUniquePendingBySessionKey(params.sessionKey); + if (!pending) { + return; + } + + const relay: PendingApprovalRelay = { + approvalId: approvalEvent.approvalId, + runId: pending.idempotencyKey, + sessionId: pending.sessionId, + sessionKey: pending.sessionKey, + state: "active", + }; + this.approvalRelays.set(relay.approvalId, relay); + void this.runApprovalRelay(relay, approvalEvent); + } + + private async runApprovalRelay( + relay: PendingApprovalRelay, + approvalEvent: GatewayExecApprovalEvent, + ): Promise { + let resolved = false; + try { + const details = await this.getGatewayApprovalDetails(relay.approvalId); + if (!this.isApprovalRelayActive(relay)) { + resolved = await this.resolveGatewayApproval(relay.approvalId, "deny"); + return; + } + + const request = buildAcpPermissionRequest({ + sessionId: relay.sessionId, + event: approvalEvent, + details, + }); + let decision: GatewayExecApprovalDecision | undefined; + try { + const response = await this.connection.requestPermission(request); + decision = resolveGatewayDecisionFromPermissionOutcome(response, request.options); + } catch (err) { + this.log(`approval relay request failed for ${relay.approvalId}: ${String(err)}`); + } + + const selectedDecision = this.isApprovalRelayActive(relay) && decision ? decision : "deny"; + resolved = await this.resolveGatewayApproval(relay.approvalId, selectedDecision); + } finally { + const current = this.approvalRelays.get(relay.approvalId); + if (current === relay && current.state === "active") { + if (resolved) { + // Keep completed relays until prompt cleanup as replay/dedup sentinels. + current.state = "completed"; + } else { + this.approvalRelays.delete(relay.approvalId); + } + } + } + } + + private async getGatewayApprovalDetails( + approvalId: string, + ): Promise { + try { + return await this.gateway.request("exec.approval.get", { + id: approvalId, + }); + } catch (err) { + this.log(`approval relay hydrate failed for ${approvalId}: ${String(err)}`); + return null; + } + } + + private async resolveGatewayApproval( + approvalId: string, + decision: GatewayExecApprovalDecision, + ): Promise { + try { + await this.gateway.request("exec.approval.resolve", { + id: approvalId, + decision, + }); + return true; + } catch (err) { + this.log(`approval relay resolve failed for ${approvalId}: ${String(err)}`); + return false; + } + } + + private isApprovalRelayActive(relay: PendingApprovalRelay): boolean { + return ( + this.approvalRelays.get(relay.approvalId) === relay && + relay.state === "active" && + this.getPendingPrompt(relay.sessionId, relay.runId) !== undefined + ); + } + + private clearApprovalRelaysForPrompt( + sessionId: string, + runId?: string, + opts: { denyActive?: boolean } = {}, + ): void { + for (const [approvalId, relay] of this.approvalRelays) { + if (relay.sessionId !== sessionId) { + continue; + } + if (runId && relay.runId !== runId) { + continue; + } + this.approvalRelays.delete(approvalId); + if (opts.denyActive && relay.state === "active") { + void this.resolveGatewayApproval(approvalId, "deny"); + } + } + } + private async handleChatEvent(evt: EventFrame): Promise { const payload = evt.payload as Record | undefined; if (!payload) { @@ -1250,6 +1471,7 @@ export class AcpGatewayAgent implements Agent { pending: PendingPrompt, stopReason: StopReason, ): Promise { + this.clearApprovalRelaysForPrompt(sessionId, pending.idempotencyKey, { denyActive: true }); this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); if (this.pendingPrompts.size === 0) { @@ -1298,6 +1520,20 @@ export class AcpGatewayAgent implements Agent { return undefined; } + private findUniquePendingBySessionKey(sessionKey: string): PendingPrompt | undefined { + let match: PendingPrompt | undefined; + for (const pending of this.pendingPrompts.values()) { + if (pending.sessionKey !== sessionKey) { + continue; + } + if (match) { + return undefined; + } + match = pending; + } + return match; + } + private reconcilePendingSessionKey(pending: PendingPrompt, sessionKey: string): void { if (pending.sessionKey === sessionKey) { return; @@ -1332,6 +1568,9 @@ export class AcpGatewayAgent implements Agent { if (currentPending !== pending) { return; } + this.clearApprovalRelaysForPrompt(pending.sessionId, pending.idempotencyKey, { + denyActive: true, + }); this.pendingPrompts.delete(pending.sessionId); this.sessionStore.clearActiveRun(pending.sessionId); if (this.pendingPrompts.size === 0) { @@ -1678,6 +1917,9 @@ export class AcpGatewayAgent implements Agent { } if (pending) { + this.clearApprovalRelaysForPrompt(session.sessionId, pending.idempotencyKey, { + denyActive: true, + }); this.pendingPrompts.delete(session.sessionId); if (this.pendingPrompts.size === 0) { this.clearDisconnectTimer(); diff --git a/src/agents/acp-spawn-parent-stream.test.ts b/src/agents/acp-spawn-parent-stream.test.ts index aa6ae95f0ad..2ecfa2c8089 100644 --- a/src/agents/acp-spawn-parent-stream.test.ts +++ b/src/agents/acp-spawn-parent-stream.test.ts @@ -113,9 +113,15 @@ describe("startAcpSpawnParentStreamRelay", () => { }); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("Started codex session"))).toBe(true); - expect(texts.some((text) => text.includes("codex: hello from child"))).toBe(true); - expect(texts.some((text) => text.includes("codex run completed in 2s"))).toBe(true); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("Started codex session")]), + ); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex: hello from child")]), + ); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex run completed in 2s")]), + ); expect( enqueueSystemEventMock.mock.calls.every( (call) => (call[1] as { trusted?: boolean } | undefined)?.trusted === false, @@ -150,8 +156,8 @@ describe("startAcpSpawnParentStreamRelay", () => { }); vi.advanceTimersByTime(1_500); - expect(collectedTexts().some((text) => text.includes("has produced no output for 1s"))).toBe( - true, + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("has produced no output for 1s")]), ); emitAgentEvent({ @@ -164,8 +170,10 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(5); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("resumed output."))).toBe(true); - expect(texts.some((text) => text.includes("codex: resumed output"))).toBe(true); + expect(texts).toEqual(expect.arrayContaining([expect.stringContaining("resumed output.")])); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex: resumed output")]), + ); emitAgentEvent({ runId: "run-2", @@ -175,7 +183,9 @@ describe("startAcpSpawnParentStreamRelay", () => { error: "boom", }, }); - expect(collectedTexts().some((text) => text.includes("run failed: boom"))).toBe(true); + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("run failed: boom")]), + ); relay.dispose(); }); @@ -191,8 +201,8 @@ describe("startAcpSpawnParentStreamRelay", () => { }); vi.advanceTimersByTime(1_001); - expect(collectedTexts().some((text) => text.includes("stream relay timed out after 1s"))).toBe( - true, + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("stream relay timed out after 1s")]), ); const before = enqueueSystemEventMock.mock.calls.length; @@ -218,11 +228,15 @@ describe("startAcpSpawnParentStreamRelay", () => { emitStartNotice: false, }); - expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(false); + expect(collectedTexts()).not.toEqual( + expect.arrayContaining([expect.stringContaining("Started codex session")]), + ); relay.notifyStarted(); - expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(true); + expect(collectedTexts()).toEqual( + expect.arrayContaining([expect.stringContaining("Started codex session")]), + ); relay.dispose(); }); @@ -286,7 +300,7 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(15); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("codex: hello world"))).toBe(true); + expect(texts).toEqual(expect.arrayContaining([expect.stringContaining("codex: hello world")])); relay.dispose(); }); @@ -311,8 +325,12 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(15); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("checking thread context"))).toBe(false); - expect(texts.some((text) => text.includes("post a tight progress reply here"))).toBe(false); + expect(texts).not.toEqual( + expect.arrayContaining([expect.stringContaining("checking thread context")]), + ); + expect(texts).not.toEqual( + expect.arrayContaining([expect.stringContaining("post a tight progress reply here")]), + ); relay.dispose(); }); @@ -345,8 +363,12 @@ describe("startAcpSpawnParentStreamRelay", () => { vi.advanceTimersByTime(15); const texts = collectedTexts(); - expect(texts.some((text) => text.includes("checking thread context"))).toBe(false); - expect(texts.some((text) => text.includes("codex: final answer ready"))).toBe(true); + expect(texts).not.toEqual( + expect.arrayContaining([expect.stringContaining("checking thread context")]), + ); + expect(texts).toEqual( + expect.arrayContaining([expect.stringContaining("codex: final answer ready")]), + ); relay.dispose(); }); diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index a96a20bb06a..0b26f633cd2 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -14,7 +14,7 @@ describe("createAnthropicPayloadLogger", () => { flush: async () => undefined, }, }); - expect(logger).not.toBeNull(); + expect(typeof logger?.wrapStreamFn).toBe("function"); const payload = { messages: [ @@ -41,7 +41,11 @@ describe("createAnthropicPayloadLogger", () => { }) as StreamFn; const wrapped = logger?.wrapStreamFn(streamFn); - await wrapped?.({ api: "anthropic-messages" } as never, { messages: [] } as never, {}); + expect(typeof wrapped).toBe("function"); + if (!wrapped) { + throw new Error("expected payload logger to wrap stream function"); + } + await wrapped({ api: "anthropic-messages" } as never, { messages: [] } as never, {}); const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; const sanitizedPayload = (event.payload ?? {}) as Record; diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 085d061cea7..346448d3386 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -170,7 +170,11 @@ describe("saveAuthProfileStore", () => { lastGood?: unknown; usageStats?: unknown; }; - expect(authProfiles.profiles["anthropic:default"]).toEqual(expect.any(Object)); + expect(authProfiles.profiles["anthropic:default"]).toEqual({ + type: "api_key", + provider: "anthropic", + key: "sk-anthropic-plain", + }); expect(authProfiles.order).toBeUndefined(); expect(authProfiles.lastGood).toBeUndefined(); expect(authProfiles.usageStats).toBeUndefined(); diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index ae073af73d2..3b075375e8a 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -113,10 +113,8 @@ describe("OAuth refresh in-process queue", () => { it("resetOAuthRefreshQueuesForTest drains pending gates", () => { // We can't observe the internal map, but we can assert that calling the // reset is idempotent and safe from any state. - expect(() => { - resetOAuthRefreshQueuesForTest(); - resetOAuthRefreshQueuesForTest(); - }).not.toThrow(); + expect(resetOAuthRefreshQueuesForTest()).toBeUndefined(); + expect(resetOAuthRefreshQueuesForTest()).toBeUndefined(); }); it("serializes a 10-caller burst so later arrivals never pass an earlier caller", async () => { diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index c9e38907fb3..4460118b322 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -117,9 +117,10 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () expect(callCount).toBe(1); expect(results).toHaveLength(agentCount); for (const result of results) { - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("cross-agent-refreshed-access"); - expect(result?.provider).toBe(provider); + expect(result).toMatchObject({ + apiKey: "cross-agent-refreshed-access", + provider, + }); } }, 10_000); }); diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index 673f4a0cf3a..12800417176 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -186,9 +186,10 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { // fresh main credentials are used read-through without copying the refresh token. const result = await resolveFromSecondaryAgent(profileId); - expect(result).not.toBeNull(); - expect(result?.apiKey).toBe("fresh-access-token"); - expect(result?.provider).toBe("anthropic"); + expect(result).toMatchObject({ + apiKey: "fresh-access-token", + provider: "anthropic", + }); // The secondary store keeps its local credential; inherited OAuth is read-through. const secondaryStore = JSON.parse( diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index f5649eadedb..dd4a0905349 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -364,12 +364,13 @@ describe("clearExpiredCooldowns", () => { }); it("clears expired cooldownUntil and resets errorCount", () => { + const lastFailureAt = Date.now() - 120_000; const store = makeStore({ "anthropic:default": { cooldownUntil: Date.now() - 1_000, errorCount: 4, failureCounts: { rate_limit: 3, timeout: 1 }, - lastFailureAt: Date.now() - 120_000, + lastFailureAt, }, }); @@ -380,7 +381,7 @@ describe("clearExpiredCooldowns", () => { expect(stats?.errorCount).toBe(0); expect(stats?.failureCounts).toBeUndefined(); // lastFailureAt preserved for failureWindowMs decay - expect(stats?.lastFailureAt).toEqual(expect.any(Number)); + expect(stats?.lastFailureAt).toBe(lastFailureAt); }); it("clears expired disabledUntil and disabledReason", () => { @@ -610,6 +611,7 @@ describe("markAuthProfileUsed", () => { storeMocks.updateAuthProfileStoreWithLock.mockResolvedValue(null); + const beforeUsed = Date.now(); await markAuthProfileUsed({ store, profileId: "anthropic:default", @@ -622,7 +624,7 @@ describe("markAuthProfileUsed", () => { ); expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(0); expect(store.usageStats?.["anthropic:default"]?.cooldownUntil).toBeUndefined(); - expect(store.usageStats?.["anthropic:default"]?.lastUsed).toEqual(expect.any(Number)); + expect(store.usageStats?.["anthropic:default"]?.lastUsed).toBeGreaterThanOrEqual(beforeUsed); }); it("adopts locked store usage stats without saving locally when lock update succeeds", async () => { diff --git a/src/agents/bash-tools.exec-foreground-failures.test.ts b/src/agents/bash-tools.exec-foreground-failures.test.ts index 4ca357b67de..34e6d91197e 100644 --- a/src/agents/bash-tools.exec-foreground-failures.test.ts +++ b/src/agents/bash-tools.exec-foreground-failures.test.ts @@ -48,7 +48,8 @@ describe("exec foreground failures", () => { exitCode: null, aggregated: "", }); - expect((result.details as { durationMs?: number }).durationMs).toEqual(expect.any(Number)); + expect((result.details as { durationMs?: number }).durationMs).toBeTypeOf("number"); + expect((result.details as { durationMs?: number }).durationMs).toBeGreaterThanOrEqual(0); }); it("rejects invalid host values before launching a command", async () => { diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index 7b7654a0928..1e38b355a01 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -247,9 +247,10 @@ function buildLocalPreparedNodeRun(params: { request: ExecuteNodeHostCommandParams; target: NodeExecutionTarget; }): PreparedNodeRun { + const rawCommand = formatExecCommand(params.target.argv); const command = resolveSystemRunCommandRequest({ command: params.target.argv, - rawCommand: params.request.command, + rawCommand, }); if (!command.ok) { throw new Error(command.message); @@ -258,7 +259,7 @@ function buildLocalPreparedNodeRun(params: { throw new Error("command required"); } const commandText = formatExecCommand(command.argv); - const previewText = command.previewText?.trim(); + const previewText = params.request.command.trim() || command.previewText?.trim(); const commandPreview = previewText && previewText !== commandText ? previewText : null; const plan = { argv: [...command.argv], diff --git a/src/agents/bash-tools.exec.script-preflight.test.ts b/src/agents/bash-tools.exec.script-preflight.test.ts index 8969f75ea64..8086ca6e080 100644 --- a/src/agents/bash-tools.exec.script-preflight.test.ts +++ b/src/agents/bash-tools.exec.script-preflight.test.ts @@ -490,7 +490,9 @@ describeNonWin("exec script preflight", () => { }), ).resolves.toBeUndefined(); expect(scriptOpenFlags.length).toBeGreaterThan(0); - expect(scriptOpenFlags.some((flags) => (flags & fsConstants.O_NONBLOCK) !== 0)).toBe(true); + expect(scriptOpenFlags.filter((flags) => (flags & fsConstants.O_NONBLOCK) !== 0)).not.toEqual( + [], + ); }); }); diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts index 2b6d246fe14..4eacba1e9ab 100644 --- a/src/agents/bootstrap-budget.test.ts +++ b/src/agents/bootstrap-budget.test.ts @@ -211,7 +211,8 @@ describe("bootstrap prompt warnings", () => { mode: "once", }); expect(first.warningShown).toBe(true); - expect(first.signature).toEqual(expect.any(String)); + expect(first.signature).toBeTypeOf("string"); + expect(first.signature).not.toBe(""); expect(JSON.parse(first.signature ?? "{}")).toMatchObject({ bootstrapMaxChars: 120, bootstrapTotalMaxChars: 200, @@ -474,7 +475,12 @@ describe("bootstrap prompt warnings", () => { injectLegacyWarning(optimizedTurns[2] ?? "", warningLines), ]; const cacheHitRate = (turns: string[]) => { - const hits = turns.slice(1).filter((turn, index) => turn === turns[index]).length; + let hits = 0; + for (let index = 1; index < turns.length; index++) { + if (turns[index] === turns[index - 1]) { + hits++; + } + } return hits / Math.max(1, turns.length - 1); }; diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 3ea591cac30..7b12dfb1587 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -89,8 +89,9 @@ async function createHeartbeatAgentsWorkspace() { } function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) { - expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(false); - expect(files.some((file) => file.name === "AGENTS.md")).toBe(true); + const fileNames = files.map((file) => file.name); + expect(fileNames).not.toContain("HEARTBEAT.md"); + expect(fileNames).toContain("AGENTS.md"); } describe("resolveBootstrapFilesForRun", () => { @@ -103,7 +104,8 @@ describe("resolveBootstrapFilesForRun", () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); - expect(files.some((file) => file.path === path.join(workspaceDir, "EXTRA.md"))).toBe(true); + const filePaths = files.map((file) => file.path); + expect(filePaths).toContain(path.join(workspaceDir, "EXTRA.md")); }); it("drops malformed hook files with missing/invalid paths", async () => { @@ -166,9 +168,10 @@ describe("resolveBootstrapContextForRun", () => { const result = await resolveBootstrapContextForRun({ workspaceDir }); - expect(result.bootstrapFiles.some((file) => file.name === "BOOTSTRAP.md")).toBe(true); - expect(result.contextFiles.some((file) => file.path.endsWith("BOOTSTRAP.md"))).toBe(true); - expect(result.contextFiles.some((file) => file.path.endsWith("AGENTS.md"))).toBe(true); + const bootstrapFileNames = result.bootstrapFiles.map((file) => file.name); + expect(bootstrapFileNames).toContain("BOOTSTRAP.md"); + const contextFileNames = result.contextFiles.map((file) => path.basename(file.path)); + expect(contextFileNames).toEqual(expect.arrayContaining(["BOOTSTRAP.md", "AGENTS.md"])); }); it("uses heartbeat-only bootstrap files in lightweight heartbeat mode", async () => { @@ -183,7 +186,8 @@ describe("resolveBootstrapContextForRun", () => { }); expect(files.length).toBeGreaterThan(0); - expect(files.every((file) => file.name === "HEARTBEAT.md")).toBe(true); + const nonHeartbeatFiles = files.filter((file) => file.name !== "HEARTBEAT.md"); + expect(nonHeartbeatFiles).toEqual([]); }); it("keeps bootstrap context empty in lightweight cron mode", async () => { @@ -258,7 +262,8 @@ describe("resolveBootstrapContextForRun", () => { }, }); - expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(true); + const fileNames = files.map((file) => file.name); + expect(fileNames).toContain("HEARTBEAT.md"); }); }); diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index f0d751072fb..9b13f03e088 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -53,7 +53,7 @@ describe("createCacheTrace", () => { }, }); - expect(trace).not.toBeNull(); + expect(typeof trace?.recordStage).toBe("function"); expect(trace?.filePath).toBe(resolveUserPath("~/.openclaw/logs/cache-trace.jsonl")); trace?.recordStage("session:loaded", { diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 537e635c49b..75f74541e03 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -93,6 +93,14 @@ function createRuntimeBackendEntry(params: Parameters } satisfies RuntimeBackendEntry; } +function requireCliBackendConfig(...args: Parameters) { + const resolved = resolveCliBackendConfig(...args); + if (!resolved) { + throw new Error(`expected CLI backend config for ${args[0]}`); + } + return resolved; +} + function createClaudeCliOverrideConfig(config: CliBackendConfig): OpenClawConfig { return { agents: { @@ -377,10 +385,9 @@ beforeEach(() => { describe("resolveCliBackendConfig reliability merge", () => { it("defaults codex-cli fresh sandboxing and config-pinned resume sandboxing", () => { - const resolved = resolveCliBackendConfig("codex-cli"); + const resolved = requireCliBackendConfig("codex-cli"); - expect(resolved).not.toBeNull(); - expect(resolved?.config.args).toEqual([ + expect(resolved.config.args).toEqual([ "exec", "--json", "--color", @@ -391,7 +398,7 @@ describe("resolveCliBackendConfig reliability merge", () => { 'service_tier="priority"', "--skip-git-repo-check", ]); - expect(resolved?.config.resumeArgs).toEqual([ + expect(resolved.config.resumeArgs).toEqual([ "exec", "resume", "{sessionId}", @@ -423,15 +430,14 @@ describe("resolveCliBackendConfig reliability merge", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("codex-cli", cfg); + const resolved = requireCliBackendConfig("codex-cli", cfg); - expect(resolved).not.toBeNull(); - expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000); + expect(resolved.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000); // Ensure defaults are retained when only one field is overridden. - expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3); - expect(resolved?.config.reliability?.watchdog?.resume?.minMs).toBe(60_000); - expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000); - expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); + expect(resolved.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3); + expect(resolved.config.reliability?.watchdog?.resume?.minMs).toBe(60_000); + expect(resolved.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000); + expect(resolved.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); }); it("deep-merges reliability output-limit overrides", () => { @@ -467,9 +473,8 @@ describe("resolveCliBackendConfig reliability merge", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("test-cli", cfg); + const resolved = requireCliBackendConfig("test-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.reliability?.outputLimits).toEqual({ maxTurnRawChars: 16_384, maxTurnLines: 20_000, @@ -511,9 +516,8 @@ describe("resolveCliBackendLiveTest", () => { describe("resolveCliBackendConfig claude-cli defaults", () => { it("derives bypassPermissions from OpenClaw's default YOLO exec policy", () => { - const resolved = resolveCliBackendConfig("claude-cli"); + const resolved = requireCliBackendConfig("claude-cli"); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("claude-config-file"); expect(resolved?.config.output).toBe("jsonl"); @@ -543,11 +547,10 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }); it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => { - const resolved = resolveCliBackendConfig("claude-cli", { + const resolved = requireCliBackendConfig("claude-cli", { tools: { exec: { security: "allowlist", ask: "on-miss" } }, }); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).not.toContain("--permission-mode"); expect(resolved?.config.args).not.toContain("bypassPermissions"); expect(resolved?.config.resumeArgs).not.toContain("--permission-mode"); @@ -631,9 +634,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.command).toBe("/usr/local/bin/claude"); expect(resolved?.config.args).toContain("--setting-sources"); expect(resolved?.config.args).toContain("user"); @@ -679,9 +681,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); expect(resolved?.config.args).not.toContain("--permission-mode"); expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); @@ -709,9 +710,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); expect(resolved?.config.args).toEqual([ "-p", @@ -754,9 +754,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toEqual([ "-p", "--setting-sources", @@ -783,9 +782,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS); expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS); }); @@ -800,9 +798,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS); expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS); }); @@ -830,9 +827,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.args).toContain("--setting-sources"); expect(resolved?.config.args).toContain("user"); expect(resolved?.config.args).not.toContain("--permission-mode"); @@ -859,9 +855,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.env).toEqual({ SAFE_CUSTOM: "ok", ANTHROPIC_BASE_URL: "https://evil.example.com/v1", @@ -894,9 +889,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("claude-cli", cfg); + const resolved = requireCliBackendConfig("claude-cli", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("claude-config-file"); expect(resolved?.config.args).toEqual([ @@ -930,9 +924,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { describe("resolveCliBackendConfig google-gemini-cli defaults", () => { it("uses Gemini CLI json args and existing-session resume mode", () => { - const resolved = resolveCliBackendConfig("google-gemini-cli"); + const resolved = requireCliBackendConfig("google-gemini-cli"); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("gemini-system-settings"); expect(resolved?.config.args).toEqual([ @@ -958,9 +951,8 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { }); it("uses Codex CLI bundle MCP config overrides", () => { - const resolved = resolveCliBackendConfig("codex-cli"); + const resolved = requireCliBackendConfig("codex-cli"); - expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("codex-config-overrides"); expect(resolved?.defaultAuthProfileId).toBeUndefined(); @@ -990,7 +982,7 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { }), ]; - const resolved = resolveCliBackendConfig("claude-cli"); + const resolved = requireCliBackendConfig("claude-cli"); expect(resolved?.resolveExecutionArgs).toBe(resolveExecutionArgs); }); @@ -1026,9 +1018,8 @@ describe("resolveCliBackendConfig alias precedence", () => { }, } satisfies OpenClawConfig; - const resolved = resolveCliBackendConfig("kimi", cfg); + const resolved = requireCliBackendConfig("kimi", cfg); - expect(resolved).not.toBeNull(); expect(resolved?.config.command).toBe("kimi-canonical"); expect(resolved?.config.args).toEqual(["--canonical"]); }); diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 07c917cb96d..8f5b99b4fd9 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -437,7 +437,7 @@ describe("writeCliImages", () => { useResume: false, }); - expect(argv.filter((arg) => arg === "--image")).toHaveLength(1); + expect(argv.reduce((count, arg) => count + (arg === "--image" ? 1 : 0), 0)).toBe(1); expect(argv[argv.indexOf("--image") + 1]).toContain("openclaw-cli-images"); await expect(fs.readFile(prepared.imagePaths?.[0] ?? "")).resolves.toEqual( Buffer.from(explicitImage.data, "base64"), diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index b6ad3f7a2fe..c4048a402be 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -673,23 +673,28 @@ describe("runCliAgent spawn path", () => { it("cancels the managed CLI run when the abort signal fires", async () => { const abortController = new AbortController(); - let resolveWait!: (value: { - reason: - | "manual-cancel" - | "overall-timeout" - | "no-output-timeout" - | "spawn-error" - | "signal" - | "exit"; - exitCode: number | null; - exitSignal: NodeJS.Signals | number | null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: boolean; - noOutputTimedOut: boolean; - }) => void; + let resolveWait: + | ((value: { + reason: + | "manual-cancel" + | "overall-timeout" + | "no-output-timeout" + | "spawn-error" + | "signal" + | "exit"; + exitCode: number | null; + exitSignal: NodeJS.Signals | number | null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: boolean; + noOutputTimedOut: boolean; + }) => void) + | undefined; const cancel = vi.fn((reason?: string) => { + if (!resolveWait) { + throw new Error("Expected managed CLI wait resolver to be initialized"); + } resolveWait({ reason: reason === "manual-cancel" ? "manual-cancel" : "signal", exitCode: null, @@ -1971,16 +1976,18 @@ describe("runCliAgent spawn path", () => { it("does not surface stale stderr after a later Claude live exit", async () => { let stdoutListener: ((chunk: string) => void) | undefined; let stderrListener: ((chunk: string) => void) | undefined; - let resolveExit!: (value: { - reason: "exit"; - exitCode: number; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }) => void; + let resolveExit: + | ((value: { + reason: "exit"; + exitCode: number; + exitSignal: null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: false; + noOutputTimedOut: false; + }) => void) + | undefined; const wait = new Promise<{ reason: "exit"; exitCode: number; @@ -2013,6 +2020,9 @@ describe("runCliAgent spawn path", () => { return; } cb?.(); + if (!resolveExit) { + throw new Error("Expected Claude live exit resolver to be initialized"); + } resolveExit({ reason: "exit", exitCode: 1, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index ef966e2787c..f797471efea 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -619,6 +619,41 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { } }); + it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => { + const { dir, sessionFile } = createSessionFile(); + try { + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "owner-token", + nonOwnerToken: "non-owner-token", + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + }); + + await expect( + prepareCliRunContext({ + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-tools-allow", + config: createCliBackendConfig({ bundleMcp: true }), + toolsAllow: ["read", "web_search"], + }), + ).rejects.toThrow( + "CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy", + ); + + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("fails closed for native tool-capable CLI backends when tools are disabled", async () => { const { dir, sessionFile } = createSessionFile(); try { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 226899880a9..730a4266572 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -113,6 +113,11 @@ export async function prepareCliRunContext( if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } + if (params.toolsAllow !== undefined) { + throw new Error( + `CLI backend ${backendResolved.id} cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy`, + ); + } if (params.disableTools === true && backendResolved.nativeToolMode === "always-on") { throw new Error( `CLI backend ${backendResolved.id} cannot run with tools disabled because it exposes native tools`, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 810a8221ccc..b699db87218 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -50,6 +50,8 @@ export type RunCliAgentParams = { messageProvider?: string; agentAccountId?: string; senderIsOwner?: boolean; + /** Runtime tool allow-list. CLI harnesses fail closed when this is set. */ + toolsAllow?: string[]; disableTools?: boolean; abortSignal?: AbortSignal; onExecutionStarted?: () => void; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index ebb71c0c3d4..4d92fbe49c4 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -509,8 +509,9 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); const sessionFile = updatedFirst?.sessionFile; + expect(sessionFile).toBeTruthy(); if (!sessionFile) { - throw new Error("expected embedded gap-fill persistence to create a session file"); + throw new Error("Expected CLI transcript session file."); } await appendSessionTranscriptMessage({ @@ -636,6 +637,57 @@ describe("CLI attempt execution", () => { ); }); + it("forwards runtime toolsAllow into CLI attempts so the CLI harness can fail closed", async () => { + const sessionKey = "agent:main:direct:claude-tools-allow"; + const sessionEntry: SessionEntry = { + sessionId: "openclaw-session-cli-tools-allow", + updatedAt: Date.now(), + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + runCliAgentMock.mockResolvedValueOnce(makeCliResult("restricted cli")); + + await runAgentAttempt({ + providerOverride: "claude-cli", + originalProvider: "claude-cli", + modelOverride: "opus", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey, + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "route this", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-cli-tools-allow", + opts: { + senderIsOwner: true, + toolsAllow: ["read", "web_search"], + } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: "discord", + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "claude-cli", + sessionStore, + storePath, + sessionHasHistory: false, + }); + + expect(runCliAgentMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "claude-cli", + toolsAllow: ["read", "web_search"], + }), + ); + }); + it("routes canonical Anthropic models through the configured Claude CLI runtime", async () => { const sessionKey = "agent:main:direct:canonical-claude-cli"; const sessionEntry: SessionEntry = { @@ -983,6 +1035,53 @@ describe("embedded attempt harness pinning", () => { ); }); + it("forwards runtime toolsAllow into embedded attempts", async () => { + const sessionEntry: SessionEntry = { + sessionId: "tools-allow-session", + updatedAt: Date.now(), + }; + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedPiRunResult); + + await runAgentAttempt({ + providerOverride: "openai", + originalProvider: "openai", + modelOverride: "gpt-5.4", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "read only", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-tools-allow", + opts: { + senderIsOwner: true, + toolsAllow: ["read", "web_search"], + } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "openai", + sessionHasHistory: false, + }); + + expect(runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + toolsAllow: ["read", "web_search"], + }), + ); + }); + it("lets provider/model runtime policy choose Codex without storing a session harness pin", async () => { const sessionEntry: SessionEntry = { sessionId: "codex-history-session", diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index c45db13622b..7cda6fcbf80 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -521,6 +521,7 @@ export function runAgentAttempt(params: { messageProvider: params.opts.messageProvider ?? params.messageChannel, agentAccountId: params.runContext.accountId, senderIsOwner: params.opts.senderIsOwner, + toolsAllow: params.opts.toolsAllow, cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd, cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd, }); @@ -624,6 +625,7 @@ export function runAgentAttempt(params: { extraSystemPrompt: params.opts.extraSystemPrompt, bootstrapContextMode: params.opts.bootstrapContextMode, bootstrapContextRunKind: params.opts.bootstrapContextRunKind, + toolsAllow: params.opts.toolsAllow, internalEvents: params.opts.internalEvents, inputProvenance: params.opts.inputProvenance, streamParams: params.opts.streamParams, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 7371a973bf7..389b45265ac 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -83,6 +83,8 @@ export type AgentCommandOpts = { senderIsOwner?: boolean; /** Whether this caller is authorized to use provider/model per-run overrides. */ allowModelOverride?: boolean; + /** Optional runtime tool allow-list; when set, only these tools are exposed for this run. */ + toolsAllow?: string[]; /** Group/spawn metadata for subagent policy inheritance and routing context. */ groupId?: SpawnedRunMetadata["groupId"]; groupChannel?: SpawnedRunMetadata["groupChannel"]; diff --git a/src/agents/current-time.ts b/src/agents/current-time.ts index b98b8594669..f0de821e226 100644 --- a/src/agents/current-time.ts +++ b/src/agents/current-time.ts @@ -26,7 +26,7 @@ export function resolveCronStyleNow(cfg: TimeConfigLike, nowMs: number): CronSty const formattedTime = formatUserTime(new Date(nowMs), userTimezone, userTimeFormat) ?? new Date(nowMs).toISOString(); const utcTime = new Date(nowMs).toISOString().replace("T", " ").slice(0, 16) + " UTC"; - const timeLine = `Current time: ${formattedTime} (${userTimezone}) / ${utcTime}`; + const timeLine = `Current time: ${formattedTime} (${userTimezone})\nReference UTC: ${utcTime}`; return { userTimezone, formattedTime, timeLine }; } diff --git a/src/agents/google-gemini-switch.live.test.ts b/src/agents/google-gemini-switch.live.test.ts index 95379aba60a..efc275c4d16 100644 --- a/src/agents/google-gemini-switch.live.test.ts +++ b/src/agents/google-gemini-switch.live.test.ts @@ -10,7 +10,7 @@ const LIVE = isLiveTestEnabled(["GEMINI_LIVE_TEST"]); const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip; describeLive("gemini live switch", () => { - const googleModels = ["gemini-3-pro-preview", "gemini-2.5-pro"] as const; + const googleModels = ["gemini-3.1-pro-preview", "gemini-2.5-pro"] as const; for (const modelId of googleModels) { it(`handles unsigned tool calls from Antigravity when switching to ${modelId}`, async () => { diff --git a/src/agents/harness-runtimes.ts b/src/agents/harness-runtimes.ts index bfdf17b02c0..3583d318db4 100644 --- a/src/agents/harness-runtimes.ts +++ b/src/agents/harness-runtimes.ts @@ -107,19 +107,49 @@ function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set): void { + const pushRuntimeId = (value: unknown) => { + const runtime = normalizeRuntimeId(value); + if (runtime && runtime !== "auto" && runtime !== "pi") { + runtimes.add(runtime); + } + }; + + pushRuntimeId(config.agents?.defaults?.agentRuntime?.id); + for (const agent of config.agents?.list ?? []) { + pushRuntimeId(agent.agentRuntime?.id); + } +} + +export type ConfiguredAgentHarnessRuntimeOptions = { + includeEnvRuntime?: boolean; + includeLegacyAgentRuntimes?: boolean; +}; + export function collectConfiguredAgentHarnessRuntimes( config: OpenClawConfig, env: NodeJS.ProcessEnv, + options: ConfiguredAgentHarnessRuntimeOptions = {}, ): string[] { const runtimes = new Set(); + const includeEnvRuntime = options.includeEnvRuntime ?? true; + const includeLegacyAgentRuntimes = options.includeLegacyAgentRuntimes ?? true; const pushCodexForOpenAIModel = (model: unknown, agentId?: string) => { if (hasOpenAIModelRef(config, model, agentId)) { runtimes.add("codex"); } }; - void env; + if (includeEnvRuntime) { + const envRuntime = normalizeRuntimeId(env.OPENCLAW_AGENT_RUNTIME); + if (envRuntime && envRuntime !== "auto" && envRuntime !== "pi") { + runtimes.add(envRuntime); + } + } pushConfiguredModelRuntimeIds(config, runtimes); + if (includeLegacyAgentRuntimes) { + pushLegacyAgentRuntimeIds(config, runtimes); + } const defaultsModel = config.agents?.defaults?.model; pushCodexForOpenAIModel(defaultsModel); if (Array.isArray(config.agents?.list)) { diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index acbb2f44c20..a259b584504 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -384,7 +384,7 @@ describe("native hook relay registry", () => { const invocations = __testing.getNativeHookRelayInvocationsForTests(); expect(invocations).toHaveLength(200); - expect(invocations.some((invocation) => invocation.toolUseId === "call-0")).toBe(false); + expect(invocations.map((invocation) => invocation.toolUseId)).not.toContain("call-0"); expect(invocations.at(-1)).toEqual(expect.objectContaining({ toolUseId: "call-209" })); }); diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 951d40dfc61..f99c3daef87 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -305,6 +305,7 @@ describe("main-session-restart-recovery", () => { const callParams = vi.mocked(callGateway).mock.calls[0]?.[0].params as { message?: string }; expect(callParams.message).toContain(pendingPayload); + const beforeStoreRead = Date.now(); const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); const entry = store["agent:main:main"]; expect(entry).toMatchObject({ @@ -314,8 +315,8 @@ describe("main-session-restart-recovery", () => { pendingFinalDeliveryAttemptCount: 1, pendingFinalDeliveryLastError: null, }); - expect(entry?.pendingFinalDeliveryCreatedAt).toEqual(expect.any(Number)); - expect(entry?.pendingFinalDeliveryLastAttemptAt).toEqual(expect.any(Number)); + expect(entry?.pendingFinalDeliveryCreatedAt).toBeLessThanOrEqual(beforeStoreRead); + expect(entry?.pendingFinalDeliveryLastAttemptAt).toBeLessThanOrEqual(beforeStoreRead); expect(entry?.pendingFinalDeliveryLastAttemptAt ?? 0).toBeGreaterThanOrEqual( entry?.pendingFinalDeliveryCreatedAt ?? Number.POSITIVE_INFINITY, ); diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 1d35c9cd0f9..0f9a1b46cdc 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -372,15 +372,13 @@ async function resolveDemoLocalApiKey(params: { storedKeys: string[]; configuredApiKey: string; }) { - let resolved!: Awaited>; - await withEnvAsync({ DEMO_LOCAL_API_KEY: params.envApiKey }, async () => { - resolved = await resolveApiKeyForProvider({ + return await withEnvAsync({ DEMO_LOCAL_API_KEY: params.envApiKey }, async () => { + return await resolveApiKeyForProvider({ provider: "demo-local", store: buildDemoLocalStore(params.storedKeys), cfg: buildDemoLocalProviderCfg(params.configuredApiKey), }); }); - return resolved; } describe("getApiKeyForModel", () => { diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index ed93a4303ab..1cb40d35e31 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -73,6 +73,7 @@ let setLoggerOverride: LoggerModule["setLoggerOverride"]; const makeCfg = makeModelFallbackCfg; let cleanupLogCapture: (() => void) | undefined; +const OPENAI_PROBE_CANDIDATE = { provider: "openai", model: "gpt-4.1-mini" } as const; async function loadModelFallbackProbeModules() { const authProfilesStoreModule = await import("./auth-profiles/store.js"); @@ -209,7 +210,7 @@ describe("runWithModelFallback – probe logic", () => { mockedGetSoonestCooldownExpiry.mockReturnValue(params.soonest); mockedResolveProfilesUnavailableReason.mockReturnValue(params.reason); return modelFallbackTesting.resolveCooldownDecision({ - candidate: { provider: "openai", model: "gpt-4.1-mini" }, + candidate: OPENAI_PROBE_CANDIDATE, isPrimary: params.isPrimary ?? true, requestedModel: params.requestedModel ?? true, hasFallbackCandidates: params.hasFallbackCandidates ?? true, @@ -226,6 +227,17 @@ describe("runWithModelFallback – probe logic", () => { }); } + function expectOpenAiProbeSuspension( + decision: ReturnType, + reason: "rate_limit" | "billing", + ) { + expect(decision).toEqual({ + type: "suspend_lanes", + reason, + leaderCandidate: OPENAI_PROBE_CANDIDATE, + }); + } + async function expectPrimarySkippedAfterLongCooldown(reason: "billing" | "rate_limit") { const cfg = makeCfg(); const expiresIn30Min = NOW + 30 * 60 * 1000; @@ -323,13 +335,14 @@ describe("runWithModelFallback – probe logic", () => { ).toEqual({ type: "attempt", reason: "rate_limit", markProbe: true }); _probeThrottleInternals.lastProbeAttempt.set("recent-openai", NOW - 10_000); - expect( + expectOpenAiProbeSuspension( resolveOpenAiCooldownDecision({ reason: "rate_limit", soonest: NOW + 30 * 1000, throttleKey: "recent-openai", }), - ).toMatchObject({ type: "skip", reason: "rate_limit" }); + "rate_limit", + ); }); it("logs primary metadata on probe success and failure fallback decisions", async () => { @@ -633,13 +646,14 @@ describe("runWithModelFallback – probe logic", () => { const agentBKey = _probeThrottleInternals.resolveProbeThrottleKey("openai", "/tmp/agent-b"); _probeThrottleInternals.lastProbeAttempt.set(agentAKey, NOW - 10_000); - expect( + expectOpenAiProbeSuspension( resolveOpenAiCooldownDecision({ reason: "rate_limit", soonest: NOW + 30 * 1000, throttleKey: agentAKey, }), - ).toMatchObject({ type: "skip", reason: "rate_limit" }); + "rate_limit", + ); expect( resolveOpenAiCooldownDecision({ reason: "rate_limit", @@ -666,11 +680,12 @@ describe("runWithModelFallback – probe logic", () => { soonest: NOW + 60 * 1000, }), ).toEqual({ type: "attempt", reason: "billing", markProbe: true }); - expect( + expectOpenAiProbeSuspension( resolveOpenAiCooldownDecision({ reason: "billing", soonest: NOW + 30 * 60 * 1000, }), - ).toMatchObject({ type: "skip", reason: "billing" }); + "billing", + ); }); }); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index b1737efb172..7c9df6b4a17 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -131,9 +131,19 @@ const authRuntimeMock = vi.hoisted(() => { continue; } const stats = store.usageStats?.[profileId]; - const expiry = [stats?.cooldownUntil, stats?.disabledUntil] - .filter((value): value is number => isActive(value, ts)) - .toSorted((a, b) => a - b)[0]; + const cooldownUntil = stats?.cooldownUntil; + const disabledUntil = stats?.disabledUntil; + let expiry: number | undefined; + if (isActive(cooldownUntil, ts)) { + expiry = cooldownUntil; + } + if ( + disabledUntil !== undefined && + isActive(disabledUntil, ts) && + (expiry === undefined || disabledUntil < expiry) + ) { + expiry = disabledUntil; + } if (expiry !== undefined && (soonest === null || expiry < soonest)) { soonest = expiry; } diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index b8e702f4fa2..09753f00af6 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -123,7 +123,11 @@ export function inferUniqueProviderFromConfiguredModels(params: { if (!modelId) { continue; } - if (modelId === model || normalizeLowercaseStringOrEmpty(modelId) === normalized) { + const normalizedModelId = normalizeStaticProviderModelId(providerId, modelId); + if ( + normalizedModelId === model || + normalizeLowercaseStringOrEmpty(normalizedModelId) === normalized + ) { addProvider(providerId); } } @@ -834,7 +838,8 @@ export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): Mo continue; } for (const model of provider.models) { - const id = normalizeOptionalString(model?.id) ?? ""; + const rawId = normalizeOptionalString(model?.id) ?? ""; + const id = rawId ? normalizeStaticProviderModelId(providerId, rawId) : ""; if (!id) { continue; } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 62a9c15b75d..7ebd9ae5f92 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -5,6 +5,7 @@ import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.j import { migrateLegacyRuntimeModelRef } from "./model-runtime-aliases.js"; import { buildAllowedModelSet, + buildConfiguredModelCatalog, inferUniqueProviderFromConfiguredModels, parseModelRef, buildModelAliasIndex, @@ -689,6 +690,25 @@ describe("model-selection", () => { ).toBe("qwen-dashscope"); }); + it("infers Google provider from canonicalized configured provider catalogs", () => { + const cfg = { + models: { + providers: { + google: { + models: [{ id: "gemini-3-pro-preview" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "gemini-3.1-pro-preview", + }), + ).toBe("google"); + }); + it("returns undefined when provider catalog matches are ambiguous", () => { const cfg = { models: { @@ -712,6 +732,33 @@ describe("model-selection", () => { }); }); + describe("buildConfiguredModelCatalog", () => { + it("emits canonical Google Gemini 3.1 provider model ids", () => { + const cfg = { + models: { + providers: { + google: { + models: [ + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(buildConfiguredModelCatalog({ cfg })).toContainEqual( + expect.objectContaining({ + provider: "google", + id: "gemini-3.1-pro-preview", + name: "Gemini 3 Pro", + }), + ); + }); + }); + describe("buildModelAliasIndex", () => { it("should build alias index from config", () => { const cfg: Partial = { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 33042429a07..f304c81b036 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -14,7 +14,9 @@ import { resolveAgentModelFallbacksOverride, } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { findModelInCatalog } from "./model-catalog-lookup.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; export { resolveThinkingDefault } from "./model-thinking-default.js"; import { type ModelRef, @@ -236,6 +238,70 @@ export function resolveDefaultModelForAgent(params: { }); } +export async function canonicalizeCaseOnlyCatalogModelRef(params: { + raw: string | undefined; + cfg?: OpenClawConfig; + defaultProvider: string; + loadCatalog: () => Promise; + aliasIndex?: ModelAliasIndex; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; + preserveAuthProfile?: boolean; +}): Promise { + const rawModel = normalizeOptionalString(params.raw); + if (!rawModel) { + return undefined; + } + const split = splitTrailingAuthProfile(rawModel); + if (shouldKeepProfileQualifiedModelRefRaw(split.profile, params.preserveAuthProfile)) { + return rawModel; + } + if (!isCaseOnlyProviderModelRef(split.model)) { + return rawModel; + } + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw: split.model, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (!resolved) { + return rawModel; + } + const entry = findModelInCatalog( + await params.loadCatalog(), + resolved.ref.provider, + resolved.ref.model, + ); + return entry ? formatCatalogModelRef(entry, split.profile) : rawModel; +} + +function hasExplicitProviderModelRef(raw: string): boolean { + const slash = raw.indexOf("/"); + return slash > 0 && slash < raw.length - 1; +} + +function isCaseOnlyProviderModelRef(raw: string): boolean { + return hasExplicitProviderModelRef(raw) && raw !== raw.toLowerCase(); +} + +function shouldKeepProfileQualifiedModelRefRaw( + profile: string | undefined, + preserveAuthProfile: boolean | undefined, +): boolean { + return Boolean(profile && preserveAuthProfile === false); +} + +function formatCatalogModelRef(entry: ModelCatalogEntry, profile: string | undefined): string { + return appendAuthProfileSuffix(`${entry.provider}/${entry.id}`, profile); +} + +function appendAuthProfileSuffix(modelRef: string, profile: string | undefined): string { + return profile ? `${modelRef}@${profile}` : modelRef; +} + function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string }): string[] { if (params.agentId) { const override = resolveAgentModelFallbacksOverride(params.cfg, params.agentId); diff --git a/src/agents/models-config.providers.policy.lookup.test.ts b/src/agents/models-config.providers.policy.lookup.test.ts index f7b95ec8be4..53f53bbdb04 100644 --- a/src/agents/models-config.providers.policy.lookup.test.ts +++ b/src/agents/models-config.providers.policy.lookup.test.ts @@ -50,22 +50,22 @@ describe("resolveProviderPluginLookupKey", () => { ).toBe("google"); }); - it("does not throw when runtime provider models is an object map", () => { - expect(() => + it("falls through when runtime provider models is an object map", () => { + expect( resolveProviderPluginLookupKey("openrouter", { baseUrl: "https://openrouter.ai/api/v1", models: { "some/model": { api: "openai-completions" } } as never, }), - ).not.toThrow(); + ).toBe("openrouter"); }); - it("does not throw when runtime provider models is undefined", () => { - expect(() => + it("falls through when runtime provider models is undefined", () => { + expect( resolveProviderPluginLookupKey("openrouter", { baseUrl: "https://openrouter.ai/api/v1", models: undefined as never, }), - ).not.toThrow(); + ).toBe("openrouter"); }); it("falls through to the provider key when runtime provider models is non-array", () => { diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index bbedd794d31..17ce8e2063e 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -224,7 +224,7 @@ describe("models-config", () => { provider: { baseUrl: "https://api.copilot.example", models: [] }, }); - expectCopilotProviderFromPlan(plan).toEqual({ + expect(expectCopilotProviderFromPlan(plan)).toEqual({ baseUrl: "https://api.copilot.example", models: [], }); @@ -235,7 +235,7 @@ describe("models-config", () => { provider: { baseUrl: "https://api.individual.githubcopilot.com", models: [] }, }); - expectCopilotProviderFromPlan(plan)?.toEqual({ + expect(expectCopilotProviderFromPlan(plan)).toEqual({ baseUrl: "https://api.individual.githubcopilot.com", models: [], }); @@ -271,6 +271,7 @@ function expectCopilotProviderFromPlan( plan.action === "write" ? (JSON.parse(plan.contents) as { providers?: Record }) : {}; - expect(parsed.providers?.["github-copilot"]).toEqual(expect.any(Object)); - return expect(parsed.providers?.["github-copilot"]); + const provider = parsed.providers?.["github-copilot"]; + expect(provider).toEqual(expect.any(Object)); + return provider; } diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index f517f29edbe..eedfcf25fce 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -998,9 +998,11 @@ describe("openai transport stream", () => { }; expect(params.instructions).toBe("Stable prefix\nDynamic suffix"); - expect(params.input?.some((item) => item.role === "system" || item.role === "developer")).toBe( - false, - ); + expect(Array.isArray(params.input)).toBe(true); + expect(params.input?.map((item) => item.role)).toEqual(["user"]); + expect( + params.input?.filter((item) => item.role === "system" || item.role === "developer"), + ).toEqual([]); expect(params.prompt_cache_key).toBe("session-123"); expect(params.store).toBe(false); expect(params).not.toHaveProperty("metadata"); @@ -1299,7 +1301,9 @@ describe("openai transport stream", () => { }>; }; - expect(params.input?.some((item) => item.type === "reasoning")).toBe(true); + expect( + params.input?.reduce((count, item) => count + (item.type === "reasoning" ? 1 : 0), 0), + ).toBe(1); const assistantMessage = params.input?.find( (item) => item.type === "message" && item.role === "assistant", ); @@ -3386,9 +3390,9 @@ describe("openai transport stream", () => { await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream); expect(output.stopReason).toBe("stop"); - expect(output.content.some((block) => (block as { type?: string }).type === "toolCall")).toBe( - false, - ); + expect( + output.content.filter((block) => (block as { type?: string }).type === "toolCall"), + ).toEqual([]); }); it("handles reasoning_details from OpenRouter/Qwen3 in completions stream", async () => { diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts index 5f164e3d0e2..e629089afe3 100644 --- a/src/agents/openai-ws-connection.test.ts +++ b/src/agents/openai-ws-connection.test.ts @@ -538,7 +538,7 @@ describe("OpenAIWebSocketManager", () => { it("is safe to call before connect()", () => { const manager = buildManager(); - expect(() => manager.close()).not.toThrow(); + expect(manager.close()).toBeUndefined(); expect(manager.connectionState).toBe("closed"); }); }); @@ -775,7 +775,7 @@ describe("OpenAIWebSocketManager", () => { lastSocket().simulateError(new Error("SSL handshake failed")); await p; - expect(errors.some((e) => e.message === "SSL handshake failed")).toBe(true); + expect(errors.map((error) => error.message)).toContain("SSL handshake failed"); }); it("handles multiple successive socket errors without crashing", async () => { @@ -790,8 +790,9 @@ describe("OpenAIWebSocketManager", () => { await p; expect(errors.length).toBeGreaterThanOrEqual(2); - expect(errors.some((e) => e.message === "first error")).toBe(true); - expect(errors.some((e) => e.message === "second error")).toBe(true); + expect(errors.map((error) => error.message)).toEqual( + expect.arrayContaining(["first error", "second error"]), + ); }); }); diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index f42007add08..185ce90fce9 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -29,6 +29,16 @@ import type { InputItem, ResponseCreateEvent } from "./openai-ws-types.js"; import { log } from "./pi-embedded-runner/logger.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + // ───────────────────────────────────────────────────────────────────────────── // Mock OpenAIWebSocketManager // ───────────────────────────────────────────────────────────────────────────── @@ -1266,8 +1276,7 @@ describe("buildAssistantMessageFromResponse", () => { it("includes both text and tool calls when both present", () => { const response = makeResponseObject("resp_4", "Running...", "exec"); const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content.some((c) => c.type === "text")).toBe(true); - expect(msg.content.some((c) => c.type === "toolCall")).toBe(true); + expect(msg.content.map((c) => c.type)).toEqual(["text", "toolCall"]); expect(msg.stopReason).toBe("toolUse"); }); @@ -2694,7 +2703,9 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); // The failed manager is closed before the replacement session manager is installed. - expect(MockManager.instances.some((instance) => instance.closeCallCount >= 1)).toBe(true); + expect( + countMatching(MockManager.instances, (instance) => instance.closeCallCount >= 1), + ).toBeGreaterThanOrEqual(1); } finally { MockManager.globalConnectShouldFail = false; } @@ -2750,7 +2761,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "start")).toBe(1); expect(events.some((event) => event.type === "error")).toBe(false); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); @@ -2784,7 +2795,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "start")).toBe(1); expect(events.some((event) => event.type === "error")).toBe(false); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); @@ -2819,7 +2830,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(streamSimpleCalls).toHaveLength(0); expect(firstManager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "start")).toBe(1); const doneEvent = events.find((event) => event.type === "done"); expect(doneEvent?.message?.content?.[0]?.text).toBe("retry succeeded"); }); @@ -3026,8 +3037,7 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(sent2.previous_response_id).toBe("resp_turn1"); // Input should only contain tool results, not the full history const inputTypes = (sent2.input ?? []).map((i) => i.type); - expect(inputTypes.every((t) => t === "function_call_output")).toBe(true); - expect(inputTypes).toHaveLength(1); + expect(inputTypes).toEqual(["function_call_output"]); }); it("sends only a follow-up user message when the full context is a strict extension", async () => { diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index a3c4ac5dbde..734a6dfb5fd 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -900,7 +900,7 @@ describe("session_status tool", () => { liveModelSwitchPending: true, }), ); - expect(saved.sessionId).toEqual(expect.any(String)); + expect(saved.sessionId).toBeTypeOf("string"); expect(saved.sessionId.trim().length).toBeGreaterThan(0); }); @@ -928,7 +928,7 @@ describe("session_status tool", () => { liveModelSwitchPending: true, }), ); - expect(saved.sessionId).toEqual(expect.any(String)); + expect(saved.sessionId).toBeTypeOf("string"); expect(saved.sessionId.trim().length).toBeGreaterThan(0); }); diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index b6942e7961d..a1ebce999b1 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -52,6 +52,16 @@ const TEST_CONFIG = { }, } as OpenClawConfig; +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + const resolveSessionConversationStub: NonNullable< ChannelMessagingAdapter["resolveSessionConversation"] > = ({ rawId }) => ({ @@ -781,7 +791,8 @@ describe("sessions tools", () => { it("sessions_send supports fire-and-forget and wait", async () => { const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; - let _historyCallCount = 0; + let historyCallCount = 0; + let waitCallCount = 0; let sendCallCount = 0; let lastWaitedRunId: string | undefined; const replyByRunId = new Map(); @@ -810,12 +821,13 @@ describe("sessions tools", () => { }; } if (request.method === "agent.wait") { + waitCallCount += 1; const params = request.params as { runId?: string } | undefined; lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { - _historyCallCount += 1; + historyCallCount += 1; const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [ @@ -857,9 +869,9 @@ describe("sessions tools", () => { runId: "run-1", delivery: { status: "pending", mode: "announce" }, }); - await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 3); - await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 3); - await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 3); + await waitForCalls(() => agentCallCount, 3); + await waitForCalls(() => waitCallCount, 3); + await waitForCalls(() => historyCallCount, 3); const waitPromise = tool.execute("call6", { sessionKey: "main", @@ -873,9 +885,9 @@ describe("sessions tools", () => { delivery: { status: "pending", mode: "announce" }, }); expect(typeof (waited.details as { runId?: string }).runId).toBe("string"); - await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 6); - await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 6); - await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 7); + await waitForCalls(() => agentCallCount, 6); + await waitForCalls(() => waitCallCount, 6); + await waitForCalls(() => historyCallCount, 7); const agentCalls = calls.filter((call) => call.method === "agent"); const waitCalls = calls.filter((call) => call.method === "agent.wait"); @@ -1052,7 +1064,7 @@ describe("sessions tools", () => { }); await vi.waitFor( () => { - expect(calls.filter((call) => call.method === "agent")).toHaveLength(3); + expect(countMatching(calls, (call) => call.method === "agent")).toBe(3); }, { timeout: 2_000, interval: 5 }, ); @@ -1248,7 +1260,7 @@ describe("sessions tools", () => { sessionKey: targetKey, }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls.filter((call) => call.method === "agent")).toHaveLength(1); + expect(countMatching(calls, (call) => call.method === "agent")).toBe(1); }); it("sessions_send skips duplicate A2A delivery for waited parent-owned native subagents", async () => { @@ -1315,17 +1327,16 @@ describe("sessions tools", () => { reply: "child reply", delivery: { status: "skipped", mode: "announce" }, }); - expect(calls.filter((call) => call.method === "agent")).toHaveLength(1); - expect( - calls.some( - (call) => - call.method === "agent" && - typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" && - (call.params as { extraSystemPrompt?: string }).extraSystemPrompt?.includes( - "Agent-to-agent reply step", - ), - ), - ).toBe(false); + expect(countMatching(calls, (call) => call.method === "agent")).toBe(1); + const replyPromptAgentCalls = calls.filter( + (call) => + call.method === "agent" && + typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" && + (call.params as { extraSystemPrompt?: string }).extraSystemPrompt?.includes( + "Agent-to-agent reply step", + ), + ); + expect(replyPromptAgentCalls).toEqual([]); expect(calls.some((call) => call.method === "send")).toBe(false); }); @@ -1450,7 +1461,7 @@ describe("sessions tools", () => { }); await vi.waitFor( () => { - expect(calls.filter((call) => call.method === "send")).toHaveLength(1); + expect(countMatching(calls, (call) => call.method === "send")).toBe(1); }, { timeout: 2_000, interval: 5 }, ); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index a4bf1a2b057..8962a105964 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -52,6 +52,16 @@ vi.mock("./tools/agent-step.js", () => ({ const callGatewayMock = getCallGatewayMock(); const RUN_TIMEOUT_SECONDS = 1; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { return { onAgentSubagentSpawn: (params: unknown) => { @@ -235,7 +245,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { () => ctx.waitCalls.some((call) => call.runId === child.runId) && patchCalls.some((call) => call.label === "my-task") && - ctx.calls.filter((call) => call.method === "agent").length >= 2, + countMatching(ctx.calls, (call) => call.method === "agent") >= 2, ); if (!child.sessionKey) { throw new Error("missing child sessionKey"); @@ -371,7 +381,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { await waitForSessionsSpawnEvent( "lifecycle cleanup", - () => ctx.calls.filter((call) => call.method === "agent").length >= 2 && Boolean(deletedKey), + () => countMatching(ctx.calls, (call) => call.method === "agent") >= 2 && Boolean(deletedKey), ); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); @@ -437,7 +447,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { ); await waitForSessionsSpawnEvent( "main agent cleanup trigger", - () => ctx.calls.filter((call) => call.method === "agent").length >= 2, + () => countMatching(ctx.calls, (call) => call.method === "agent") >= 2, ); await waitForSessionsSpawnEvent("delete cleanup", () => Boolean(deletedKey)); @@ -563,7 +573,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { await waitForSessionsSpawnEvent( "account-aware lifecycle announce", - () => ctx.calls.filter((call) => call.method === "agent").length >= 2, + () => countMatching(ctx.calls, (call) => call.method === "agent") >= 2, ); await waitForRunCleanup(child.sessionKey); diff --git a/src/agents/openclaw-tools.update-plan.test.ts b/src/agents/openclaw-tools.update-plan.test.ts index e6d8c8bddc3..be583d2ba72 100644 --- a/src/agents/openclaw-tools.update-plan.test.ts +++ b/src/agents/openclaw-tools.update-plan.test.ts @@ -11,6 +11,21 @@ function expectUpdatePlanEnabled(params: UpdatePlanGatingParams, expected: boole expect(isUpdatePlanToolEnabledForOpenClawTools(params)).toBe(expected); } +function toolNames(tools: ReturnType): string[] { + return tools.map((tool) => tool.name); +} + +function expectToolNamed( + tools: ReturnType, + name: string, +): ReturnType[number] { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name} to be registered`); + } + return tool; +} + function openAiGpt5Params( config: OpenClawConfig, overrides: Partial = {}, @@ -48,8 +63,8 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(defaultTools.some((tool) => tool.name === "update_plan")).toBe(false); - expect(emptyAllowlistTools.some((tool) => tool.name === "update_plan")).toBe(false); + expect(toolNames(defaultTools)).not.toContain("update_plan"); + expect(toolNames(emptyAllowlistTools)).not.toContain("update_plan"); }); it("wraps constructed tools with before-tool-call hooks by default", () => { @@ -63,13 +78,9 @@ describe("openclaw-tools update_plan gating", () => { wrapBeforeToolCallHook: false, }); + expect(isToolWrappedWithBeforeToolCallHook(expectToolNamed(tools, "sessions_list"))).toBe(true); expect( - isToolWrappedWithBeforeToolCallHook(tools.find((tool) => tool.name === "sessions_list")!), - ).toBe(true); - expect( - isToolWrappedWithBeforeToolCallHook( - unwrappedTools.find((tool) => tool.name === "sessions_list")!, - ), + isToolWrappedWithBeforeToolCallHook(expectToolNamed(unwrappedTools, "sessions_list")), ).toBe(false); }); @@ -95,7 +106,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + expect(toolNames(tools)).toContain("update_plan"); }); it("registers update_plan when a config allowlist group includes it", () => { @@ -106,7 +117,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + expect(toolNames(tools)).toContain("update_plan"); }); it("registers update_plan when a runtime allowlist group includes it", () => { @@ -118,7 +129,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(true); + expect(toolNames(tools)).toContain("update_plan"); }); it("respects deny policy while constructing update_plan for grouped allowlists", () => { @@ -131,7 +142,7 @@ describe("openclaw-tools update_plan gating", () => { modelId: "claude-sonnet-4-6", }); - expect(tools.some((tool) => tool.name === "update_plan")).toBe(false); + expect(toolNames(tools)).not.toContain("update_plan"); }); it("auto-enables update_plan for unconfigured GPT-5 openai runs", () => { diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 798d8d144bf..01f4de4c51b 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -327,7 +327,7 @@ describe("session MCP runtime", () => { }); it("disposes catalog startup in-flight without leaving cached runtimes", async () => { - let notifyCatalogStarted!: () => void; + let notifyCatalogStarted: (() => void) | undefined; const catalogStarted = new Promise((resolve) => { notifyCatalogStarted = resolve; }); @@ -339,6 +339,9 @@ describe("session MCP runtime", () => { workspaceDir: params.workspaceDir, configFingerprint: params.configFingerprint ?? "fingerprint", getCatalog: async () => { + if (!notifyCatalogStarted) { + throw new Error("Expected bundle MCP catalog start callback to be initialized"); + } notifyCatalogStarted(); return await new Promise((_, reject) => { rejectCatalog = reject; diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts index c89e587a263..bdfdbf8cf8b 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -109,8 +109,8 @@ describe("buildApiErrorObservationFields", () => { `{"type":"error","error":{"type":"server_error","message":"${longMessage}"},"request_id":"req_long"}`, ); - expect(observed.rawErrorPreview).toEqual(expect.any(String)); - expect(observed.providerErrorMessagePreview).toEqual(expect.any(String)); + expect(observed.rawErrorPreview).toBeTypeOf("string"); + expect(observed.providerErrorMessagePreview).toBeTypeOf("string"); expect(observed.rawErrorPreview?.length).toBeLessThanOrEqual(401); expect(observed.providerErrorMessagePreview?.length).toBeLessThanOrEqual(201); expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true); diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts index c9e20be1cd7..bc3a65aa842 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts @@ -71,7 +71,8 @@ describe("buildBootstrapContextFiles", () => { warn: (message) => warnings.push(message), }); const kept = result?.content.match(/kept (\d+)\+(\d+) chars/); - expect(kept).not.toBeNull(); + expect(kept?.[1]).toEqual(expect.any(String)); + expect(kept?.[2]).toEqual(expect.any(String)); if (!kept) { throw new Error("missing truncation kept-count marker"); } @@ -219,9 +220,9 @@ describe("buildBootstrapContextFiles", () => { expect(result).toHaveLength(1); expect(result[0]?.path).toBe("/tmp/AGENTS.md"); expect(warnings).toHaveLength(3); - expect(warnings.every((warning) => warning.includes('missing or invalid "path" field'))).toBe( - true, - ); + expect( + warnings.filter((warning) => !warning.includes('missing or invalid "path" field')), + ).toEqual([]); }); }); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 229413a9250..ab8b7473fe9 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -963,10 +963,11 @@ describe("image dimension errors", () => { const raw = '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; const parsed = parseImageDimensionError(raw); - expect(parsed).not.toBeNull(); - expect(parsed?.maxDimensionPx).toBe(2000); - expect(parsed?.messageIndex).toBe(84); - expect(parsed?.contentIndex).toBe(1); + expect(parsed).toMatchObject({ + maxDimensionPx: 2000, + messageIndex: 84, + contentIndex: 1, + }); expect(isImageDimensionErrorMessage(raw)).toBe(true); }); }); diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 6cd1362d42f..18971aa50d5 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -59,8 +59,8 @@ describeLive("pi embedded extra params (live)", () => { } } - expect(stopReason).toEqual(expect.any(String)); - expect(outputTokens).toEqual(expect.any(Number)); + expect(stopReason).toBeTypeOf("string"); + expect(outputTokens).toBeTypeOf("number"); // Should respect maxTokens from config (16) — allow a small buffer for provider rounding. expect(outputTokens ?? 0).toBeLessThanOrEqual(20); }, 30_000); diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts index 3cb2e8c79aa..a4e189d6b49 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts @@ -22,10 +22,13 @@ function toolResult(id: string, text: string): AgentMessage { } function deferred() { - let resolve!: (value: T | PromiseLike) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((r) => { resolve = r; }); + if (!resolve) { + throw new Error("Expected wait-for-idle deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 1884f9e7d94..8bde202c594 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -83,6 +83,7 @@ const installRunEmbeddedMocks = () => { }; let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let authProfileUsageTesting: typeof import("./auth-profiles/usage.js").__testing; let createDiagnosticLogRecordCaptureFn: typeof import("../logging/test-helpers/diagnostic-log-capture.js").createDiagnosticLogRecordCapture; let cleanupLogCapture: (() => void) | undefined; let resetLoggerFn: typeof import("../logging/logger.js").resetLogger; @@ -93,6 +94,7 @@ beforeAll(async () => { vi.resetModules(); installRunEmbeddedMocks(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); + ({ __testing: authProfileUsageTesting } = await import("./auth-profiles/usage.js")); ({ createDiagnosticLogRecordCapture: createDiagnosticLogRecordCaptureFn } = await import("../logging/test-helpers/diagnostic-log-capture.js")); ({ resetLogger: resetLoggerFn, setLoggerOverride: setLoggerOverrideFn } = @@ -128,6 +130,7 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; + authProfileUsageTesting.setDepsForTest(null); cleanupLogCapture?.(); cleanupLogCapture = undefined; setLoggerOverrideFn(null); @@ -905,6 +908,46 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(sleepWithAbortMock).not.toHaveBeenCalled(); }); + it("starts the retry attempt before prompt failure cooldown marking finishes", async () => { + let releaseMark: (() => void) | undefined; + const markCanFinish = new Promise((resolve) => { + releaseMark = resolve; + }); + let markStarted = false; + authProfileUsageTesting.setDepsForTest({ + updateAuthProfileStoreWithLock: async () => { + markStarted = true; + await markCanFinish; + return null; + }, + }); + + try { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPromptErrorThenSuccessfulAttempt("rate limit exceeded"); + + const runPromise = runAutoPinnedOpenAiTurn({ + agentDir, + workspaceDir, + sessionKey: "agent:test:prompt-deferred-mark", + runId: "run:prompt-deferred-mark", + }); + + await vi.waitFor(() => expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2)); + expect(markStarted).toBe(true); + releaseMark?.(); + releaseMark = undefined; + await runPromise; + + const usageStats = await readUsageStats(agentDir); + expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); + }); + } finally { + releaseMark?.(); + } + }); + it("uses configured overload backoff before rotating profiles", async () => { const { usageStats } = await runAutoPinnedRotationCase({ errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', @@ -1507,8 +1550,9 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); it("skips profiles in cooldown when rotating after failure", async () => { - await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { const authPath = path.join(agentDir, "auth-profiles.json"); + const p2CooldownUntil = Date.now() + 60 * 60 * 1000; const payload = { version: 1, profiles: { @@ -1518,7 +1562,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }, usageStats: { "openai:p1": { lastUsed: 1 }, - "openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown + "openai:p2": { cooldownUntil: p2CooldownUntil }, // p2 in cooldown "openai:p3": { lastUsed: 3 }, }, }; @@ -1536,7 +1580,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { const usageStats = await readUsageStats(agentDir); expect(typeof usageStats["openai:p1"]?.lastUsed).toBe("number"); expect(typeof usageStats["openai:p3"]?.lastUsed).toBe("number"); - expect(usageStats["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); + expect(usageStats["openai:p2"]?.cooldownUntil).toBe(p2CooldownUntil); }); }); }); diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 55510fbb78d..2bfca6940ea 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -54,10 +54,13 @@ type Deferred = { }; function createDeferred(): Deferred { - let resolve!: (value: T) => void; + let resolve: ((value: T) => void) | undefined; const promise = new Promise((promiseResolve) => { resolve = promiseResolve; }); + if (!resolve) { + throw new Error("Expected compaction deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts b/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts index b2ffbd0ce83..c385b5bb53f 100644 --- a/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts +++ b/src/agents/pi-embedded-runner/compaction-successor-transcript.test.ts @@ -179,9 +179,11 @@ describe("rotateTranscriptAfterCompaction", () => { expect(entries.find((entry) => entry.id === staleModelId)).toBeUndefined(); expect(entries.find((entry) => entry.id === staleThinkingId)).toBeUndefined(); expect(entries.find((entry) => entry.id === staleSessionInfoId)).toBeUndefined(); - expect(entries.filter((entry) => entry.type === "model_change")).toHaveLength(1); - expect(entries.filter((entry) => entry.type === "thinking_level_change")).toHaveLength(1); - expect(entries.filter((entry) => entry.type === "session_info")).toHaveLength(1); + const countEntryType = (type: (typeof entries)[number]["type"]) => + entries.reduce((count, entry) => count + (entry.type === type ? 1 : 0), 0); + expect(countEntryType("model_change")).toBe(1); + expect(countEntryType("thinking_level_change")).toBe(1); + expect(countEntryType("session_info")).toBe(1); expect(entries.find((entry) => entry.type === "model_change")).toMatchObject({ provider: "openai", modelId: "gpt-5.2", diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index c9849ef2645..4c67541cbd6 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -168,7 +168,7 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { const sessionKey = "agent:main:session-rewrite-handoff"; const sessionLane = resolveSessionLane(sessionKey); const events: string[] = []; - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { events.push("foreground-start"); await new Promise((resolve) => { @@ -201,11 +201,14 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, ], }); - expect(rewritePromise).toEqual(expect.any(Promise)); + expect(rewritePromise?.then).toBeTypeOf("function"); await flushAsyncWork(); expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled(); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await expect(rewritePromise!).resolves.toEqual({ changed: true, @@ -410,7 +413,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-1"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -486,6 +489,9 @@ describe("runContextEngineMaintenance", () => { deliveryStatus: "pending", }); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(1)); expect(maintain.mock.calls[0]?.[0]).toMatchObject({ @@ -541,7 +547,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-2"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -592,6 +598,9 @@ describe("runContextEngineMaintenance", () => { ); expect(queuedTasks).toHaveLength(1); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(2)); const completedTasks = listTasksForOwnerKey(sessionKey).filter( @@ -616,7 +625,7 @@ describe("runContextEngineMaintenance", () => { resetTaskFlowRegistryForTests({ persist: false }); const sessionKey = "agent:main:session-rerun"; - let releaseFirstMaintenance!: () => void; + let releaseFirstMaintenance: (() => void) | undefined; let maintenanceCalls = 0; const maintain = vi.fn(async () => { maintenanceCalls += 1; @@ -665,6 +674,9 @@ describe("runContextEngineMaintenance", () => { reason: "turn", }); + if (!releaseFirstMaintenance) { + throw new Error("Expected first maintenance release callback to be initialized"); + } releaseFirstMaintenance(); await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(2)); @@ -740,7 +752,9 @@ describe("runContextEngineMaintenance", () => { status: "cancelled", notifyPolicy: "silent", }); - expect(tasks.some((task) => task.runId?.startsWith("turn-maint:"))).toBe(true); + expect(tasks.map((task) => task.runId)).toContainEqual( + expect.stringMatching(/^turn-maint:/), + ); } finally { vi.useRealTimers(); } @@ -816,7 +830,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-3"; const sessionLane = resolveSessionLane(sessionKey); const events: string[] = []; - let releaseFirstForeground!: () => void; + let releaseFirstForeground: (() => void) | undefined; const firstForeground = enqueueCommandInLane(sessionLane, async () => { events.push("foreground-1-start"); await new Promise((resolve) => { @@ -863,6 +877,9 @@ describe("runContextEngineMaintenance", () => { events.push("foreground-2-end"); }); + if (!releaseFirstForeground) { + throw new Error("Expected first foreground release callback to be initialized"); + } releaseFirstForeground(); await waitForAssertion(() => expect(events).toEqual([ @@ -893,7 +910,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-rewrite-priority"; const sessionLane = resolveSessionLane(sessionKey); const events: string[] = []; - let allowRewrite!: () => void; + let allowRewrite: (() => void) | undefined; const maintain = vi.fn(async (params?: unknown) => { events.push("maintenance-start"); await new Promise((resolve) => { @@ -963,6 +980,9 @@ describe("runContextEngineMaintenance", () => { events.push("foreground-end"); }); + if (!allowRewrite) { + throw new Error("Expected maintenance rewrite release callback to be initialized"); + } allowRewrite(); await waitForAssertion(() => @@ -1045,7 +1065,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-long"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -1090,6 +1110,9 @@ describe("runContextEngineMaintenance", () => { ), ); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await waitForAssertion(() => expect(peekSystemEvents(sessionKey)).toEqual( @@ -1117,7 +1140,7 @@ describe("runContextEngineMaintenance", () => { const sessionKey = "agent:main:session-throttle"; const sessionLane = resolveSessionLane(sessionKey); - let releaseForeground!: () => void; + let releaseForeground: (() => void) | undefined; const foregroundTurn = enqueueCommandInLane(sessionLane, async () => { await new Promise((resolve) => { releaseForeground = resolve; @@ -1175,6 +1198,9 @@ describe("runContextEngineMaintenance", () => { ), ).toHaveLength(2); + if (!releaseForeground) { + throw new Error("Expected foreground turn release callback to be initialized"); + } releaseForeground(); await foregroundTurn; } finally { diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts index ad56b0ffb06..a9584a3e2d7 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts +++ b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts @@ -126,7 +126,9 @@ describe("applyFinalEffectiveToolPolicy", () => { warn: (message) => warnings.push(message), }); - expect(warnings.some((w) => w.includes("unknown entries"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("unknown entries")]), + ); }); it("still warns on genuinely unknown entries in the bundled pass", () => { @@ -137,7 +139,9 @@ describe("applyFinalEffectiveToolPolicy", () => { warn: (message) => warnings.push(message), }); - expect(warnings.some((w) => w.includes("totally-made-up-tool"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("totally-made-up-tool")]), + ); }); it("keeps bundle MCP tools in the coding profile via plugin metadata", () => { diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index 4c600a29fcd..dba5a2122f8 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -1072,8 +1072,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { }), }); - expect(incompleteTurnText).not.toBeNull(); - expect(incompleteTurnText).toContain("couldn't generate a response"); + expect(incompleteTurnText).toEqual(expect.stringContaining("couldn't generate a response")); }); it("surfaces tool-use terminal with pre-tool text and side effects as replay-unsafe (#76477)", () => { diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d7a825abc36..05e591eca64 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -2003,11 +2003,6 @@ export async function runEmbeddedPiAgent( promptErrorDetails.reason ?? classifyFailoverReason(errorText, { provider }); const promptProfileFailureReason = resolveRunAuthProfileFailureReason(promptFailoverReason); - await maybeMarkAuthProfileFailure({ - profileId: lastProfileId, - reason: promptProfileFailureReason, - modelId, - }); const promptFailoverFailure = promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider }); // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. @@ -2046,6 +2041,15 @@ export async function runEmbeddedPiAgent( promptFailoverDecision.action === "rotate_profile" && (await advanceAuthProfile()) ) { + if (failedPromptProfileId && promptProfileFailureReason) { + maybeMarkAuthProfileFailure({ + profileId: failedPromptProfileId, + reason: promptProfileFailureReason, + modelId, + }).catch((err) => + log.warn(`deferred prompt profile failure mark failed: ${String(err)}`), + ); + } traceAttempts.push({ provider, model: modelId, @@ -2072,6 +2076,15 @@ export async function runEmbeddedPiAgent( profileRotated: true, }); } + if (failedPromptProfileId && promptProfileFailureReason) { + maybeMarkAuthProfileFailure({ + profileId: failedPromptProfileId, + reason: promptProfileFailureReason, + modelId, + }).catch((err) => + log.warn(`deferred prompt profile failure mark failed: ${String(err)}`), + ); + } const fallbackThinking = pickFallbackThinkingLevel({ message: errorText, attempted: attemptedThinking, diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts index 551640c5863..56ff2801d39 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.test.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.test.ts @@ -57,6 +57,69 @@ function expectThrownFailoverError(outcome: Outcome): FailoverError { } describe("handleAssistantFailover", () => { + describe("rotate_profile branch", () => { + it("rotates before waiting on auth profile failure marking", async () => { + const events: string[] = []; + let releaseMark: (() => void) | undefined; + const markFinished = new Promise((resolve) => { + releaseMark = resolve; + }); + const markSettled = new Promise((resolve) => { + void markFinished.then(() => resolve()); + }); + const maybeMarkAuthProfileFailure = vi.fn(async () => { + events.push("mark-start"); + await markFinished; + events.push("mark-finish"); + }); + + const outcome = await handleAssistantFailover( + makeParams({ + initialDecision: { action: "rotate_profile", reason: "rate_limit" }, + failoverReason: "rate_limit", + assistantProfileFailureReason: "rate_limit", + lastProfileId: "openai:p1", + billingFailure: false, + rateLimitFailure: true, + maybeMarkAuthProfileFailure, + advanceAuthProfile: vi.fn(async () => { + events.push("advance"); + return true; + }), + }), + ); + + expect(outcome.action).toBe("retry"); + expect(events).toEqual(["advance", "mark-start"]); + if (!releaseMark) { + throw new Error("Expected auth profile failure mark release callback to be initialized"); + } + releaseMark(); + await markSettled; + await vi.waitFor(() => expect(events).toEqual(["advance", "mark-start", "mark-finish"])); + expect(events).toEqual(["advance", "mark-start", "mark-finish"]); + }); + + it("does not log profile-specific warnings without a failed profile id", async () => { + const warn = vi.fn(); + const outcome = await handleAssistantFailover( + makeParams({ + initialDecision: { action: "rotate_profile", reason: "timeout" }, + failoverReason: "timeout", + timedOut: true, + cloudCodeAssistFormatError: true, + lastProfileId: undefined, + billingFailure: false, + advanceAuthProfile: vi.fn(async () => true), + warn, + }), + ); + + expect(outcome.action).toBe("retry"); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("Profile undefined")); + }); + }); + describe("surface_error branch (openclaw#70124)", () => { it("throws a billing FailoverError so the webchat can render the provider failure", async () => { const logDecision = vi.fn(); diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.ts b/src/agents/pi-embedded-runner/run/assistant-failover.ts index a200a86e434..752e24ec1c3 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.ts @@ -97,22 +97,20 @@ export async function handleAssistantFailover(params: { }; if (decision.action === "rotate_profile") { - if (params.lastProfileId) { - const reason = params.timedOut ? "timeout" : params.assistantProfileFailureReason; - await params.maybeMarkAuthProfileFailure({ - profileId: params.lastProfileId, - reason, - modelId: params.modelId, - }); - if (params.timedOut && !params.isProbeSession) { - params.warn(`Profile ${params.lastProfileId} timed out. Trying next account...`); + const failedProfileId = params.lastProfileId; + const failureReason = params.timedOut ? "timeout" : params.assistantProfileFailureReason; + const markFailedProfile = () => { + if (!failedProfileId || !failureReason || failureReason === "timeout") { + return; } - if (params.cloudCodeAssistFormatError) { - params.warn( - `Profile ${params.lastProfileId} hit Cloud Code Assist format error. Tool calls will be sanitized on retry.`, - ); - } - } + params + .maybeMarkAuthProfileFailure({ + profileId: failedProfileId, + reason: failureReason, + modelId: params.modelId, + }) + .catch((err) => params.warn(`deferred profile failure mark failed: ${String(err)}`)); + }; if (params.failoverReason === "overloaded") { overloadProfileRotations += 1; @@ -124,6 +122,7 @@ export async function handleAssistantFailover(params: { params.warn( `overload profile rotation cap reached for ${sanitizeForLog(params.provider)}/${sanitizeForLog(params.modelId)} after ${overloadProfileRotations} rotations; escalating to model fallback`, ); + markFailedProfile(); params.logAssistantFailoverDecision("fallback_model", { status }); return { action: "throw", @@ -152,6 +151,15 @@ export async function handleAssistantFailover(params: { } const rotated = await params.advanceAuthProfile(); + markFailedProfile(); + if (params.timedOut && !params.isProbeSession && failedProfileId) { + params.warn(`Profile ${failedProfileId} timed out. Trying next account...`); + } + if (params.cloudCodeAssistFormatError && failedProfileId) { + params.warn( + `Profile ${failedProfileId} hit Cloud Code Assist format error. Tool calls will be sanitized on retry.`, + ); + } if (rotated) { params.logAssistantFailoverDecision("rotate_profile"); await params.maybeBackoffBeforeOverloadFailover(params.failoverReason); diff --git a/src/agents/pi-embedded-runner/run/auth-controller.test.ts b/src/agents/pi-embedded-runner/run/auth-controller.test.ts index ad3e1db8cd0..e59a0c28034 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.test.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.test.ts @@ -29,12 +29,15 @@ vi.mock("../../model-auth.js", async () => { import { createEmbeddedRunAuthController } from "./auth-controller.js"; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected auth controller deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts index 820e0c482c9..e9442e786da 100644 --- a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts +++ b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts @@ -23,15 +23,23 @@ function expectPrunedImageMessage( messages: AgentMessage[], errorMessage: string, ): Array<{ type: string; text?: string; data?: string }> { - const pruned = pruneProcessedHistoryImages(messages); - expect(pruned).not.toBeNull(); - expect(pruned).not.toBe(messages); - const content = expectArrayMessageContent(pruned?.[0], errorMessage); + const pruned = expectPrunedMessages(messages); + const content = expectArrayMessageContent(pruned[0], errorMessage); expect(content).toHaveLength(2); expect(content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); return content; } +function expectPrunedMessages(messages: AgentMessage[]): AgentMessage[] { + const pruned = pruneProcessedHistoryImages(messages); + expect(pruned).toEqual(expect.any(Array)); + if (!pruned) { + throw new Error("expected pruned history messages"); + } + expect(pruned).not.toBe(messages); + return pruned; +} + function expectImageMessagePreserved(messages: AgentMessage[], errorMessage: string) { const pruned = pruneProcessedHistoryImages(messages); @@ -98,10 +106,9 @@ describe("pruneProcessedHistoryImages", () => { ...oldEnoughTail(), ]; - const pruned = pruneProcessedHistoryImages(messages); + const pruned = expectPrunedMessages(messages); - expect(pruned).not.toBeNull(); - const content = expectArrayMessageContent(pruned?.[0], "expected user array content"); + const content = expectArrayMessageContent(pruned[0], "expected user array content"); expect(content[0]?.text).toBe( [ "old image", @@ -128,10 +135,9 @@ describe("pruneProcessedHistoryImages", () => { ...oldEnoughTail(), ]; - const pruned = pruneProcessedHistoryImages(messages); + const pruned = expectPrunedMessages(messages); - expect(pruned).not.toBeNull(); - const firstUser = pruned?.[0] as Extract | undefined; + const firstUser = pruned[0] as Extract | undefined; expect(firstUser?.content).toBe(`please remember ${PRUNED_HISTORY_MEDIA_REFERENCE_MARKER}`); const originalUser = messages[0] as Extract | undefined; expect(originalUser?.content).toBe( @@ -149,10 +155,9 @@ describe("pruneProcessedHistoryImages", () => { ...oldEnoughTail(), ]; - const pruned = pruneProcessedHistoryImages(messages); + const pruned = expectPrunedMessages(messages); - expect(pruned).not.toBeNull(); - const toolResult = pruned?.[0] as Extract | undefined; + const toolResult = pruned[0] as Extract | undefined; expect(toolResult?.content).toBe(`previous ${PRUNED_HISTORY_MEDIA_REFERENCE_MARKER} result`); const originalToolResult = messages[0] as | Extract @@ -284,13 +289,12 @@ describe("pruneProcessedHistoryImages", () => { assistantTurn(), ]; - const pruned = pruneProcessedHistoryImages(messages); - expect(pruned).not.toBeNull(); + const pruned = expectPrunedMessages(messages); - const oldContent = expectArrayMessageContent(pruned?.[0], "expected old user content"); + const oldContent = expectArrayMessageContent(pruned[0], "expected old user content"); expect(oldContent[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); - const recentContent = expectArrayMessageContent(pruned?.[6], "expected recent user content"); + const recentContent = expectArrayMessageContent(pruned[6], "expected recent user content"); expect(recentContent[1]).toMatchObject({ type: "image", data: "abc" }); const originalOldContent = expectArrayMessageContent( diff --git a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts b/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts index d168c3c481b..6034ffdd033 100644 --- a/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts +++ b/src/agents/pi-embedded-runner/run/idle-timeout-breaker.test.ts @@ -79,7 +79,7 @@ describe("stepIdleTimeoutBreaker (#76293)", () => { ], { cap: 0 }, ); - expect(steps.every((s) => !s.tripped)).toBe(true); + expect(steps.some((step) => step.tripped)).toBe(false); expect(steps.at(-1)?.consecutive).toBe(7); }); @@ -94,7 +94,7 @@ describe("stepIdleTimeoutBreaker (#76293)", () => { outputTokens: 220, })), ); - expect(steps.every((s) => !s.tripped)).toBe(true); + expect(steps.some((step) => step.tripped)).toBe(false); expect(steps.at(-1)?.consecutive).toBe(0); }); diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index 0d81b9dc42e..caefbb883f0 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -80,7 +80,7 @@ describe("detectImageReferences", () => { 1, ); - expect(refs.some((r) => r.type === "path")).toBe(true); + expect(refs.map((ref) => ref.type)).toContain("path"); }); it("does not leak parser state between calls", () => { @@ -267,8 +267,7 @@ describe("loadImageFromRef", () => { }, ); - expect(image).not.toBeNull(); - expect(image?.type).toBe("image"); + expect(image).toMatchObject({ type: "image" }); expect(image?.data.length).toBeGreaterThan(0); } finally { await fs.rm(sandboxParent, { recursive: true, force: true }); diff --git a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts index 896ccc30c81..98487561ced 100644 --- a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts @@ -77,7 +77,7 @@ describe("buildEmbeddedRunPayloads", () => { expectOverloadedFallback(payloads); expect(payloads[0]?.isError).toBe(true); - expect(payloads.some((payload) => payload.text === errorJson)).toBe(false); + expect(payloads.map((payload) => payload.text)).not.toContain(errorJson); }); it("suppresses mutating tool warnings when an assistant error reply already covers the turn", () => { @@ -90,8 +90,12 @@ describe("buildEmbeddedRunPayloads", () => { expectOverloadedFallback(payloads); expect(payloads[0]?.isError).toBe(true); - expect(payloads.some((payload) => payload.text?.includes("Edit"))).toBe(false); - expect(payloads.some((payload) => payload.text?.includes("missing"))).toBe(false); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("Edit")]), + ); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("missing")]), + ); }); it("keeps mutating tool warnings when assistant error artifacts are not user-facing", () => { @@ -118,7 +122,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectOverloadedFallback(payloads); - expect(payloads.some((payload) => payload.text === errorJsonPretty)).toBe(false); + expect(payloads.map((payload) => payload.text)).not.toContain(errorJsonPretty); }); it("suppresses raw error JSON from fallback assistant text", () => { @@ -127,7 +131,9 @@ describe("buildEmbeddedRunPayloads", () => { }); expectOverloadedFallback(payloads); - expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("request_id")]), + ); }); it("surfaces OpenAI model capacity errors instead of generic empty-response copy", () => { @@ -181,7 +187,9 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads).toHaveLength(1); expect(payloads[0]?.isError).toBe(true); - expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false); + expect(payloads.map((payload) => payload.text ?? "")).not.toEqual( + expect.arrayContaining([expect.stringContaining("request_id")]), + ); }); it("does not suppress error-shaped JSON when the assistant did not error", () => { diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 2499d83b9cd..ad7e014ebef 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -27,7 +27,6 @@ import type { ToolResultFormat } from "../../pi-embedded-subscribe.shared-types. import { extractAssistantThinking, extractAssistantVisibleText, - formatReasoningMessage, } from "../../pi-embedded-utils.js"; import { isExecLikeToolName, type ToolErrorSummary } from "../../tool-error-summary.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; @@ -283,7 +282,7 @@ export function buildEmbeddedRunPayloads(params: { const reasoningText = suppressAssistantArtifacts ? "" : params.lastAssistant && params.reasoningLevel === "on" && params.thinkingLevel !== "off" - ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) + ? extractAssistantThinking(params.lastAssistant) : ""; if (reasoningText) { replyItems.push({ text: reasoningText, isReasoning: true }); diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts index 90005a9a5f5..2bb36abcf77 100644 --- a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts +++ b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts @@ -215,7 +215,7 @@ describe("rewriteTranscriptEntriesInSessionManager", () => { "rewritten summary entry", ); expect(sessionManager.getLabel(rewrittenSummaryEntry.id)).toBe("bookmark"); - expect(sessionManager.getBranch().some((entry) => entry.type === "label")).toBe(true); + expect(sessionManager.getBranch().map((entry) => entry.type)).toContain("label"); }); it("remaps compaction keep markers when rewritten entries change ids", () => { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index a1c56e085ef..625e735bc15 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -34,7 +34,6 @@ import { extractAssistantVisibleText, extractThinkingFromTaggedStream, extractThinkingFromTaggedText, - formatReasoningMessage, promoteThinkingTagsToBlocks, } from "./pi-embedded-utils.js"; @@ -692,7 +691,7 @@ export function handleMessageEnd( ctx.state.includeReasoning || ctx.state.streamReasoning ? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText) : ""; - const formattedReasoning = rawThinking ? formatReasoningMessage(rawThinking) : ""; + const trimmedReasoning = rawThinking ? rawThinking.trim() : ""; const trimmedText = text.trim(); const parsedText = trimmedText ? parseReplyDirectives(splitTrailingDirective(trimmedText, { final: true }).text) @@ -770,18 +769,18 @@ export function handleMessageEnd( !ctx.params.silentExpected && !suppressDeterministicApprovalOutput && ctx.state.includeReasoning && - formattedReasoning && + trimmedReasoning && onBlockReply && - formattedReasoning !== ctx.state.lastReasoningSent, + trimmedReasoning !== ctx.state.lastReasoningSent, ); const shouldEmitReasoningBeforeAnswer = shouldEmitReasoning && ctx.state.blockReplyBreak === "message_end" && !addedDuringMessage; const maybeEmitReasoning = () => { - if (!shouldEmitReasoning || !formattedReasoning) { + if (!shouldEmitReasoning || !trimmedReasoning) { return; } - ctx.state.lastReasoningSent = formattedReasoning; - ctx.emitBlockReply({ text: formattedReasoning, isReasoning: true }); + ctx.state.lastReasoningSent = trimmedReasoning; + ctx.emitBlockReply({ text: trimmedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index a434dbce12e..a795f02c168 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -120,6 +120,7 @@ function buildToolCallSummary(toolName: string, args: unknown, meta?: string): T meta, mutatingAction: mutation.mutatingAction, actionFingerprint: mutation.actionFingerprint, + fileTarget: mutation.fileTarget, }; } @@ -914,6 +915,7 @@ export async function handleToolExecutionEnd( timedOut: isToolResultTimedOut(sanitizedResult) || undefined, mutatingAction: callSummary?.mutatingAction, actionFingerprint: callSummary?.actionFingerprint, + fileTarget: callSummary?.fileTarget, }; } else if (ctx.state.lastToolError) { // Keep unresolved mutating failures until the same action succeeds. @@ -923,6 +925,7 @@ export async function handleToolExecutionEnd( toolName, meta, actionFingerprint: callSummary?.actionFingerprint, + fileTarget: callSummary?.fileTarget, }) ) { ctx.state.lastToolError = undefined; diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 4556dcdadf6..3b62e97712c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -25,6 +25,7 @@ export type ToolCallSummary = { meta?: string; mutatingAction: boolean; actionFingerprint?: string; + fileTarget?: import("./tool-mutation.js").FileTarget; }; export type EmbeddedPiSubscribeState = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts index 1a909ae2746..ee762a46be4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts @@ -27,14 +27,14 @@ describe("subscribeEmbeddedPiSession", () => { blockReplyBreak: "text_end", }); - // This should not throw even without onBlockReplyFlush - expect(() => { + // Missing onBlockReplyFlush should still accept streaming events. + expect( handler?.({ type: "tool_execution_start", toolName: "bash", toolCallId: "tool-no-flush", args: { command: "echo test" }, - }); - }).not.toThrow(); + }), + ).toBeUndefined(); }); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts index 3bceb2dd171..82a6e375b8e 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts @@ -26,7 +26,7 @@ describe("subscribeEmbeddedPiSession", () => { function expectReasoningAndAnswerCalls(onBlockReply: ReturnType) { expect(onBlockReply).toHaveBeenCalledTimes(2); - expect(onBlockReply.mock.calls[0][0].text).toBe("Reasoning:\n_Because it helps_"); + expect(onBlockReply.mock.calls[0][0].text).toBe("Because it helps"); expect(onBlockReply.mock.calls[1][0].text).toBe("Final answer"); } diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 7067d704d75..26c4e845a7d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -1,5 +1,6 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import * as agentEvents from "../infra/agent-events.js"; import { THINKING_TAG_CASES, createSubscribedSessionHarness, @@ -216,7 +217,7 @@ describe("subscribeEmbeddedPiSession", () => { const streamTexts = onReasoningStream.mock.calls .map((call) => call[0]?.text) .filter((value): value is string => typeof value === "string"); - expect(streamTexts.at(-1)).toBe("Reasoning:\n_Because it helps_"); + expect(streamTexts.at(-1)).toBe("Because it helps"); expect(assistantMessage.content).toEqual([ { type: "thinking", thinking: "Because it helps" }, @@ -469,7 +470,10 @@ describe("subscribeEmbeddedPiSession", () => { text: "Generated 1 image.", }), ); - expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + const earlyMediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.length); + expect(earlyMediaPayloads).toEqual([]); emitAssistantTextDelta(emit, "MEDIA:/tmp/generated.png"); emit({ @@ -611,7 +615,10 @@ describe("subscribeEmbeddedPiSession", () => { text: firstChunk.trim(), }), ); - expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + const earlyMediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.length); + expect(earlyMediaPayloads).toEqual([]); emitAssistantTextDelta(emit, `MEDIA:${mediaUrl}`); emit({ @@ -751,10 +758,52 @@ describe("subscribeEmbeddedPiSession", () => { const streamTexts = onReasoningStream.mock.calls .map((call) => call[0]?.text) .filter((value): value is string => typeof value === "string"); - expect(streamTexts.at(-1)).toBe("Reasoning:\n_Checking files done_"); + expect(streamTexts.at(-1)).toBe("Checking files done"); expect(onReasoningEnd).toHaveBeenCalledTimes(1); }); + it("extracts correct reasoning delta for incremental stream updates", () => { + const emitAgentEventSpy = vi.spyOn(agentEvents, "emitAgentEvent").mockImplementation(() => {}); + const { emit } = createSubscribedHarness({ + runId: "run", + reasoningMode: "stream", + onReasoningStream: vi.fn(), + }); + + emit({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Step 1" }], + }, + assistantMessageEvent: { + type: "thinking_delta", + delta: "Step 1", + }, + }); + + emit({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Step 1 and Step 2" }], + }, + assistantMessageEvent: { + type: "thinking_delta", + delta: " and Step 2", + }, + }); + + const thinkingEvents = emitAgentEventSpy.mock.calls + .map((call) => call[0]) + .filter((evt) => evt?.stream === "thinking"); + + expect(thinkingEvents.length).toBe(2); + expect(thinkingEvents[0]?.data?.delta).toBe("Step 1"); + expect(thinkingEvents[1]?.data?.delta).toBe(" and Step 2"); + emitAgentEventSpy.mockRestore(); + }); + it("emits reasoning end once when native and tagged reasoning end overlap", () => { const onReasoningEnd = vi.fn(); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index b4431a0b6ab..51aaad9dc71 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -36,7 +36,6 @@ import { isPromiseLike } from "./pi-embedded-subscribe.promise.js"; import { filterToolResultMediaUrls } from "./pi-embedded-subscribe.tools.js"; import type { SubscribeEmbeddedPiSessionParams } from "./pi-embedded-subscribe.types.js"; import { - formatReasoningMessage, stripDowngradedToolCallText, THINKING_TAG_SCAN_RE, } from "./pi-embedded-utils.js"; @@ -831,31 +830,31 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar if (!state.streamReasoning || !params.onReasoningStream) { return; } - const formatted = formatReasoningMessage(text); - if (!formatted) { + const trimmed = text.trim(); + if (!trimmed) { return; } - if (formatted === state.lastStreamedReasoning) { + if (trimmed === state.lastStreamedReasoning) { return; } // Compute delta: new text since the last emitted reasoning. - // Guard against non-prefix changes (e.g. trim/format altering earlier content). + // Guard against non-prefix changes (e.g. trim altering earlier content). const prior = state.lastStreamedReasoning ?? ""; - const delta = formatted.startsWith(prior) ? formatted.slice(prior.length) : formatted; - state.lastStreamedReasoning = formatted; + const delta = trimmed.startsWith(prior) ? trimmed.slice(prior.length) : trimmed; + state.lastStreamedReasoning = trimmed; // Broadcast thinking event to WebSocket clients in real-time emitAgentEvent({ runId: params.runId, stream: "thinking", data: { - text: formatted, + text: trimmed, delta, }, }); void params.onReasoningStream({ - text: formatted, + text: trimmed, }); }; diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index d47669a02f1..fa1e0c65298 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -113,8 +113,9 @@ const createCompactionHandler = () => { }), } as unknown as ExtensionAPI; compactionSafeguardExtension(mockApi); + expect(compactionHandler).toBeDefined(); if (!compactionHandler) { - throw new Error("expected compaction safeguard handler"); + throw new Error("Expected compaction safeguard to register a handler."); } return compactionHandler; }; @@ -511,7 +512,7 @@ describe("compaction-safeguard runtime registry", () => { it("clears entry when value is null", () => { const sm = {}; setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.7 }); - expect(getCompactionSafeguardRuntime(sm)).not.toBeNull(); + expect(getCompactionSafeguardRuntime(sm)).toEqual({ maxHistoryShare: 0.7 }); setCompactionSafeguardRuntime(sm, null); expect(getCompactionSafeguardRuntime(sm)).toBeNull(); }); @@ -899,7 +900,9 @@ describe("compaction-safeguard recent-turn preservation", () => { const identifiers = extractOpaqueIdentifiers( "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and again a1b2c3d4e5f6", ); - expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); // pragma: allowlist secret + expect( + identifiers.reduce((count, id) => count + (id === "A1B2C3D4E5F6" ? 1 : 0), 0), // pragma: allowlist secret + ).toBe(1); }); it("dedupes identifiers before applying the result cap", () => { diff --git a/src/agents/pi-hooks/context-pruning.test.ts b/src/agents/pi-hooks/context-pruning.test.ts index a2da89bb330..e45d15d2644 100644 --- a/src/agents/pi-hooks/context-pruning.test.ts +++ b/src/agents/pi-hooks/context-pruning.test.ts @@ -405,7 +405,7 @@ describe("context-pruning", () => { }); const tool = findToolResult(next, "t1"); - expect(tool.content.filter((block) => block.type === "image")).toEqual([]); + expect(tool.content.some((block) => block.type === "image")).toBe(false); expect(toolText(tool)).toContain("[image removed during context pruning]"); expect(toolText(tool)).toContain("visible tool text"); }); diff --git a/src/agents/pi-hooks/context-pruning/pruner.test.ts b/src/agents/pi-hooks/context-pruning/pruner.test.ts index 0b334339650..64751364bf5 100644 --- a/src/agents/pi-hooks/context-pruning/pruner.test.ts +++ b/src/agents/pi-hooks/context-pruning/pruner.test.ts @@ -104,7 +104,7 @@ function expectToolResultWasTrimmed(result: AgentMessage[]) { } describe("pruneContextMessages", () => { - it("does not crash on assistant message with malformed thinking block (missing thinking string)", () => { + it("keeps assistant messages with malformed thinking blocks", () => { const messages: AgentMessage[] = [ makeUser("hello"), makeAssistant([ @@ -112,30 +112,30 @@ describe("pruneContextMessages", () => { { type: "text", text: "ok" }, ]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(2); }); - it("does not crash on assistant message with null content entries", () => { + it("keeps assistant messages with null content entries", () => { const messages: AgentMessage[] = [ makeUser("hello"), makeAssistant([null as unknown as AssistantContentBlock, { type: "text", text: "world" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(2); }); - it("does not crash on assistant message with malformed text block (missing text string)", () => { + it("keeps assistant messages with malformed text blocks", () => { const messages: AgentMessage[] = [ makeUser("hello"), makeAssistant([ @@ -143,16 +143,16 @@ describe("pruneContextMessages", () => { { type: "thinking", thinking: "still fine" }, ]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(2); }); - it("does not crash on toolResult with malformed text block (missing text string)", () => { + it("keeps tool results with malformed text blocks", () => { // Regression: a plugin returning undefined produces {type: "text"} with no text property, // which crashed estimateTextAndImageChars / collectTextSegments / collectPrunableToolResultSegments. // See https://github.com/openclaw/openclaw/issues/34979 @@ -174,16 +174,16 @@ describe("pruneContextMessages", () => { makeAssistant([{ type: "text", text: "done" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(5); }); - it("does not crash on toolResult with malformed text block during soft-trim (image path)", () => { + it("keeps tool results with malformed text blocks during soft-trim image paths", () => { // The collectPrunableToolResultSegments path is exercised when the tool result // contains image blocks alongside a malformed text block. const malformedToolResult = { @@ -199,28 +199,28 @@ describe("pruneContextMessages", () => { makeAssistant([{ type: "text", text: "here it is" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: { - ...DEFAULT_CONTEXT_PRUNING_SETTINGS, - keepLastAssistants: 1, - softTrimRatio: 0, - hardClear: { - ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, - enabled: false, - }, - softTrim: { - maxChars: 5_000, - headChars: 2_000, - tailChars: 2_000, - }, + const result = pruneContextMessages({ + messages, + settings: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS, + keepLastAssistants: 1, + softTrimRatio: 0, + hardClear: { + ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear, + enabled: false, }, - ctx: CONTEXT_WINDOW_1M, - isToolPrunable: () => true, - contextWindowTokensOverride: 1, - }), - ).not.toThrow(); + softTrim: { + maxChars: 5_000, + headChars: 2_000, + tailChars: 2_000, + }, + }, + ctx: CONTEXT_WINDOW_1M, + isToolPrunable: () => true, + contextWindowTokensOverride: 1, + }); + + expect(result).toHaveLength(3); }); it("counts malformed non-string text blocks when deciding to trim tool results", () => { @@ -264,7 +264,7 @@ describe("pruneContextMessages", () => { expect(textBlock.text).toContain("[Tool result trimmed:"); }); - it("does not crash on toolResult with null content entries", () => { + it("keeps tool results with null content entries", () => { const malformedToolResult = { role: "toolResult", toolName: "read", @@ -278,13 +278,13 @@ describe("pruneContextMessages", () => { makeAssistant([{ type: "text", text: "done" }]), ]; - expect(() => - pruneContextMessages({ - messages, - settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, - ctx: CONTEXT_WINDOW_1M, - }), - ).not.toThrow(); + const result = pruneContextMessages({ + messages, + settings: DEFAULT_CONTEXT_PRUNING_SETTINGS, + ctx: CONTEXT_WINDOW_1M, + }); + + expect(result).toHaveLength(3); }); it("handles well-formed thinking blocks correctly", () => { diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 63a4aad8fd2..6ce5f87a3c6 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -941,7 +941,7 @@ describe("before_tool_call requireApproval handling", () => { }); expect(result.blocked).toBe(false); - expect(removeListenerSpy.mock.calls.some(([type]) => type === "abort")).toBe(true); + expect(removeListenerSpy.mock.calls.map(([type]) => type)).toContain("abort"); }); it("calls onResolution with allow-once on approval", async () => { diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index 398e1fc9352..ad446753f3a 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -13,37 +13,13 @@ import { patchPluginSessionExtension } from "../plugins/host-hook-state.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginHookRegistration } from "../plugins/types.js"; - -type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js"); -type PiToolsAbortModule = typeof import("./pi-tools.abort.js"); -type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js"); - -type ToClientToolDefinitions = ToolDefinitionAdapterModule["toClientToolDefinitions"]; -type ToToolDefinitions = ToolDefinitionAdapterModule["toToolDefinitions"]; -type WrapToolWithAbortSignal = PiToolsAbortModule["wrapToolWithAbortSignal"]; -type BeforeToolCallTesting = BeforeToolCallModule["__testing"]; -type ConsumeAdjustedParamsForToolCall = BeforeToolCallModule["consumeAdjustedParamsForToolCall"]; -type WrapToolWithBeforeToolCallHook = BeforeToolCallModule["wrapToolWithBeforeToolCallHook"]; - -let toClientToolDefinitions!: ToClientToolDefinitions; -let toToolDefinitions!: ToToolDefinitions; -let wrapToolWithAbortSignal!: WrapToolWithAbortSignal; -let beforeToolCallTesting!: BeforeToolCallTesting; -let consumeAdjustedParamsForToolCall!: ConsumeAdjustedParamsForToolCall; -let wrapToolWithBeforeToolCallHook!: WrapToolWithBeforeToolCallHook; - -beforeEach(async () => { - if (!wrapToolWithBeforeToolCallHook) { - ({ toClientToolDefinitions, toToolDefinitions } = - await import("./pi-tool-definition-adapter.js")); - ({ wrapToolWithAbortSignal } = await import("./pi-tools.abort.js")); - ({ - __testing: beforeToolCallTesting, - consumeAdjustedParamsForToolCall, - wrapToolWithBeforeToolCallHook, - } = await import("./pi-tools.before-tool-call.js")); - } -}); +import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js"; +import { wrapToolWithAbortSignal } from "./pi-tools.abort.js"; +import { + __testing as beforeToolCallTesting, + consumeAdjustedParamsForToolCall, + wrapToolWithBeforeToolCallHook, +} from "./pi-tools.before-tool-call.js"; type BeforeToolCallHandlerMock = ReturnType; @@ -53,6 +29,20 @@ type BeforeToolCallHookInstall = { handler: BeforeToolCallHandlerMock; }; +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + function installBeforeToolCallHook(params?: { enabled?: boolean; runBeforeToolCallImpl?: (...args: unknown[]) => unknown; @@ -359,7 +349,7 @@ describe("before_tool_call hook integration for client tools", () => { }); it("preserves client tool source order when hooks resolve out of order", async () => { - let releaseFirstHook!: () => void; + let releaseFirstHook: (() => void) | undefined; const firstHookGate = new Promise((resolve) => { releaseFirstHook = resolve; }); @@ -445,13 +435,19 @@ describe("before_tool_call hook integration for client tools", () => { { name: "second_tool", completed: true }, ]); + if (!releaseFirstHook) { + throw new Error("Expected first before-tool-call hook release callback to be initialized"); + } releaseFirstHook(); await firstRun; - expect(slots.filter((slot) => slot.completed).map((slot) => slot.name)).toEqual([ - "first_tool", - "second_tool", - ]); + expect( + collectMatching( + slots, + (slot) => slot.completed, + (slot) => slot.name, + ), + ).toEqual(["first_tool", "second_tool"]); expect(slots.map((slot) => slot.params)).toEqual([ { value: "first", marker: "first_tool" }, { value: "second", marker: "second_tool" }, diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index abfc97f2e1d..5896a697a87 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -94,6 +94,10 @@ function applyRuntimeToolsAllow(tools: T[], toolsAll type OpenClawCodingTool = ReturnType[number]; +function toolNameList(tools: readonly { name: string }[]): string[] { + return tools.map((tool) => tool.name); +} + function requireTool(tools: OpenClawCodingTool[], name: string): OpenClawCodingTool { const tool = tools.find((candidate) => candidate.name === name); if (!tool) { @@ -354,23 +358,23 @@ describe("createOpenClawCodingTools", () => { it("enforces apply_patch availability and canonical names across model/provider constraints", () => { const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true }); - expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true); - expect(defaultTools.some((tool) => tool.name === "process")).toBe(true); - expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(defaultTools)).toContain("exec"); + expect(toolNameList(defaultTools)).toContain("process"); + expect(toolNameList(defaultTools)).not.toContain("apply_patch"); const openAiTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "openai", modelId: "gpt-5.4", }); - expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true); + expect(toolNameList(openAiTools)).toContain("apply_patch"); const codexTools = createOpenClawCodingTools({ config: testConfig, modelProvider: "openai-codex", modelId: "gpt-5.4", }); - expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true); + expect(toolNameList(codexTools)).toContain("apply_patch"); const disabledConfig: OpenClawConfig = { tools: { @@ -384,14 +388,14 @@ describe("createOpenClawCodingTools", () => { modelProvider: "openai", modelId: "gpt-5.4", }); - expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(disabledOpenAiTools)).not.toContain("apply_patch"); const anthropicTools = createOpenClawCodingTools({ config: disabledConfig, modelProvider: "anthropic", modelId: "claude-opus-4-6", }); - expect(anthropicTools.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(anthropicTools)).not.toContain("apply_patch"); const allowModelsConfig: OpenClawConfig = { tools: { @@ -405,14 +409,14 @@ describe("createOpenClawCodingTools", () => { modelProvider: "openai", modelId: "gpt-5.4", }); - expect(allowed.some((tool) => tool.name === "apply_patch")).toBe(true); + expect(toolNameList(allowed)).toContain("apply_patch"); const denied = createOpenClawCodingTools({ config: allowModelsConfig, modelProvider: "openai", modelId: "gpt-5.4-mini", }); - expect(denied.some((tool) => tool.name === "apply_patch")).toBe(false); + expect(toolNameList(denied)).not.toContain("apply_patch"); const oauthTools = createOpenClawCodingTools({ config: testConfig, @@ -666,7 +670,7 @@ describe("createOpenClawCodingTools", () => { }, } as OpenClawConfig, }); - expect(subagentAllowOnly.some((tool) => tool.name === "browser")).toBe(false); + expect(toolNameList(subagentAllowOnly)).not.toContain("browser"); const profileStageAlsoAllow = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", @@ -675,20 +679,20 @@ describe("createOpenClawCodingTools", () => { tools: { profile: "coding", alsoAllow: ["browser"] }, } as OpenClawConfig, }); - expect(profileStageAlsoAllow.some((tool) => tool.name === "browser")).toBe(true); + expect(toolNameList(profileStageAlsoAllow)).toContain("browser"); }); it("can keep message available when a cron route needs it under the coding profile", () => { const codingTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, }); - expect(codingTools.some((tool) => tool.name === "message")).toBe(false); + expect(toolNameList(codingTools)).not.toContain("message"); const cronTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, forceMessageTool: true, }); - expect(cronTools.some((tool) => tool.name === "message")).toBe(true); + expect(toolNameList(cronTools)).toContain("message"); }); it("keeps heartbeat response available for heartbeat runs under the coding profile", () => { @@ -699,7 +703,7 @@ describe("createOpenClawCodingTools", () => { forceHeartbeatTool: true, }); - expect(codingTools.some((tool) => tool.name === "heartbeat_respond")).toBe(true); + expect(toolNameList(codingTools)).toContain("heartbeat_respond"); }); it("enables heartbeat response when visible replies are message-tool-only", () => { @@ -711,7 +715,7 @@ describe("createOpenClawCodingTools", () => { trigger: "heartbeat", }); - expect(tools.some((tool) => tool.name === "heartbeat_respond")).toBe(true); + expect(toolNameList(tools)).toContain("heartbeat_respond"); }); it("can keep message available when a cron route needs it under a provider coding profile", () => { @@ -720,7 +724,7 @@ describe("createOpenClawCodingTools", () => { modelProvider: "openai", modelId: "gpt-5.4", }); - expect(providerProfileTools.some((tool) => tool.name === "message")).toBe(false); + expect(toolNameList(providerProfileTools)).not.toContain("message"); const cronTools = createOpenClawCodingTools({ config: { tools: { byProvider: { openai: { profile: "coding" } } } }, @@ -728,7 +732,7 @@ describe("createOpenClawCodingTools", () => { modelId: "gpt-5.4", forceMessageTool: true, }); - expect(cronTools.some((tool) => tool.name === "message")).toBe(true); + expect(toolNameList(cronTools)).toContain("message"); }); it.each(providerAliasCases)( @@ -812,7 +816,7 @@ describe("createOpenClawCodingTools", () => { senderIsOwner: true, }); - expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false); + expect(toolNameList(xaiTools)).not.toContain("web_search"); for (const tool of xaiTools) { const violations = findUnsupportedSchemaKeywords( tool.parameters, @@ -889,9 +893,9 @@ describe("createOpenClawCodingTools", () => { }, }); const tools = createOpenClawCodingTools({ sandbox }); - expect(tools.some((tool) => tool.name === "exec")).toBe(true); - expect(tools.some((tool) => tool.name === "read")).toBe(false); - expect(tools.some((tool) => tool.name === "browser")).toBe(false); + expect(toolNameList(tools)).toContain("exec"); + expect(toolNameList(tools)).not.toContain("read"); + expect(toolNameList(tools)).not.toContain("browser"); }); it("hard-disables write/edit when sandbox workspaceAccess is ro", () => { @@ -907,9 +911,9 @@ describe("createOpenClawCodingTools", () => { }, }); const tools = createOpenClawCodingTools({ sandbox }); - expect(tools.some((tool) => tool.name === "read")).toBe(true); - expect(tools.some((tool) => tool.name === "write")).toBe(false); - expect(tools.some((tool) => tool.name === "edit")).toBe(false); + expect(toolNameList(tools)).toContain("read"); + expect(toolNameList(tools)).not.toContain("write"); + expect(toolNameList(tools)).not.toContain("edit"); }); it("accepts canonical parameters for read/write/edit", async () => { diff --git a/src/agents/pi-tools.read.host-edit-access.test.ts b/src/agents/pi-tools.read.host-edit-access.test.ts index 6e807b716a3..f227137f4a4 100644 --- a/src/agents/pi-tools.read.host-edit-access.test.ts +++ b/src/agents/pi-tools.read.host-edit-access.test.ts @@ -66,8 +66,13 @@ describe("createHostWorkspaceEditTool host access mapping", () => { // library replaces any access error with a misleading "File not found". // By resolving silently the subsequent readFile call surfaces the real // "Path escapes workspace root" / "outside-workspace" error instead. + const operations = mocks.operations; + expect(operations).toBeDefined(); + if (!operations) { + throw new Error("Expected workspace edit operations to be registered."); + } await expect( - mocks.operations.access(path.join(workspaceDir, "escape", "secret.txt")), + operations.access(path.join(workspaceDir, "escape", "secret.txt")), ).resolves.toBeUndefined(); }, ); diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 628e8b860ae..0a4b8eee15b 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -156,7 +156,7 @@ describe("AgentRuntimePlan", () => { expect(normalized).toHaveLength(1); expect(normalized[0]?.name).toBe("ping"); - expect(normalized[0]?.parameters).toBeTypeOf("object"); + expect(normalized[0]?.parameters).toStrictEqual({}); }); it("does not forward OpenAI API-key profiles into the Codex harness auth slot", () => { diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index c75b8ac4c19..b55ed3c0fde 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -322,7 +322,10 @@ describe("resolveSandboxContext", () => { workspaceDir, }); - expect(result).not.toBeNull(); + expect(result).toMatchObject({ workspaceDir: expect.any(String) }); + if (!result) { + throw new Error("expected sandbox workspace resolution"); + } expect(syncSkillsToWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ sourceWorkspaceDir: workspaceDir, diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 116e8819c5d..efbe274ff29 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -244,9 +244,9 @@ describe("ensureSandboxBrowser create args", () => { const createArgs = findDockerArgsCall(dockerMocks.execDocker.mock.calls, "create"); const envEntries = collectDockerFlagValues(createArgs ?? [], "-e"); - expect(envEntries.some((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD="))).toBe( - false, - ); + expect( + envEntries.filter((entry) => entry.startsWith("OPENCLAW_BROWSER_NOVNC_PASSWORD=")), + ).toEqual([]); expect(result?.noVncUrl).toBeUndefined(); }); diff --git a/src/agents/sandbox/fs-bridge.backend.e2e.test.ts b/src/agents/sandbox/fs-bridge.backend.e2e.test.ts index f458223b176..8dde9c6356d 100644 --- a/src/agents/sandbox/fs-bridge.backend.e2e.test.ts +++ b/src/agents/sandbox/fs-bridge.backend.e2e.test.ts @@ -116,7 +116,9 @@ describe("sandbox fs bridge local backend e2e", () => { await expect( fs.readFile(path.join(workspaceDir, "nested", "hello.txt"), "utf8"), ).resolves.toBe("from-backend"); - expect(scripts.some((script) => script.includes("operation = sys.argv[1]"))).toBe(true); + expect(scripts).toEqual( + expect.arrayContaining([expect.stringContaining("operation = sys.argv[1]")]), + ); } finally { await fs.rm(stateDir, { recursive: true, force: true }); } diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index 1fd440266c9..866f2c96109 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -13,11 +13,21 @@ import { } from "./fs-bridge.test-helpers.js"; function expectNoScriptsContaining(scripts: string[], needle: string) { - expect(scripts.filter((script) => script.includes(needle))).toEqual([]); + expect(scripts.some((script) => script.includes(needle))).toBe(false); } function expectSomeScriptContaining(scripts: string[], needle: string) { - expect(scripts.filter((script) => script.includes(needle)).length).toBeGreaterThan(0); + expect(scripts.some((script) => script.includes(needle))).toBe(true); +} + +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; } describe("sandbox fs bridge shell compatibility", () => { @@ -49,8 +59,8 @@ describe("sandbox fs bridge shell compatibility", () => { const scripts = getScriptsFromCalls(); const executables = mockedExecDockerRaw.mock.calls.map(([args]) => args[3] ?? ""); - expect(executables.filter((shell) => shell !== "sh")).toEqual([]); - expect(scripts.filter((script) => !/set -eu[;\n]/.test(script))).toEqual([]); + expect(executables.every((shell) => shell === "sh")).toBe(true); + expect(scripts.every((script) => /set -eu[;\n]/.test(script))).toBe(true); expectNoScriptsContaining(scripts, "pipefail"); }); }); @@ -157,7 +167,9 @@ describe("sandbox fs bridge shell compatibility", () => { await bridge.rename({ from: "a.txt", to: "nested/b.txt" }); const scripts = getScriptsFromCalls(); - expect(scripts.filter((script) => script.includes("operation = sys.argv[1]")).length).toBe(3); + expect(countMatching(scripts, (script) => script.includes("operation = sys.argv[1]"))).toBe( + 3, + ); expectNoScriptsContaining(scripts, 'mkdir -p -- "$2"'); expectNoScriptsContaining(scripts, 'rm -f -- "$2"'); expectNoScriptsContaining(scripts, 'mv -- "$3" "$2/$4"'); diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts index ca031126615..5fb8849e188 100644 --- a/src/agents/sandbox/validate-sandbox-security.test.ts +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -180,11 +180,11 @@ describe("validateBindMounts", () => { }); it("compares Windows allowed roots case-insensitively", () => { - expect(() => + expect( validateBindMounts(["d:/DATA/OpenClaw/src:/src:ro"], { allowedSourceRoots: ["D:/data/openclaw"], }), - ).not.toThrow(); + ).toBeUndefined(); expect(() => validateBindMounts(["D:/other/project:/src:ro"], { @@ -280,22 +280,22 @@ describe("validateBindMounts", () => { it("allows bind sources in allowed roots when allowlist is configured", () => { const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-allowed-")); - expect(() => + expect( validateBindMounts([`${join(projectRoot, "cache")}:/data:ro`], { allowedSourceRoots: [projectRoot], }), - ).not.toThrow(); + ).toBeUndefined(); }); it("allows bind sources outside allowed roots with explicit dangerous override", () => { const allowedRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-allowed-root-")); const externalRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-external-")); - expect(() => + expect( validateBindMounts([`${externalRoot}:/data:ro`], { allowedSourceRoots: [allowedRoot], allowSourcesOutsideAllowedRoots: true, }), - ).not.toThrow(); + ).toBeUndefined(); }); it("blocks reserved container target paths by default", () => { @@ -307,11 +307,11 @@ describe("validateBindMounts", () => { it("allows reserved container target paths with explicit dangerous override", () => { const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-reserved-")); - expect(() => + expect( validateBindMounts([`${projectRoot}:/workspace:rw`], { allowReservedContainerTargets: true, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -354,11 +354,11 @@ describe("validateNetworkMode", () => { }); it("allows container namespace joins with explicit dangerous override", () => { - expect(() => + expect( validateNetworkMode("container:abc123", { allowContainerNamespaceJoin: true, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -397,13 +397,13 @@ describe("profile hardening", () => { describe("validateSandboxSecurity", () => { it("passes with safe config", () => { const projectRoot = mkdtempSync(join(tmpdir(), "openclaw-sbx-safe-config-")); - expect(() => + expect( validateSandboxSecurity({ binds: [`${projectRoot}:/src:rw`], network: "none", seccompProfile: "/tmp/seccomp.json", apparmorProfile: "openclaw-sandbox", }), - ).not.toThrow(); + ).toBeUndefined(); }); }); diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index d3635036d6f..0e74006559e 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -175,7 +175,7 @@ describe("sanitizeToolUseResultPairing", () => { ]); const out = sanitizeToolUseResultPairing(input); - expect(out.filter((m) => m.role === "toolResult")).toHaveLength(1); + expect(out.reduce((count, m) => count + (m.role === "toolResult" ? 1 : 0), 0)).toBe(1); }); it("drops duplicate tool results for the same id across the transcript", () => { diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index 9893047a113..6e508a86484 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -11,16 +11,6 @@ let resetSessionWriteLockStateForTest: typeof import("./session-write-lock.js"). let resolveSessionLockMaxHoldFromTimeout: typeof import("./session-write-lock.js").resolveSessionLockMaxHoldFromTimeout; let resolveSessionWriteLockAcquireTimeoutMs: typeof import("./session-write-lock.js").resolveSessionWriteLockAcquireTimeoutMs; -vi.mock("../shared/pid-alive.js", async () => { - const original = - await vi.importActual("../shared/pid-alive.js"); - return { - ...original, - // Keep liveness checks real; only pin process start time for PID recycle coverage. - getProcessStartTime: (pid: number) => (pid === process.pid ? FAKE_STARTTIME : null), - }; -}); - async function expectLockRemovedOnlyAfterFinalRelease(params: { lockPath: string; firstLock: { release: () => Promise }; @@ -142,6 +132,12 @@ describe("acquireSessionWriteLock", () => { resetSessionWriteLockStateForTest(); vi.clearAllMocks(); }); + + function pinCurrentProcessStartTimeForTest(): void { + __testing.setProcessStartTimeResolverForTest((pid) => + pid === process.pid ? FAKE_STARTTIME : null, + ); + } it("reuses locks across symlinked session paths", async () => { await withSymlinkedSessionPaths( async ({ sessionReal, sessionLink, realLockPath, linkLockPath }) => { @@ -418,6 +414,7 @@ describe("acquireSessionWriteLock", () => { }); it("cleans untracked current-process .jsonl lock files with matching starttime", async () => { + pinCurrentProcessStartTimeForTest(); const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-")); const sessionsDir = path.join(root, "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); @@ -490,6 +487,7 @@ describe("acquireSessionWriteLock", () => { return; } await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + pinCurrentProcessStartTimeForTest(); // Write a lock with a live PID (current process) but a wrong starttime, // simulating PID recycling: the PID is alive but belongs to a different // process than the one that created the lock. @@ -511,6 +509,7 @@ describe("acquireSessionWriteLock", () => { it("reclaims untracked current-process lock files with matching starttime", async () => { await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + pinCurrentProcessStartTimeForTest(); await writeCurrentProcessLock(lockPath, { starttime: FAKE_STARTTIME }); await expectCurrentPidOwnsLock({ sessionFile, timeoutMs: 500 }); @@ -526,6 +525,7 @@ describe("acquireSessionWriteLock", () => { }); it("does not reclaim active in-process lock files with matching starttime", async () => { + pinCurrentProcessStartTimeForTest(); await expectActiveInProcessLockIsNotReclaimed({ legacyStarttime: FAKE_STARTTIME }); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index aaf1888864c..ae20cde9268 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -61,6 +61,7 @@ type LockInspectionDetails = Pick< >; const SESSION_LOCKS = createFileLockManager("openclaw.session-write-lock"); +let resolveProcessStartTimeForLock = getProcessStartTime; function isFileLockError(error: unknown, code: string): boolean { return (error as { code?: unknown } | null)?.code === code; @@ -312,7 +313,7 @@ function inspectLockPayload( const pidRecycled = pidAlive && pid !== null && storedStarttime !== null ? (() => { - const currentStarttime = getProcessStartTime(pid); + const currentStarttime = resolveProcessStartTimeForLock(pid); return currentStarttime !== null && currentStarttime !== storedStarttime; })() : false; @@ -419,7 +420,7 @@ function shouldTreatAsOrphanSelfLock(params: { return params.reclaimLockWithoutStarttime; } - const currentStarttime = getProcessStartTime(process.pid); + const currentStarttime = resolveProcessStartTimeForLock(process.pid); return currentStarttime !== null && currentStarttime === storedStarttime; } @@ -543,7 +544,7 @@ export async function acquireSessionWriteLock(params: { metadata: { maxHoldMs }, payload: () => { const createdAt = new Date().toISOString(); - const starttime = getProcessStartTime(process.pid); + const starttime = resolveProcessStartTimeForLock(process.pid); const lockPayload: LockFilePayload = { pid: process.pid, createdAt }; if (starttime !== null) { lockPayload.starttime = starttime; @@ -591,6 +592,9 @@ export const __testing = { handleTerminationSignal, releaseAllLocksSync, runLockWatchdogCheck, + setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void { + resolveProcessStartTimeForLock = resolver ?? getProcessStartTime; + }, }; export async function drainSessionWriteLockStateForTest(): Promise { @@ -603,4 +607,5 @@ export function resetSessionWriteLockStateForTest(): void { releaseAllLocksSync(); stopWatchdogTimer(); unregisterCleanupHandlers(); + resolveProcessStartTimeForLock = getProcessStartTime; } diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 6a1926016b6..9cba4c43057 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -48,7 +48,11 @@ describe("getShellConfig", () => { it("uses PowerShell on Windows", () => { const { shell, args } = getShellConfig(); const normalized = shell.toLowerCase(); - expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true); + if (normalized.includes("powershell")) { + expect(normalized).toContain("powershell"); + } else { + expect(normalized).toContain("pwsh"); + } expect(args).toEqual(["-NoProfile", "-NonInteractive", "-Command"]); }); return; diff --git a/src/agents/skills-install.test.ts b/src/agents/skills-install.test.ts index dda90668539..32832fa2caf 100644 --- a/src/agents/skills-install.test.ts +++ b/src/agents/skills-install.test.ts @@ -163,10 +163,12 @@ describe("installSkill code safety scanning", () => { expect(result.ok).toBe(false); expect(result.message).toContain('Skill "danger-skill" installation blocked'); - expect(result.warnings?.some((warning) => warning.includes("dangerous code patterns"))).toBe( - true, + expect(result.warnings ?? []).toEqual( + expect.arrayContaining([ + expect.stringContaining("dangerous code patterns"), + expect.stringContaining("runner.js:1"), + ]), ); - expect(result.warnings?.some((warning) => warning.includes("runner.js:1"))).toBe(true); expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); }); }); diff --git a/src/agents/skills-status.test.ts b/src/agents/skills-status.test.ts index 8ce80855c96..e2db625c472 100644 --- a/src/agents/skills-status.test.ts +++ b/src/agents/skills-status.test.ts @@ -157,8 +157,18 @@ describe("buildWorkspaceSkillStatus", () => { expect(report.agentId).toBe("specialist"); expect(report.agentSkillFilter).toEqual(["alpha"]); expect(report.skills.find((skill) => skill.name === "alpha")?.blockedByAgentFilter).toBe(false); - expect(report.skills.find((skill) => skill.name === "alpha")?.modelVisible).toBe(true); - expect(report.skills.find((skill) => skill.name === "beta")?.blockedByAgentFilter).toBe(true); + expect(report.skills).toContainEqual( + expect.objectContaining({ + name: "alpha", + modelVisible: true, + }), + ); + expect(report.skills).toContainEqual( + expect.objectContaining({ + name: "beta", + blockedByAgentFilter: true, + }), + ); expect(report.skills.find((skill) => skill.name === "beta")?.modelVisible).toBe(false); }); diff --git a/src/agents/skills.bundled-frontmatter.test.ts b/src/agents/skills.bundled-frontmatter.test.ts index 1fbbf4e713c..4d2ad708f31 100644 --- a/src/agents/skills.bundled-frontmatter.test.ts +++ b/src/agents/skills.bundled-frontmatter.test.ts @@ -17,9 +17,9 @@ describe("bundled taskflow skill frontmatter", () => { const raw = await fs.readFile(path.join(repoRoot, relativePath), "utf8"); const frontmatter = parseFrontmatter(raw); - expect(frontmatter.name, relativePath).toEqual(expect.any(String)); + expect(frontmatter.name, relativePath).toBeTypeOf("string"); expect(frontmatter.name.length, relativePath).toBeGreaterThan(0); - expect(frontmatter.description, relativePath).toEqual(expect.any(String)); + expect(frontmatter.description, relativePath).toBeTypeOf("string"); expect(frontmatter.description.length, relativePath).toBeGreaterThan(0); } }); diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index 5f0ba4a3347..90059289b58 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -52,6 +52,16 @@ let envSnapshot: SkillsHomeEnvSnapshot; let tempRoot = ""; let workspaceCaseIndex = 0; +function collectMatching(items: readonly T[], predicate: (item: T) => boolean): T[] { + const matches: T[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(item); + } + } + return matches; +} + async function createTempWorkspaceDir() { const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`); await fs.mkdir(workspaceDir, { recursive: true }); @@ -561,7 +571,9 @@ describe("loadWorkspaceSkillEntries", () => { }, }).map((entry) => entry.skill.name); - expect(names.filter((name) => name.startsWith("nested-skill-"))).toHaveLength(2); + expect( + names.reduce((count, name) => count + (name.startsWith("nested-skill-") ? 1 : 0), 0), + ).toBe(2); expect( warn.mock.calls .map(([line]) => String(line)) @@ -597,7 +609,10 @@ describe("loadWorkspaceSkillEntries", () => { }, }).map((entry) => entry.skill.name); - expect(names.filter((name) => name.startsWith("valid-"))).toEqual(["valid-a", "valid-b"]); + expect(collectMatching(names, (name) => name.startsWith("valid-"))).toEqual([ + "valid-a", + "valid-b", + ]); }); }); }); diff --git a/src/agents/skills/refresh.test.ts b/src/agents/skills/refresh.test.ts index 0feabdfbf74..8669706e547 100644 --- a/src/agents/skills/refresh.test.ts +++ b/src/agents/skills/refresh.test.ts @@ -75,7 +75,8 @@ describe("ensureSkillsWatcher", () => { posix(path.join(os.homedir(), ".agents", "skills")), ]), ); - expect(targets.every((target) => !target.includes("*"))).toBe(true); + const wildcardTargets = targets.filter((target) => target.includes("*")); + expect(wildcardTargets).toEqual([]); const ignored = refreshModule.shouldIgnoreSkillsWatchPath; // Node/JS paths diff --git a/src/agents/subagent-registry-helpers.ts b/src/agents/subagent-registry-helpers.ts index d4b384140e4..f8adb4e27b0 100644 --- a/src/agents/subagent-registry-helpers.ts +++ b/src/agents/subagent-registry-helpers.ts @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import fsSync, { promises as fs } from "node:fs"; import path from "node:path"; import { getRuntimeConfig } from "../config/config.js"; import { @@ -178,6 +178,13 @@ export function resolveSubagentRunOrphanReason(params: { } } +function isResolvedChildPath(params: { childPath: string; rootPath: string }) { + const rootWithSep = params.rootPath.endsWith(path.sep) + ? params.rootPath + : `${params.rootPath}${path.sep}`; + return params.childPath.startsWith(rootWithSep); +} + export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promise { if (!entry.attachmentsDir || !entry.attachmentsRootDir) { return; @@ -205,8 +212,7 @@ export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promis const rootBase = rootReal ?? path.resolve(entry.attachmentsRootDir); const dirBase = dirReal; - const rootWithSep = rootBase.endsWith(path.sep) ? rootBase : `${rootBase}${path.sep}`; - if (!dirBase.startsWith(rootWithSep)) { + if (!isResolvedChildPath({ childPath: dirBase, rootPath: rootBase })) { return; } await fs.rm(dirBase, { recursive: true, force: true }); @@ -215,6 +221,39 @@ export async function safeRemoveAttachmentsDir(entry: SubagentRunRecord): Promis } } +function safeRemoveAttachmentsDirSync(entry: SubagentRunRecord): void { + if (!entry.attachmentsDir || !entry.attachmentsRootDir) { + return; + } + + const resolveReal = (targetPath: string): string | null => { + try { + return fsSync.realpathSync.native(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return null; + } + throw err; + } + }; + + try { + const rootReal = resolveReal(entry.attachmentsRootDir); + const dirReal = resolveReal(entry.attachmentsDir); + if (!dirReal) { + return; + } + + const rootBase = rootReal ?? path.resolve(entry.attachmentsRootDir); + if (!isResolvedChildPath({ childPath: dirReal, rootPath: rootBase })) { + return; + } + fsSync.rmSync(dirReal, { recursive: true, force: true }); + } catch { + // best effort + } +} + export function reconcileOrphanedRun(params: { runId: string; entry: SubagentRunRecord; @@ -258,7 +297,7 @@ export function reconcileOrphanedRun(params: { const shouldDeleteAttachments = params.entry.cleanup === "delete" || !params.entry.retainAttachmentsOnKeep; if (shouldDeleteAttachments) { - void safeRemoveAttachmentsDir(params.entry); + safeRemoveAttachmentsDirSync(params.entry); } const removed = params.runs.delete(params.runId); params.resumedRuns.delete(params.runId); diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 5ea9ddac6dd..eba02ce175c 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -188,12 +188,13 @@ describe("announce loop guard (#18264)", () => { mocks.loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]])); // Initialization attempts resume once, then gives up for exhausted entries. + const beforeInit = Date.now(); registry.initSubagentRegistry(); await Promise.resolve(); await Promise.resolve(); expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(entry.cleanupCompletedAt).toEqual(expect.any(Number)); + expect(entry.cleanupCompletedAt).toBeGreaterThanOrEqual(beforeInit); }); test("expired completion-message entries are still resumed for announce", async () => { diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 1d9beec6fc2..d013acdb581 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -507,6 +507,7 @@ describe("subagent registry persistence", () => { expect(afterFirst?.cleanupCompletedAt).toBeUndefined(); announceSpy.mockResolvedValueOnce(true); + const beforeRetry = Date.now(); restartRegistry(); await waitForRegistryWork(async () => { const afterSecond = await readPersistedRun<{ @@ -519,7 +520,7 @@ describe("subagent registry persistence", () => { const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { runs: Record; }; - expect(afterSecond.runs["run-3"].cleanupCompletedAt).toEqual(expect.any(Number)); + expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); }); it("retries cleanup announce after announce flow rejects", async () => { @@ -553,6 +554,7 @@ describe("subagent registry persistence", () => { expect(afterFirst.runs["run-reject"].cleanupCompletedAt).toBeUndefined(); announceSpy.mockResolvedValueOnce(true); + const beforeRetry = Date.now(); restartRegistry(); await waitForRegistryWork(async () => { const afterSecond = await readPersistedRun<{ @@ -565,7 +567,7 @@ describe("subagent registry persistence", () => { const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { runs: Record; }; - expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toEqual(expect.any(Number)); + expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); }); it("keeps delete-mode runs retryable when announce is deferred", async () => { diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index fa9c3bd7503..4b6acb8c457 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -67,6 +67,17 @@ vi.mock("../config/sessions.js", () => { const announceSpy = vi.fn(async (_params: unknown) => true); const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); const emitSessionLifecycleEventMock = vi.fn(); + +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + const noopContextEngine = { info: { id: "test-context-engine", name: "Test context engine" }, ingest: async () => ({ ingested: false }), @@ -136,7 +147,7 @@ describe("subagent registry steer restarts", () => { }; const createDeferredAnnounceResolver = (): ((value: boolean) => void) => { - let resolveAnnounce!: (value: boolean) => void; + let resolveAnnounce: ((value: boolean) => void) | undefined; announceSpy.mockImplementationOnce( () => new Promise((resolve) => { @@ -144,6 +155,9 @@ describe("subagent registry steer restarts", () => { }), ); return (value: boolean) => { + if (!resolveAnnounce) { + throw new Error("Expected subagent announcement resolver to be initialized"); + } resolveAnnounce(value); }; }; @@ -566,9 +580,10 @@ describe("subagent registry steer restarts", () => { const run = listMainRuns()[0]; expect(run?.outcome).toMatchObject({ status: "error", error: "manual kill" }); - expect(run?.outcome?.startedAt).toEqual(expect.any(Number)); - expect(run?.outcome?.endedAt).toEqual(expect.any(Number)); - expect(run?.outcome?.elapsedMs).toEqual(expect.any(Number)); + expect(run?.outcome?.startedAt).toBeTypeOf("number"); + expect(run?.outcome?.endedAt).toBeTypeOf("number"); + expect(run?.outcome?.elapsedMs).toBeTypeOf("number"); + expect(run?.outcome?.elapsedMs).toBeGreaterThanOrEqual(0); expect(run?.outcome?.endedAt).toBeGreaterThanOrEqual(run?.outcome?.startedAt ?? 0); expect(run?.cleanupHandled).toBe(true); expect(typeof run?.cleanupCompletedAt).toBe("number"); @@ -683,7 +698,7 @@ describe("subagent registry steer restarts", () => { const childRunIds = announceSpy.mock.calls.map( (call) => ((call[0] ?? {}) as { childRunId?: string }).childRunId, ); - expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(1); + expect(countMatching(childRunIds, (id) => id === "run-parent")).toBe(1); }); emitLifecycleEnd("run-child"); @@ -691,15 +706,15 @@ describe("subagent registry steer restarts", () => { const childRunIds = announceSpy.mock.calls.map( (call) => ((call[0] ?? {}) as { childRunId?: string }).childRunId, ); - expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(2); - expect(childRunIds.filter((id) => id === "run-child")).toHaveLength(1); + expect(countMatching(childRunIds, (id) => id === "run-parent")).toBe(2); + expect(countMatching(childRunIds, (id) => id === "run-child")).toBe(1); }); const childRunIds = announceSpy.mock.calls.map( (call) => ((call[0] ?? {}) as { childRunId?: string }).childRunId, ); - expect(childRunIds.filter((id) => id === "run-parent")).toHaveLength(2); - expect(childRunIds.filter((id) => id === "run-child")).toHaveLength(1); + expect(countMatching(childRunIds, (id) => id === "run-parent")).toBe(2); + expect(countMatching(childRunIds, (id) => id === "run-child")).toBe(1); }); it("retries completion-mode announce delivery with backoff and then gives up after retry limit", async () => { diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index 36767de7d54..231b7d1ad6f 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -36,7 +36,6 @@ describe("decodeStrictBase64", () => { const input = "hello world"; const encoded = Buffer.from(input).toString("base64"); const result = decodeStrictBase64(encoded, maxBytes); - expect(result).not.toBeNull(); expect(result?.toString("utf8")).toBe(input); }); @@ -79,7 +78,6 @@ describe("decodeStrictBase64", () => { const exactBuf = Buffer.alloc(1024, 0x41); const encoded = exactBuf.toString("base64"); const result = decodeStrictBase64(encoded, maxBytes); - expect(result).not.toBeNull(); expect(result?.byteLength).toBe(1024); }); }); diff --git a/src/agents/tool-error-summary.ts b/src/agents/tool-error-summary.ts index 39a9264ae7d..f38628d40eb 100644 --- a/src/agents/tool-error-summary.ts +++ b/src/agents/tool-error-summary.ts @@ -1,4 +1,5 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import type { FileTarget } from "./tool-mutation.js"; export type ToolErrorSummary = { toolName: string; @@ -7,6 +8,7 @@ export type ToolErrorSummary = { timedOut?: boolean; mutatingAction?: boolean; actionFingerprint?: string; + fileTarget?: FileTarget; }; const EXEC_LIKE_TOOL_NAMES = new Set(["exec", "bash"]); diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index 904ca191a0b..84e47e651ad 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -88,6 +88,167 @@ describe("tool mutation helpers", () => { ).toBe(false); }); + it("populates structured fileTarget for file-mutating calls (#79024)", () => { + expect(buildToolMutationState("edit", { file_path: "/tmp/a" }).fileTarget).toEqual({ + path: "/tmp/a", + }); + expect(buildToolMutationState("write", { path: "/tmp/Foo|bar" }).fileTarget).toEqual({ + path: "/tmp/foo|bar", + }); + // Non-file-mutating tools never carry fileTarget, even with a path arg. + expect(buildToolMutationState("bash", { command: "rm /tmp/a" }).fileTarget).toBeUndefined(); + expect(buildToolMutationState("exec", { command: "touch /tmp/a" }).fileTarget).toBeUndefined(); + // apply_patch is excluded from file-mutating set, so no fileTarget even + // if a path-shaped arg is synthetically present. + expect( + buildToolMutationState("apply_patch", { input: "*** Update File: /tmp/a" }).fileTarget, + ).toBeUndefined(); + }); + + it("recognizes cross-tool file-mutation recovery on the same target (#79024)", () => { + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + ), + ).toBe(true); + expect( + isSameToolMutationAction( + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + ), + ).toBe(true); + // `apply_patch` is intentionally excluded from the file-mutating set + // because production `apply_patch` calls only carry opaque `input` text, + // so `extractFileTarget` returns `undefined` and the fail-closed branch + // refuses cross-tool recovery. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "apply_patch", + actionFingerprint: "tool=apply_patch|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + ), + ).toBe(false); + }); + + it("does not cross-recover file mutations on different targets (#79024)", () => { + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/b", + fileTarget: { path: "/tmp/b" }, + }, + ), + ).toBe(false); + }); + + it("does not over-match paths containing the fingerprint delimiter (#79024)", () => { + // The fingerprint string carries raw paths separated by `|`. A naive + // `split("|")` parser would extract `path=/tmp/a` from both fingerprints + // and incorrectly clear the prior failure. Structural fileTarget + // comparison fails closed for these distinct paths. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a|left", + fileTarget: { path: "/tmp/a|left" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|right", + fileTarget: { path: "/tmp/a|right" }, + }, + ), + ).toBe(false); + // Same delimiter-bearing path on both sides still matches. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a|shared", + fileTarget: { path: "/tmp/a|shared" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|shared", + fileTarget: { path: "/tmp/a|shared" }, + }, + ), + ).toBe(true); + }); + + it("does not cross-recover when the recovery tool is not file-mutating (#79024)", () => { + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { toolName: "bash", actionFingerprint: "tool=bash|meta=cat /tmp/a" }, + ), + ).toBe(false); + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { toolName: "exec", actionFingerprint: "tool=exec|meta=touch /tmp/a" }, + ), + ).toBe(false); + }); + + it("ignores call-specific noise when comparing the cross-tool target (#79024)", () => { + // `id=...` and `meta=...` segments differ between calls; structural + // fileTarget comparison is unaffected. + expect( + isSameToolMutationAction( + { + toolName: "edit", + actionFingerprint: "tool=edit|path=/tmp/a|id=42|meta=edit /tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + { + toolName: "write", + actionFingerprint: "tool=write|path=/tmp/a|id=99|meta=write /tmp/a", + fileTarget: { path: "/tmp/a" }, + }, + ), + ).toBe(true); + }); + it("keeps legacy name-only mutating heuristics for payload fallback", () => { expect(isLikelyMutatingToolName("sessions_spawn")).toBe(true); expect(isLikelyMutatingToolName("sessions_send")).toBe(true); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index d913446125f..61dc5d2ce0c 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -21,6 +21,21 @@ const MUTATING_TOOL_NAMES = new Set([ "session_status", ]); +// File-mutation tools that operate on the same `path` target identity. +// Recovery is allowed across these even when the tool name differs (e.g. +// edit-fails-then-write-succeeds on the same path), because the user-visible +// invariant is "the file at this path is in the desired state." +// +// `apply_patch` is intentionally excluded: production `apply_patch` calls take +// only an opaque `input` patch string, so `buildToolActionFingerprint` cannot +// extract a `path=` segment from real call args. Including `apply_patch` here +// would only match handcrafted-fingerprint test inputs, not real recoveries. +const FILE_MUTATING_TOOL_NAMES = new Set(["edit", "write"]); + +// Args aliases that identify the file target on a file-mutating call. +const FILE_TARGET_PATH_ARG_KEYS = ["path", "file_path", "filePath", "filepath", "file"] as const; +const FILE_TARGET_OLDPATH_ARG_KEYS = ["oldPath", "old_path"] as const; + const READ_ONLY_ACTIONS = new Set([ "get", "list", @@ -52,15 +67,28 @@ const MESSAGE_MUTATING_ACTIONS = new Set([ "unpin", ]); +// Structured file-target identity for cross-tool same-target recovery. +// Carried alongside `actionFingerprint` so comparison does not have to +// re-parse the joined fingerprint string. Re-parsing was unsafe because +// `buildToolActionFingerprint` stores raw path values in a `|`-delimited +// string, so a path containing `|` could over-match (e.g. `/tmp/a|left` and +// `/tmp/a|right` would both extract as `path=/tmp/a`). +export type FileTarget = { + path?: string; + oldpath?: string; +}; + type ToolMutationState = { mutatingAction: boolean; actionFingerprint?: string; + fileTarget?: FileTarget; }; type ToolActionRef = { toolName: string; meta?: string; actionFingerprint?: string; + fileTarget?: FileTarget; }; function normalizeActionName(value: unknown): string | undefined { @@ -202,26 +230,84 @@ export function buildToolActionFingerprint( return parts.join("|"); } +function isFileMutatingToolName(rawName: string): boolean { + return FILE_MUTATING_TOOL_NAMES.has(normalizeLowercaseStringOrEmpty(rawName)); +} + +function readArgFingerprintValue( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const normalized = normalizeFingerprintValue(record[key]); + if (normalized) { + return normalized; + } + } + return undefined; +} + +export function extractFileTarget(toolName: string, args: unknown): FileTarget | undefined { + if (!isFileMutatingToolName(toolName)) { + return undefined; + } + const record = asRecord(args); + const path = readArgFingerprintValue(record, FILE_TARGET_PATH_ARG_KEYS); + const oldpath = readArgFingerprintValue(record, FILE_TARGET_OLDPATH_ARG_KEYS); + if (!path && !oldpath) { + return undefined; + } + return { + ...(path !== undefined ? { path } : {}), + ...(oldpath !== undefined ? { oldpath } : {}), + }; +} + +function fileTargetsEqual(a: FileTarget, b: FileTarget): boolean { + return (a.path ?? "") === (b.path ?? "") && (a.oldpath ?? "") === (b.oldpath ?? ""); +} + export function buildToolMutationState( toolName: string, args: unknown, meta?: string, ): ToolMutationState { const actionFingerprint = buildToolActionFingerprint(toolName, args, meta); + const fileTarget = extractFileTarget(toolName, args); return { mutatingAction: actionFingerprint != null, actionFingerprint, + ...(fileTarget !== undefined ? { fileTarget } : {}), }; } export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActionRef): boolean { if (existing.actionFingerprint != null || next.actionFingerprint != null) { - // For mutating flows, fail closed: only clear when both fingerprints exist and match. - return ( - existing.actionFingerprint != null && - next.actionFingerprint != null && - existing.actionFingerprint === next.actionFingerprint - ); + // For mutating flows, fail closed: only clear when both fingerprints exist + // and either match exactly or describe the same file-mutation target. + if (existing.actionFingerprint == null || next.actionFingerprint == null) { + return false; + } + if (existing.actionFingerprint === next.actionFingerprint) { + return true; + } + // Cross-tool recovery: a successful file-mutation on the same `path` + // clears an unresolved file-mutation failure even when the tool name + // differs (e.g. edit→write self-heal). Compared structurally on + // `fileTarget` so paths containing `|` cannot over-match. + if ( + isFileMutatingToolName(existing.toolName) && + isFileMutatingToolName(next.toolName) && + existing.fileTarget !== undefined && + next.fileTarget !== undefined && + fileTargetsEqual(existing.fileTarget, next.fileTarget) + ) { + return true; + } + return false; } return existing.toolName === next.toolName && (existing.meta ?? "") === (next.meta ?? ""); } diff --git a/src/agents/tools/gateway-tool-guard-coverage.test.ts b/src/agents/tools/gateway-tool-guard-coverage.test.ts index 19ae284af63..4696719a89f 100644 --- a/src/agents/tools/gateway-tool-guard-coverage.test.ts +++ b/src/agents/tools/gateway-tool-guard-coverage.test.ts @@ -21,13 +21,13 @@ function expectAllowed( currentConfig: Record, patch: Record, ): void { - expect(() => + expect( assertGatewayConfigMutationAllowedForTest({ action: "config.patch", currentConfig, raw: JSON.stringify(patch), }), - ).not.toThrow(); + ).toBeUndefined(); } function expectBlockedApply( @@ -47,13 +47,13 @@ function expectAllowedApply( currentConfig: Record, nextConfig: Record, ): void { - expect(() => + expect( assertGatewayConfigMutationAllowedForTest({ action: "config.apply", currentConfig, raw: JSON.stringify(nextConfig), }), - ).not.toThrow(); + ).toBeUndefined(); } describe("gateway config mutation guard coverage", () => { diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 4b235f086fd..dd61db58eb3 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -113,7 +113,7 @@ function stubImageGenerationProviders() { } function requireImageGenerateTool(tool: ReturnType) { - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected image_generate tool"); } @@ -286,7 +286,7 @@ describe("createImageGenerateTool", () => { throw new Error("runtime provider list should not run during tool registration"); }); - expect( + requireImageGenerateTool( createImageGenerateTool({ config: { agents: { @@ -298,7 +298,7 @@ describe("createImageGenerateTool", () => { }, }, }), - ).not.toBeNull(); + ); expect(listProviders).not.toHaveBeenCalled(); }); @@ -325,7 +325,7 @@ describe("createImageGenerateTool", () => { }, ]); - expect( + requireImageGenerateTool( createImageGenerateTool({ config: { agents: { @@ -337,7 +337,7 @@ describe("createImageGenerateTool", () => { }, }, }), - ).not.toBeNull(); + ); }); it("infers an OpenAI image-generation model from env-backed auth", () => { @@ -347,7 +347,7 @@ describe("createImageGenerateTool", () => { expect(resolveImageGenerationModelConfigForTool({ cfg: {} })).toEqual({ primary: "openai/gpt-image-1", }); - expect(createImageGenerateTool({ config: {} })).not.toBeNull(); + requireImageGenerateTool(createImageGenerateTool({ config: {} })); }); it("does not load runtime providers while resolving an explicitly configured model", () => { @@ -410,7 +410,7 @@ describe("createImageGenerateTool", () => { ).toEqual({ primary: "openai/gpt-image-2", }); - expect(createImageGenerateTool({ config: {}, agentDir: "/tmp/agent" })).not.toBeNull(); + requireImageGenerateTool(createImageGenerateTool({ config: {}, agentDir: "/tmp/agent" })); expect(isConfigured).toHaveBeenCalledWith({ cfg: {}, agentDir: "/tmp/agent", @@ -548,24 +548,21 @@ describe("createImageGenerateTool", () => { contentType: "image/png", }); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - mediaMaxMb: 8, - imageGenerationModel: { - primary: "openai/gpt-image-1", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + mediaMaxMb: 8, + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, }, }, }, - }, - agentDir: "/tmp/agent", - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + agentDir: "/tmp/agent", + }), + ); const result = await tool.execute("call-1", { prompt: "A cat wearing sunglasses", @@ -856,19 +853,17 @@ describe("createImageGenerateTool", () => { contentType: "image/jpeg", }); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { primary: "google/gemini-3.1-flash-image-preview" }, + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { primary: "google/gemini-3.1-flash-image-preview" }, + }, }, }, - }, - }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); const result = await tool.execute("call-regression", { prompt: "kodo sawaki zazen" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; @@ -913,21 +908,19 @@ describe("createImageGenerateTool", () => { }), }, ]); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3.1-flash-image-preview", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, }, }, }, - }, - }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); await expect(tool.execute("call-2", { prompt: "too many cats", count: 5 })).rejects.toThrow( "count must be between 1 and 4", @@ -1363,22 +1356,19 @@ describe("createImageGenerateTool", () => { it("rejects unsupported aspect ratios", async () => { stubImageGenerationProviders(); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3-pro-image-preview", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, }, }, }, - }, - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); await expect( tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" }), @@ -1390,22 +1380,19 @@ describe("createImageGenerateTool", () => { it("lists registered provider and model options", async () => { stubImageGenerationProviders(); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3.1-flash-image-preview", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, }, }, }, - }, - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); const result = await tool.execute("call-list", { action: "list" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; @@ -1467,22 +1454,19 @@ describe("createImageGenerateTool", () => { }, ]); - const tool = createImageGenerateTool({ - config: { - agents: { - defaults: { - imageGenerationModel: { - primary: "__proto__/proto-v1", + const tool = requireImageGenerateTool( + createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "__proto__/proto-v1", + }, }, }, }, - }, - }); - - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image_generate tool"); - } + }), + ); const result = await tool.execute("call-list-proto", { action: "list" }); const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; diff --git a/src/agents/tools/image-tool.ollama.live.test.ts b/src/agents/tools/image-tool.ollama.live.test.ts index 341deaa5095..3656aa71927 100644 --- a/src/agents/tools/image-tool.ollama.live.test.ts +++ b/src/agents/tools/image-tool.ollama.live.test.ts @@ -80,9 +80,12 @@ describe.skipIf(!LIVE)("image tool Ollama live", () => { }, }; const tool = createImageTool({ config: cfg, agentDir, workspaceDir }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); + if (!tool) { + throw new Error("expected image tool"); + } - const result = await tool!.execute("live-ollama-image", { + const result = await tool.execute("live-ollama-image", { prompt: "Describe this image in one short sentence.", image: imagePath, }); diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 93f5db0a22a..1453351fb87 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -533,7 +533,7 @@ async function expectImageToolExecOk( } function requireImageTool(tool: T | null | undefined): T { - expect(tool).not.toBeNull(); + expect(typeof (tool as { execute?: unknown } | null | undefined)?.execute).toBe("function"); if (!tool) { throw new Error("expected image tool"); } @@ -654,7 +654,7 @@ describe("image tool implicit imageModel config", () => { deferAutoModelResolution: true, }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); expect(resolveDefaultMediaModelSpy).not.toHaveBeenCalled(); expect(resolveAutoMediaKeyProvidersSpy).not.toHaveBeenCalled(); }); @@ -673,7 +673,7 @@ describe("image tool implicit imageModel config", () => { ...createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), fallbacks: ["openai/gpt-5.4-mini", "anthropic/claude-opus-4-6"], }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -700,7 +700,7 @@ describe("image tool implicit imageModel config", () => { ...createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), fallbacks: ["openai/gpt-5.4-mini", "anthropic/claude-opus-4-6"], }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -801,7 +801,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("minimax-portal/MiniMax-VL-01"), ); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -814,7 +814,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "opencode/gpt-5-nano", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -827,7 +827,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "opencode-go/kimi-k2.6", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -842,7 +842,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("zai/glm-4.6v"), ); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -871,7 +871,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "acme/vision-1", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -933,7 +933,7 @@ describe("image tool implicit imageModel config", () => { expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "amazon-bedrock/vision-1", }); - expect(createImageTool({ config: cfg, agentDir })).not.toBeNull(); + expect(typeof createImageTool({ config: cfg, agentDir })?.execute).toBe("function"); }); }); @@ -1098,7 +1098,7 @@ describe("image tool implicit imageModel config", () => { primary: "openai/gpt-5.4-mini", }); const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); expect(tool?.description).toContain( "Only use this tool when images were NOT already provided", ); diff --git a/src/agents/tools/music-generate-tool.status.test.ts b/src/agents/tools/music-generate-tool.status.test.ts index 106bcf5933f..1256b3b5322 100644 --- a/src/agents/tools/music-generate-tool.status.test.ts +++ b/src/agents/tools/music-generate-tool.status.test.ts @@ -49,7 +49,7 @@ describe("createMusicGenerateTool status actions", () => { const result = createMusicGenerateDuplicateGuardResult("agent:main:discord:direct:123"); const text = (result?.content?.[0] as { text: string } | undefined)?.text ?? ""; - expect(result).not.toBeNull(); + expect(result?.content).toHaveLength(1); expect(text).toContain("Music generation task task-active is already running with google."); expect(text).toContain("Do not call music_generate again for this request."); expect(result?.details).toMatchObject({ diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index 569601cdb89..a4a8b8c0e79 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -111,6 +111,16 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function expectMusicGenerateTool( + tool: ReturnType, +): NonNullable> { + if (tool === null) { + throw new Error("expected music_generate tool"); + } + expect(typeof tool.execute).toBe("function"); + return tool; +} + function resetMusicGenerateMocks() { vi.restoreAllMocks(); vi.spyOn(musicGenerationRuntime, "listRuntimeMusicGenerationProviders").mockReturnValue([]); @@ -137,7 +147,7 @@ describe("createMusicGenerateTool", () => { }); it("registers when music-generation config is present", () => { - expect( + expectMusicGenerateTool( createMusicGenerateTool({ config: asConfig({ agents: { @@ -147,7 +157,7 @@ describe("createMusicGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); }); it("does not load runtime providers while registering an explicitly configured tool", () => { @@ -157,7 +167,7 @@ describe("createMusicGenerateTool", () => { throw new Error("runtime provider list should not run during tool registration"); }); - expect( + expectMusicGenerateTool( createMusicGenerateTool({ config: asConfig({ agents: { @@ -167,7 +177,7 @@ describe("createMusicGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); expect(listProviders).not.toHaveBeenCalled(); }); @@ -207,7 +217,7 @@ describe("createMusicGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected music_generate tool"); } @@ -277,7 +287,7 @@ describe("createMusicGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected music_generate tool"); } diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index 9f1ed351414..1a00afdc0b4 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -359,8 +359,12 @@ async function loadReferenceImages(params: { readFile: createSandboxBridgeReadFile({ sandbox: params.sandboxConfig }), }) : await (async () => { + const referenceTarget = resolvedPath ?? resolvedInput; + const isRemoteReference = /^https?:\/\//i.test(referenceTarget); const { signal, cleanup } = buildTimeoutAbortSignal({ timeoutMs: params.timeoutMs ?? DEFAULT_REFERENCE_FETCH_TIMEOUT_MS, + operation: "music-generate.reference-fetch", + ...(isRemoteReference ? { url: referenceTarget } : {}), }); try { return await loadWebMedia(resolvedPath ?? resolvedInput, { diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index ae4306a616d..f73c2446116 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -49,7 +49,7 @@ function requirePdfTool( ? R : never, ) { - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected pdf tool"); } diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index e8ed4dad95f..53de64f9b60 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -139,7 +139,7 @@ describe("runSessionsSendA2AFlow announce delivery", () => { roundOneReply: "Worker completed successfully", }); - expect(gatewayCalls.some((call) => call.method === "sessions.list")).toBe(true); + requireGatewayCall("sessions.list"); const sendCall = requireGatewayCall("send"); expect(sendCall.params).toMatchObject({ channel: "discord", diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 2e0c1461cf9..841d3872005 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -210,10 +210,10 @@ describe("sessions_spawn tool", () => { }, }); const schema = tool.parameters as { - properties?: { - thread?: { description?: string; enum?: string[]; type?: string }; - mode?: { description?: string; enum?: string[]; type?: string }; - }; + properties?: Record< + string, + { description?: string; enum?: string[]; type?: string } | undefined + >; }; expect(schema.properties?.thread).toBeUndefined(); @@ -236,10 +236,10 @@ describe("sessions_spawn tool", () => { }, }); const schema = tool.parameters as { - properties?: { - thread?: { description?: string; enum?: string[]; type?: string }; - mode?: { description?: string; enum?: string[]; type?: string }; - }; + properties?: Record< + string, + { description?: string; enum?: string[]; type?: string } | undefined + >; }; const thread = requireSchemaProperty(schema.properties, "thread"); diff --git a/src/agents/tools/video-generate-background.test.ts b/src/agents/tools/video-generate-background.test.ts index 9c260284e4f..dace77ea1a7 100644 --- a/src/agents/tools/video-generate-background.test.ts +++ b/src/agents/tools/video-generate-background.test.ts @@ -104,12 +104,13 @@ describe("video generate background helpers", () => { sessionKey: "agent:main:discord:channel:123", }); + const beforeProgress = Date.now(); recordVideoGenerationTaskProgress({ handle, progressSummary: "Generating video", }); - expect(getAgentRunContext(handle.runId)?.lastActiveAt).toEqual(expect.any(Number)); + expect(getAgentRunContext(handle.runId)?.lastActiveAt).toBeGreaterThanOrEqual(beforeProgress); failVideoGenerationTaskRun({ handle, @@ -121,7 +122,7 @@ describe("video generate background helpers", () => { it("keeps long-running media tasks fresh while provider work is pending", async () => { vi.useFakeTimers(); - let resolveRun!: (value: string) => void; + let resolveRun: ((value: string) => void) | undefined; const runPromise = new Promise((resolve) => { resolveRun = resolve; }); @@ -144,6 +145,9 @@ describe("video generate background helpers", () => { progressSummary: "Generating video", }); + if (!resolveRun) { + throw new Error("Expected video generation run resolver to be initialized"); + } resolveRun("done"); await expect(task).resolves.toBe("done"); const callsAfterCompletion = taskExecutorMocks.recordTaskRunProgressByRunId.mock.calls.length; diff --git a/src/agents/tools/video-generate-tool.status.test.ts b/src/agents/tools/video-generate-tool.status.test.ts index e9d60a736e9..42f0210eb08 100644 --- a/src/agents/tools/video-generate-tool.status.test.ts +++ b/src/agents/tools/video-generate-tool.status.test.ts @@ -49,7 +49,7 @@ describe("createVideoGenerateTool status actions", () => { const result = createVideoGenerateDuplicateGuardResult("agent:main:discord:direct:123"); const text = (result?.content?.[0] as { text: string } | undefined)?.text ?? ""; - expect(result).not.toBeNull(); + expect(result?.content).toHaveLength(1); expect(text).toContain("Video generation task task-active is already running with openai."); expect(text).toContain("Do not call video_generate again for this request."); expect(result?.details).toMatchObject({ diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index f4894e3a091..d1de9cd7635 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -90,6 +90,16 @@ function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function expectVideoGenerateTool( + tool: ReturnType, +): NonNullable> { + if (tool === null) { + throw new Error("expected video_generate tool"); + } + expect(typeof tool.execute).toBe("function"); + return tool; +} + function mockVideoPluginProvider(capabilities: Record = {}) { vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([ { @@ -170,7 +180,7 @@ describe("createVideoGenerateTool", () => { }); it("registers when video-generation config is present", () => { - expect( + expectVideoGenerateTool( createVideoGenerateTool({ config: asConfig({ agents: { @@ -180,7 +190,7 @@ describe("createVideoGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); }); it("does not load runtime providers while registering an explicitly configured tool", () => { @@ -190,7 +200,7 @@ describe("createVideoGenerateTool", () => { throw new Error("runtime provider list should not run during tool registration"); }); - expect( + expectVideoGenerateTool( createVideoGenerateTool({ config: asConfig({ agents: { @@ -200,7 +210,7 @@ describe("createVideoGenerateTool", () => { }, }), }), - ).not.toBeNull(); + ); expect(listProviders).not.toHaveBeenCalled(); }); @@ -309,7 +319,7 @@ describe("createVideoGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected video_generate tool"); } @@ -589,7 +599,7 @@ describe("createVideoGenerateTool", () => { }, }), }); - expect(tool).not.toBeNull(); + expect(typeof tool?.execute).toBe("function"); if (!tool) { throw new Error("expected video_generate tool"); } diff --git a/src/agents/video-generation-task-status.test.ts b/src/agents/video-generation-task-status.test.ts index d2bfe3ac37b..95e9265b8ac 100644 --- a/src/agents/video-generation-task-status.test.ts +++ b/src/agents/video-generation-task-status.test.ts @@ -15,6 +15,15 @@ const taskRuntimeInternalMocks = vi.hoisted(() => ({ vi.mock("../tasks/runtime-internal.js", () => taskRuntimeInternalMocks); +function expectActiveVideoGenerationTask( + task: ReturnType, +): NonNullable> { + if (task == null) { + throw new Error("Expected active video generation task"); + } + return task; +} + describe("video generation task status", () => { beforeEach(() => { taskRuntimeInternalMocks.listTasksForOwnerKey.mockReset(); @@ -92,11 +101,12 @@ describe("video generation task status", () => { const task = findActiveVideoGenerationTaskForSession("agent:main"); expect(task?.taskId).toBe("task-running"); - expect(getVideoGenerationTaskProviderId(task!)).toBe("openai"); - expect(buildVideoGenerationTaskStatusText(task!, { duplicateGuard: true })).toContain( + const activeTask = expectActiveVideoGenerationTask(task); + expect(getVideoGenerationTaskProviderId(activeTask)).toBe("openai"); + expect(buildVideoGenerationTaskStatusText(activeTask, { duplicateGuard: true })).toContain( "Do not call video_generate again for this request.", ); - expect(buildVideoGenerationTaskStatusDetails(task!)).toMatchObject({ + expect(buildVideoGenerationTaskStatusDetails(activeTask)).toMatchObject({ active: true, existingTask: true, status: "running", diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index b0ea88a9442..69f7311ab90 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -119,7 +119,7 @@ describeLive("xai live", () => { const doneMessage = await collectDoneMessage( stream as AsyncIterable<{ type: string; message?: AssistantLikeMessage }>, ); - expect(doneMessage.content).toEqual(expect.any(Array)); + expect(Array.isArray(doneMessage.content)).toBe(true); const payload = requireLiveValue(capturedPayload, "captured xAI payload"); if ("tool_stream" in payload) { expect(payload.tool_stream).toBe(true); @@ -130,7 +130,7 @@ describeLive("xai live", () => { : []; expect(payloadTools.length).toBeGreaterThan(0); const firstFunction = payloadTools[0]?.function; - expect(firstFunction && typeof firstFunction === "object").toBe(true); + expect(firstFunction).toEqual(expect.any(Object)); expect([undefined, false]).toContain((firstFunction as Record).strict); }); }, 90_000); diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index e6437fbce0c..e1e90ebd7eb 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -17,10 +17,18 @@ function expectFencesBalanced(chunks: string[]) { } } +function requireChunk(chunks: string[], index: number): string { + const chunk = chunks[index]; + if (chunk === undefined) { + throw new Error(`expected chunk ${index}`); + } + return chunk; +} + function expectChunkLengths(chunks: string[], expectedLengths: number[]) { expect(chunks).toHaveLength(expectedLengths.length); expectedLengths.forEach((length, index) => { - expect(chunks[index]?.length).toBe(length); + expect(requireChunk(chunks, index).length).toBe(length); }); } @@ -191,8 +199,8 @@ describe("chunkText", () => { text: "This is a message that should break nicely near a word boundary.", limit: 30, assert: (chunks: string[], text: string) => { - expect(chunks[0]?.length).toBeLessThanOrEqual(30); - expect(chunks[1]?.length).toBeLessThanOrEqual(30); + expect(requireChunk(chunks, 0).length).toBeLessThanOrEqual(30); + expect(requireChunk(chunks, 1).length).toBeLessThanOrEqual(30); expectNormalizedChunkJoin(chunks, text); }, }, @@ -338,7 +346,8 @@ describe("chunkMarkdownText", () => { const text = `${prefix}\n\n${fence}\n\n${suffix}`; const chunks = chunkMarkdownText(text, 40); - expect(chunks.some((chunk) => chunk.trimEnd() === fence)).toBe(true); + const intactFenceChunks = chunks.filter((chunk) => chunk.trimEnd() === fence); + expect(intactFenceChunks.length).toBeGreaterThan(0); expectFencesBalanced(chunks); }, }, @@ -394,7 +403,7 @@ describe("chunkMarkdownText", () => { run: () => { const text = `(${"a".repeat(80)})`; const chunks = chunkMarkdownText(text, 20); - expect(chunks[0]?.length).toBe(20); + expect(requireChunk(chunks, 0).length).toBe(20); expect(chunks.join("")).toBe(text); }, }, diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index afe10f4e63f..673b208d367 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -13,8 +13,16 @@ import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registr installDiscordRegistryHooks(); describe("resolveCommandAuthorization", () => { - const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean); + const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => { + const values: string[] = []; + for (const entry of allowFrom) { + const value = String(entry).trim(); + if (value) { + values.push(value); + } + } + return values; + }; function createAllowFromPlugin( id: string, diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index fa9de784a69..48aaae362e1 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -115,6 +115,53 @@ function requireNativeCommand(name: string, provider?: string): ChatCommandDefin return command; } +function requireCommandArg( + command: ChatCommandDefinition, + name: string, +): NonNullable[number] { + const arg = command.args?.find((candidate) => candidate.name === name); + if (!arg) { + throw new Error(`Expected ${command.key} command arg "${name}"`); + } + return arg; +} + +function requireCommandArgAt( + command: ChatCommandDefinition, + index: number, +): NonNullable[number] { + const arg = command.args?.[index]; + if (!arg) { + throw new Error(`Expected ${command.key} command arg ${index}`); + } + return arg; +} + +function requireCommandArgMenu( + params: Parameters[0], +): NonNullable> { + const menu = resolveCommandArgMenu(params); + if (!menu) { + throw new Error(`Expected arg menu for ${params.command.key}`); + } + return menu; +} + +function requireSeenChoice( + seen: { + provider?: string; + model?: string; + catalogLength?: number; + commandKey: string; + argName: string; + } | null, +) { + if (!seen) { + throw new Error("Expected command choice context"); + } + return seen; +} + describe("commands registry", () => { it("builds command text with args", () => { expect(buildCommandText("status")).toBe("/status"); @@ -138,7 +185,7 @@ describe("commands registry", () => { textAliases: ["/btw", "/side"], }); expect(normalizeCommandBody("/side what changed?")).toBe("/btw what changed?"); - expect(findCommandByNativeName("side")?.key).toBe("btw"); + expect(requireNativeCommand("side").key).toBe("btw"); expect(listNativeCommandSpecs().find((spec) => spec.name === "side")).toMatchObject({ acceptsArgs: true, }); @@ -217,7 +264,7 @@ describe("commands registry", () => { { provider: "discord" }, ); expect([...nativeNameSet(native)]).toContain("voice"); - expect(findCommandByNativeName("voice", "discord")?.key).toBe("tts"); + expect(requireNativeCommand("voice", "discord").key).toBe("tts"); expect(findCommandByNativeName("tts", "discord")).toBeUndefined(); }); @@ -228,7 +275,7 @@ describe("commands registry", () => { { provider: "slack" }, ); expect([...nativeNameSet(native)]).toContain("agentstatus"); - expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status"); + expect(requireNativeCommand("agentstatus", "slack").key).toBe("status"); expect(findCommandByNativeName("status", "slack")).toBeUndefined(); expect( findCommandByNativeName("agentstatus", "slack", { @@ -243,11 +290,10 @@ describe("commands registry", () => { }); it("can resolve default native command names without loading bundled channel fallbacks", () => { - expect( - findCommandByNativeName("status", "discord", { - includeBundledChannelFallback: false, - })?.key, - ).toBe("status"); + const command = findCommandByNativeName("status", "discord", { + includeBundledChannelFallback: false, + }); + expect(command).toMatchObject({ key: "status" }); }); it("keeps discord native command specs within slash-command limits", () => { @@ -262,7 +308,7 @@ describe("commands registry", () => { const command = requireNativeCommand(spec.name, "discord"); - const args = command?.args ?? spec.args ?? []; + const args = command.args ?? spec.args ?? []; const argNames = new Set(); let sawOptional = false; for (const arg of args) { @@ -302,8 +348,8 @@ describe("commands registry", () => { it("keeps ACP native action choices aligned with implemented handlers", () => { const acp = requireChatCommand("acp"); - const actionArg = acp.args?.find((arg) => arg.name === "action"); - expect(actionArg?.choices).toEqual([ + const actionArg = requireCommandArg(acp, "action"); + expect(actionArg.choices).toEqual([ "spawn", "cancel", "steer", @@ -324,14 +370,14 @@ describe("commands registry", () => { }); it("registers fast mode as a first-class options command", () => { - const fast = listChatCommands().find((command) => command.key === "fast"); + const fast = requireChatCommand("fast"); expect(fast).toMatchObject({ nativeName: "fast", textAliases: ["/fast"], category: "options", }); - const modeArg = fast?.args?.find((arg) => arg.name === "mode"); - expect(modeArg?.choices).toEqual(["status", "on", "off"]); + const modeArg = requireCommandArg(fast, "mode"); + expect(modeArg.choices).toEqual(["status", "on", "off"]); }); it("detects known text commands", () => { @@ -459,7 +505,7 @@ describe("commands registry args", () => { }; const args = parseCommandArgs(command, "set foo bar baz"); - expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" }); + expect(args).toMatchObject({ values: { action: "set", path: "foo", value: "bar baz" } }); }); it("serializes args via raw first, then values", () => { @@ -482,9 +528,9 @@ describe("commands registry args", () => { it("resolves auto arg menus when missing a choice arg", () => { const command = createUsageModeCommand(); - const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); - expect(menu?.arg.name).toBe("mode"); - expect(menu?.choices).toEqual([ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu.arg.name).toBe("mode"); + expect(menu.choices).toEqual([ { label: "off", value: "off" }, { label: "tokens", value: "tokens" }, { label: "full", value: "full" }, @@ -493,11 +539,12 @@ describe("commands registry args", () => { }); it("keeps verbose full available while preserving no-arg status dispatch", () => { - const verbose = listChatCommands().find((command) => command.key === "verbose"); + const verbose = requireChatCommand("verbose"); - expect(verbose?.args?.[0]?.choices).toEqual(["on", "off", "full"]); + const modeArg = requireCommandArgAt(verbose, 0); + expect(modeArg.choices).toEqual(["on", "off", "full"]); expect( - resolveCommandArgMenu({ command: verbose!, args: undefined, cfg: {} as never }), + resolveCommandArgMenu({ command: verbose, args: undefined, cfg: {} as never }), ).toBeNull(); }); @@ -547,34 +594,28 @@ describe("commands registry args", () => { ], }; - const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); - expect(menu?.arg.name).toBe("level"); - expect(menu?.choices).toEqual([ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu.arg.name).toBe("level"); + expect(menu.choices).toEqual([ { label: "low", value: "low" }, { label: "high", value: "high" }, ]); - expect(formatCommandArgMenuTitle({ command, menu: menu! })).toBe( + expect(formatCommandArgMenuTitle({ command, menu })).toBe( "Choose level for /think.\nOptions: low, high.", ); - const seenChoice = seen as { - provider?: string; - model?: string; - catalogLength?: number; - commandKey: string; - argName: string; - } | null; - expect(seenChoice?.commandKey).toBe("think"); - expect(seenChoice?.argName).toBe("level"); - expect(seenChoice?.provider).toEqual(expect.stringMatching(/\S/)); - expect(seenChoice?.model).toEqual(expect.stringMatching(/\S/)); - expect(seenChoice?.catalogLength).toBe(0); + const seenChoice = requireSeenChoice(seen); + expect(seenChoice.commandKey).toBe("think"); + expect(seenChoice.argName).toBe("level"); + expect(seenChoice.provider).toEqual(expect.stringMatching(/\S/)); + expect(seenChoice.model).toEqual(expect.stringMatching(/\S/)); + expect(seenChoice.catalogLength).toBe(0); }); it("uses configured model catalog reasoning for /think arg menus", () => { installOllamaThinkingProvider(); const command = requireNativeCommand("think"); - const menu = resolveCommandArgMenu({ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: { @@ -590,15 +631,15 @@ describe("commands registry args", () => { model: "glm-5.1:cloud", }); - expect(menu?.arg.name).toBe("level"); - expect(menu?.choices.map((choice) => choice.value)).toEqual([ + expect(menu.arg.name).toBe("level"); + expect(menu.choices.map((choice) => choice.value)).toEqual([ "off", "low", "medium", "high", "max", ]); - expect(formatCommandArgMenuTitle({ command, menu: menu! })).toBe( + expect(formatCommandArgMenuTitle({ command, menu })).toBe( "Choose level for /think.\nOptions: off, low, medium, high, max.", ); }); @@ -606,7 +647,7 @@ describe("commands registry args", () => { it("uses configured model compat for /think arg menus", () => { const command = requireNativeCommand("think"); - const menu = resolveCommandArgMenu({ + const menu = requireCommandArgMenu({ command, args: undefined, cfg: { @@ -629,8 +670,8 @@ describe("commands registry args", () => { model: "gpt-5.4", }); - expect(menu?.choices.map((choice) => choice.value)).toContain("xhigh"); - expect(formatCommandArgMenuTitle({ command, menu: menu! })).toContain("xhigh"); + expect(menu.choices.map((choice) => choice.value)).toContain("xhigh"); + expect(formatCommandArgMenuTitle({ command, menu })).toContain("xhigh"); }); it("does not show menus when args were provided as raw text only", () => { diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index beeef5de487..5dfb942cada 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -363,7 +363,7 @@ describe("createInboundDebouncer", () => { it("keeps later same-key work behind a timer-backed flush that already started", async () => { const started: string[] = []; const finished: string[] = []; - let releaseFirst!: () => void; + let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); @@ -404,6 +404,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["1"]); expect(finished).toEqual([]); + if (!releaseFirst) { + throw new Error("Expected first inbound debounce release callback to be initialized"); + } releaseFirst(); await Promise.all([firstFlush, secondEnqueue]); @@ -417,7 +420,7 @@ describe("createInboundDebouncer", () => { it("keeps fire-and-forget keyed work ahead of a later buffered item", async () => { const started: string[] = []; const finished: string[] = []; - let releaseFirst!: () => void; + let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); @@ -472,6 +475,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["1"]); expect(finished).toEqual([]); + if (!releaseFirst) { + throw new Error("Expected first inbound debounce release callback to be initialized"); + } releaseFirst(); await Promise.all([firstFlush, secondEnqueue, thirdFlush, thirdEnqueue]); @@ -484,7 +490,7 @@ describe("createInboundDebouncer", () => { it("does not serialize keyed turns when debounce is disabled and no keyed chain exists", async () => { const started: string[] = []; - let releaseFirst!: () => void; + let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); @@ -508,6 +514,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["1", "2"]); + if (!releaseFirst) { + throw new Error("Expected first inbound debounce release callback to be initialized"); + } releaseFirst(); await Promise.all([first, second]); }); @@ -582,7 +591,7 @@ describe("createInboundDebouncer", () => { it("keeps same-key overflow work ordered after falling back to immediate flushes", async () => { const started: string[] = []; const finished: string[] = []; - let releaseOverflow!: () => void; + let releaseOverflow: (() => void) | undefined; const overflowGate = new Promise((resolve) => { releaseOverflow = resolve; }); @@ -632,6 +641,9 @@ describe("createInboundDebouncer", () => { expect(started).toEqual(["2"]); expect(finished).toEqual([]); + if (!releaseOverflow) { + throw new Error("Expected inbound overflow release callback to be initialized"); + } releaseOverflow(); await Promise.all([overflowEnqueue, bufferedEnqueue, bufferedFlush]); @@ -645,7 +657,7 @@ describe("createInboundDebouncer", () => { it("counts tracked debounce keys by union of buffers and active chains", async () => { const started: string[] = []; const finished: string[] = []; - let releaseChainOnly!: () => void; + let releaseChainOnly: (() => void) | undefined; const chainOnlyGate = new Promise((resolve) => { releaseChainOnly = resolve; }); @@ -707,6 +719,9 @@ describe("createInboundDebouncer", () => { expect(finished).toEqual(["4"]); }); + if (!releaseChainOnly) { + throw new Error("Expected inbound chain-only release callback to be initialized"); + } releaseChainOnly(); await Promise.all([secondFlush, overflowEnqueue]); expect(finished).toEqual(["4", "2"]); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 8486273dc8c..3e8e4dc9162 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -92,11 +92,20 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ }, })); -let getReplyFromConfig!: GetReplyFromConfig; +let capturedGetReplyFromConfig: GetReplyFromConfig | undefined; installTriggerHandlingReplyHarness((impl) => { - getReplyFromConfig = impl; + capturedGetReplyFromConfig = impl; }); +function getReplyFromConfig( + ...args: Parameters +): ReturnType { + if (!capturedGetReplyFromConfig) { + throw new Error("Expected trigger handling reply harness to install getReplyFromConfig"); + } + return capturedGetReplyFromConfig(...args); +} + const BASE_MESSAGE = { Body: "hello", From: "+1002", diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts index 7402e07d1cc..5d6eddf3eff 100644 --- a/src/auto-reply/reply/acp-projector.test.ts +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -5,6 +5,16 @@ import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js type Delivery = { kind: string; text?: string }; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function createProjectorHarness( cfgOverrides?: Parameters[0], opts?: { onProgress?: () => void }, @@ -567,7 +577,7 @@ describe("createAcpReplyProjector", () => { }); await projector.flush(true); - expect(deliveries.filter((entry) => entry.kind === "tool").length).toBe(4); + expect(countMatching(deliveries, (entry) => entry.kind === "tool")).toBe(4); expect(deliveries[0]).toEqual({ kind: "tool", text: prefixSystemMessage("available commands updated"), diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 795af1e4279..bacdd6fe975 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -180,7 +180,7 @@ describe("runMemoryFlushIfNeeded", () => { }; expect(persisted.main.sessionId).toBe("session-rotated"); expect(persisted.main.compactionCount).toBe(2); - expect(persisted.main.memoryFlushCompactionCount).toBe(2); + expect(persisted.main.memoryFlushCompactionCount).toBe(1); expect(persisted.main.memoryFlushAt).toBe(1_700_000_000_000); }); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 76c7f78af41..4e821883566 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -968,13 +968,13 @@ export async function runMemoryFlushIfNeeded(params: { return result; }, }); - let memoryFlushCompactionCount = + const flushedCompactionCount = activeSessionEntry?.compactionCount ?? (params.sessionKey ? activeSessionStore?.[params.sessionKey]?.compactionCount : 0) ?? 0; if (memoryCompactionCompleted) { const previousSessionId = activeSessionEntry?.sessionId ?? params.followupRun.run.sessionId; - const nextCount = await memoryDeps.incrementCompactionCount({ + await memoryDeps.incrementCompactionCount({ cfg: params.cfg, sessionEntry: activeSessionEntry, sessionStore: activeSessionStore, @@ -1001,9 +1001,6 @@ export async function runMemoryFlushIfNeeded(params: { }); } } - if (typeof nextCount === "number") { - memoryFlushCompactionCount = nextCount; - } } if (params.storePath && params.sessionKey) { try { @@ -1012,7 +1009,7 @@ export async function runMemoryFlushIfNeeded(params: { sessionKey: params.sessionKey, update: async () => ({ memoryFlushAt: memoryDeps.now(), - memoryFlushCompactionCount, + memoryFlushCompactionCount: flushedCompactionCount, }), }); if (updatedEntry) { diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 78ee122be98..b1eeb91c57f 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -30,6 +30,16 @@ const state = vi.hoisted(() => ({ runEmbeddedPiAgentMock: vi.fn(), })); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + let modelFallbackModule: typeof import("../../agents/model-fallback.js"); let onAgentEvent: typeof import("../../infra/agent-events.js").onAgentEvent; @@ -1000,8 +1010,8 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(firstText).toContain("Model Fallback:"); expect(secondText).toContain("Model Fallback cleared:"); expect(thirdText).not.toContain("Model Fallback cleared:"); - expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1); - expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1); + expect(countMatching(phases, (phase) => phase === "fallback")).toBe(1); + expect(countMatching(phases, (phase) => phase === "fallback_cleared")).toBe(1); } finally { fallbackSpy.mockRestore(); } @@ -1077,8 +1087,8 @@ describe("runReplyAgent typing (heartbeat)", () => { const secondText = Array.isArray(second) ? second[0]?.text : second?.text; expect(firstText).not.toContain("Model Fallback:"); expect(secondText).not.toContain("Model Fallback cleared:"); - expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1); - expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1); + expect(countMatching(phases, (phase) => phase === "fallback")).toBe(1); + expect(countMatching(phases, (phase) => phase === "fallback_cleared")).toBe(1); } finally { fallbackSpy.mockRestore(); } diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts index 96d1e04a400..350b3b72b49 100644 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -59,6 +59,17 @@ function normalizeTelegramAllowFromEntries(values: Array): stri return formatAllowFromLowercase({ allowFrom: values, stripPrefixRe: /^(telegram|tg):/i }); } +function normalizeAllowlistValues(values: Array): string[] { + const normalized: string[] = []; + for (const value of values) { + const entry = String(value).trim(); + if (entry) { + normalized.push(entry); + } + } + return normalized; +} + function resolveTelegramTestAccount( cfg: OpenClawConfig, accountId?: string | null, @@ -128,7 +139,7 @@ const whatsappAllowlistTestPlugin: ChannelPlugin = { channelId: "whatsapp", resolveAccount: ({ cfg }) => (cfg.channels?.whatsapp as DmGroupAllowlistTestSectionConfig | undefined) ?? {}, - normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean), + normalize: ({ values }) => normalizeAllowlistValues(values), resolveDmAllowFrom: (account) => account.allowFrom, resolveGroupAllowFrom: (account) => account.groupAllowFrom, resolveDmPolicy: () => undefined, @@ -154,7 +165,7 @@ function createLegacyAllowlistPlugin(channelId: "discord" | "slack"): ChannelPlu channelId, resolveAccount: ({ cfg }) => (cfg.channels?.[channelId] as DmGroupAllowlistTestSectionConfig | undefined) ?? {}, - normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean), + normalize: ({ values }) => normalizeAllowlistValues(values), resolveDmAllowFrom: (account) => account.allowFrom ?? account.dm?.allowFrom, resolveGroupPolicy: () => undefined, resolveGroupOverrides: () => undefined, diff --git a/src/auto-reply/reply/commands-stop-target.test.ts b/src/auto-reply/reply/commands-stop-target.test.ts index 0cf415de302..ffe81b98643 100644 --- a/src/auto-reply/reply/commands-stop-target.test.ts +++ b/src/auto-reply/reply/commands-stop-target.test.ts @@ -56,8 +56,16 @@ vi.mock("./reply-run-registry.js", () => ({ }, })); -const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => { + const values: string[] = []; + for (const entry of allowFrom) { + const value = String(entry).trim(); + if (value) { + values.push(value); + } + } + return values; +}; let previousPluginRegistry: ReturnType; diff --git a/src/auto-reply/reply/commands-subagents-routing.test.ts b/src/auto-reply/reply/commands-subagents-routing.test.ts index 770e2bb02d4..0e6feb8668d 100644 --- a/src/auto-reply/reply/commands-subagents-routing.test.ts +++ b/src/auto-reply/reply/commands-subagents-routing.test.ts @@ -32,8 +32,16 @@ vi.mock("./commands-subagents-control.runtime.js", () => ({ listControlledSubagentRuns: listControlledSubagentRunsMock, })); -const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +const formatAllowFrom = ({ allowFrom }: { allowFrom: Array }) => { + const values: string[] = []; + for (const entry of allowFrom) { + const value = String(entry).trim(); + if (value) { + values.push(value); + } + } + return values; +}; let previousPluginRegistry: ReturnType; diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index 7458d92110e..2a2f4103fd1 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -41,6 +41,7 @@ vi.mock("../../tts/tts.js", () => ttsMocks); const { handleTtsCommands } = await import("./commands-tts.js"); const PRIMARY_TTS_PROVIDER = "acme-speech"; const FALLBACK_TTS_PROVIDER = "backup-speech"; +type TtsCommandResult = Awaited>; function buildTtsParams( commandBodyNormalized: string, @@ -62,6 +63,24 @@ function buildTtsParams( } as unknown as Parameters[0]; } +function expectHandled(result: TtsCommandResult): NonNullable { + if (!result) { + throw new Error("Expected TTS command to be handled"); + } + expect(result.shouldContinue).toBe(false); + return result; +} + +function expectReply( + result: TtsCommandResult, +): NonNullable["reply"]> { + const handled = expectHandled(result); + if (!handled.reply) { + throw new Error("Expected TTS command to return a reply"); + } + return handled.reply; +} + describe("handleTtsCommands status fallback reporting", () => { beforeEach(() => { vi.clearAllMocks(); @@ -104,14 +123,10 @@ describe("handleTtsCommands status fallback reporting", () => { }); const result = await handleTtsCommands(buildTtsParams("/tts status"), true); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain( - `Fallback: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, - ); - expect(result?.reply?.text).toContain( - `Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, - ); - expect(result?.reply?.text).toContain( + const reply = expectReply(result); + expect(reply.text).toContain(`Fallback: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`); + expect(reply.text).toContain(`Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`); + expect(reply.text).toContain( `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(provider_error) 73ms, ${FALLBACK_TTS_PROVIDER}:success(ok) 420ms`, ); }); @@ -136,14 +151,10 @@ describe("handleTtsCommands status fallback reporting", () => { }); const result = await handleTtsCommands(buildTtsParams("/tts status"), true); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain("Error: TTS conversion failed"); - expect(result?.reply?.text).toContain( - `Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, - ); - expect(result?.reply?.text).toContain( - `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(timeout) 999ms`, - ); + const reply = expectReply(result); + expect(reply.text).toContain("Error: TTS conversion failed"); + expect(reply.text).toContain(`Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`); + expect(reply.text).toContain(`Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(timeout) 999ms`); }); it("persists fallback metadata from /tts audio and renders it in /tts status", async () => { @@ -177,19 +188,19 @@ describe("handleTtsCommands status fallback reporting", () => { }); const audioResult = await handleTtsCommands(buildTtsParams("/tts audio hello world"), true); - expect(audioResult?.shouldContinue).toBe(false); - expect(audioResult?.reply?.mediaUrl).toBe("/tmp/fallback.ogg"); + const audioReply = expectReply(audioResult); + expect(audioReply.mediaUrl).toBe("/tmp/fallback.ogg"); const statusResult = await handleTtsCommands(buildTtsParams("/tts status"), true); - expect(statusResult?.shouldContinue).toBe(false); - expect(statusResult?.reply?.text).toContain(`Provider: ${FALLBACK_TTS_PROVIDER}`); - expect(statusResult?.reply?.text).toContain( + const statusReply = expectReply(statusResult); + expect(statusReply.text).toContain(`Provider: ${FALLBACK_TTS_PROVIDER}`); + expect(statusReply.text).toContain( `Fallback: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, ); - expect(statusResult?.reply?.text).toContain( + expect(statusReply.text).toContain( `Attempts: ${PRIMARY_TTS_PROVIDER} -> ${FALLBACK_TTS_PROVIDER}`, ); - expect(statusResult?.reply?.text).toContain( + expect(statusReply.text).toContain( `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(provider_error) 65ms, ${FALLBACK_TTS_PROVIDER}:success(ok) 175ms`, ); }); @@ -201,8 +212,8 @@ describe("handleTtsCommands status fallback reporting", () => { } as OpenClawConfig), true, ); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain("TTS status"); + const reply = expectReply(result); + expect(reply.text).toContain("TTS status"); }); it("resolves status config for the active agent", async () => { @@ -212,7 +223,7 @@ describe("handleTtsCommands status fallback reporting", () => { const result = await handleTtsCommands(buildTtsParams("/tts status", cfg, "reader"), true); - expect(result?.shouldContinue).toBe(false); + expectHandled(result); expect(ttsMocks.resolveTtsConfig).toHaveBeenCalledWith( cfg, expect.objectContaining({ agentId: "reader", channelId: "forum" }), @@ -237,7 +248,7 @@ describe("handleTtsCommands status fallback reporting", () => { true, ); - expect(result?.shouldContinue).toBe(false); + expectHandled(result); expect(ttsMocks.textToSpeech).toHaveBeenCalledWith( expect.objectContaining({ text: "hello", @@ -258,11 +269,11 @@ describe("handleTtsCommands status fallback reporting", () => { ]); const listResult = await handleTtsCommands(buildTtsParams("/tts persona"), true); - expect(listResult?.shouldContinue).toBe(false); - expect(listResult?.reply?.text).toContain("alfred (Alfred) provider=google"); + const listReply = expectReply(listResult); + expect(listReply.text).toContain("alfred (Alfred) provider=google"); const setResult = await handleTtsCommands(buildTtsParams("/tts persona alfred"), true); - expect(setResult?.shouldContinue).toBe(false); + expectHandled(setResult); expect(ttsMocks.setTtsPersona).toHaveBeenCalledWith("/tmp/tts-prefs.json", "alfred"); }); @@ -315,13 +326,14 @@ describe("handleTtsCommands status fallback reporting", () => { const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: 1, sessionFile }; const sessionStore = { "session-key": sessionEntry }; + const beforeTtsRead = Date.now(); const result = await handleTtsCommands( buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(result?.shouldContinue).toBe(false); - expect(result?.reply).toMatchObject({ + const reply = expectReply(result); + expect(reply).toMatchObject({ mediaUrl: "/tmp/latest.ogg", audioAsVoice: true, spokenText: "latest visible reply", @@ -330,7 +342,7 @@ describe("handleTtsCommands status fallback reporting", () => { expect.objectContaining({ text: "latest visible reply" }), ); expect(sessionEntry.lastTtsReadLatestHash).toMatch(/^[a-f0-9]{64}$/); - expect(sessionEntry.lastTtsReadLatestAt).toEqual(expect.any(Number)); + expect(sessionEntry.lastTtsReadLatestAt).toBeGreaterThanOrEqual(beforeTtsRead); }); it("does not resend /tts latest for the same assistant reply", async () => { @@ -358,12 +370,14 @@ describe("handleTtsCommands status fallback reporting", () => { const params = buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }); const first = await handleTtsCommands(params, true); - expect(first?.reply?.mediaUrl).toBe("/tmp/latest.ogg"); + const firstReply = expectReply(first); + expect(firstReply.mediaUrl).toBe("/tmp/latest.ogg"); ttsMocks.textToSpeech.mockClear(); const second = await handleTtsCommands(params, true); - expect(second?.reply?.text).toContain("already sent"); + const secondReply = expectReply(second); + expect(secondReply.text).toContain("already sent"); expect(ttsMocks.textToSpeech).not.toHaveBeenCalled(); }); @@ -375,21 +389,24 @@ describe("handleTtsCommands status fallback reporting", () => { buildTtsParams("/tts chat on", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(onResult?.reply?.text).toContain("enabled for this chat"); + const onReply = expectReply(onResult); + expect(onReply.text).toContain("enabled for this chat"); expect(sessionEntry.ttsAuto).toBe("always"); const offResult = await handleTtsCommands( buildTtsParams("/tts chat off", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(offResult?.reply?.text).toContain("disabled for this chat"); + const offReply = expectReply(offResult); + expect(offReply.text).toContain("disabled for this chat"); expect(sessionEntry.ttsAuto).toBe("off"); const clearResult = await handleTtsCommands( buildTtsParams("/tts chat default", {}, undefined, { sessionEntry, sessionStore }), true, ); - expect(clearResult?.reply?.text).toContain("override cleared"); + const clearReply = expectReply(clearResult); + expect(clearReply.text).toContain("override cleared"); expect(sessionEntry.ttsAuto).toBeUndefined(); }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 7f9b4d9397c..7fb857b4a02 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -727,14 +727,15 @@ function requireBlockReplyHandler( } async function dispatchTwiceWithFreshDispatchers(params: Omit) { - await dispatchReplyFromConfig({ + const first = await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); - await dispatchReplyFromConfig({ + const second = await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); + return [first, second] as const; } describe("dispatchReplyFromConfig", () => { @@ -2727,6 +2728,54 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("keeps message-tool-only delivery mode on duplicate inbound returns", async () => { + setNoAbort(); + const cfg = emptyConfig; + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + ChatType: "channel", + To: "telegram:chat:123", + MessageSid: "msg-tool-only-duplicate", + SessionKey: "agent:main:telegram:channel:123", + }); + const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); + + const [first, duplicate] = await dispatchTwiceWithFreshDispatchers({ + ctx, + cfg, + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(first.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(duplicate.sourceReplyDeliveryMode).toBe("message_tool_only"); + }); + + it("does not mark duplicate inbound returns as tool-only when message is unavailable", async () => { + setNoAbort(); + const cfg = { tools: { allow: ["read"] } } as OpenClawConfig; + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + ChatType: "channel", + To: "telegram:chat:123", + MessageSid: "msg-tool-unavailable-duplicate", + SessionKey: "agent:main:telegram:channel:123", + }); + const replyResolver = vi.fn(async () => ({ text: "visible fallback" }) as ReplyPayload); + + const [first, duplicate] = await dispatchTwiceWithFreshDispatchers({ + ctx, + cfg, + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(first.sourceReplyDeliveryMode).toBeUndefined(); + expect(duplicate.sourceReplyDeliveryMode).toBeUndefined(); + }); + it("keeps local discord exec approval tool prompts when the native runtime is inactive", async () => { setNoAbort(); const cfg = { @@ -3862,7 +3911,7 @@ describe("dispatchReplyFromConfig", () => { const ctx = buildTestCtx({ Provider: "whatsapp" }); const replyResolver = async () => [ - { text: "Reasoning:\n_thinking..._", isReasoning: true }, + { text: "thinking...", isReasoning: true }, { text: "The answer is 42" }, ] satisfies ReplyPayload[]; await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); @@ -3881,7 +3930,7 @@ describe("dispatchReplyFromConfig", () => { opts?: GetReplyOptions, ): Promise => { // Simulate block reply with reasoning payload - await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); + await opts?.onBlockReply?.({ text: "thinking...", isReasoning: true }); await opts?.onBlockReply?.({ text: "The answer is 42" }); return { text: "The answer is 42" }; }; @@ -3895,7 +3944,7 @@ describe("dispatchReplyFromConfig", () => { }, ); await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); - expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); + expect(blockReplySentTexts).not.toContain("thinking..."); expect(blockReplySentTexts).toContain("The answer is 42"); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index f348181522b..bf8c2f1ae9d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -430,20 +430,10 @@ export async function dispatchReplyFromConfig( }); }; - const inboundDedupeClaim = claimInboundDedupe(ctx); - if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") { - recordProcessed("skipped", { reason: "duplicate" }); - return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; - } let inboundDedupeReplayUnsafe = false; const markInboundDedupeReplayUnsafe = () => { inboundDedupeReplayUnsafe = true; }; - const commitInboundDedupeIfClaimed = () => { - if (inboundDedupeClaim.status === "claimed") { - commitInboundDedupe(inboundDedupeClaim.key); - } - }; const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg }); @@ -807,6 +797,20 @@ export async function dispatchReplyFromConfig( ? { ...result, sourceReplyDeliveryMode } : result; + const inboundDedupeClaim = claimInboundDedupe(ctx); + if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") { + recordProcessed("skipped", { reason: "duplicate" }); + return attachSourceReplyDeliveryMode({ + queuedFinal: false, + counts: dispatcher.getQueuedCounts(), + }); + } + const commitInboundDedupeIfClaimed = () => { + if (inboundDedupeClaim.status === "claimed") { + commitInboundDedupe(inboundDedupeClaim.key); + } + }; + let pluginFallbackReason: | "plugin-bound-fallback-missing-plugin" | "plugin-bound-fallback-no-handler" diff --git a/src/auto-reply/reply/export-html/template.security.test.ts b/src/auto-reply/reply/export-html/template.security.test.ts index 2be2f256c78..ec6b3cc123f 100644 --- a/src/auto-reply/reply/export-html/template.security.test.ts +++ b/src/auto-reply/reply/export-html/template.security.test.ts @@ -145,9 +145,12 @@ function selectorSpecificity(selector: string): [number, number, number] { const ids = selector.match(/#[\w-]+/g)?.length ?? 0; const classes = selector.match(/\.[\w-]+/g)?.length ?? 0; const withoutIdsOrClasses = selector.replace(/#[\w-]+|\.[\w-]+/g, " "); - const elements = withoutIdsOrClasses - .split(/[\s>+~]+/) - .filter((part) => /^[a-z][\w-]*$/i.test(part)).length; + let elements = 0; + for (const part of withoutIdsOrClasses.split(/[\s>+~]+/)) { + if (/^[a-z][\w-]*$/i.test(part)) { + elements++; + } + } return [ids, classes, elements]; } diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts new file mode 100644 index 00000000000..747e6064a6c --- /dev/null +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -0,0 +1,231 @@ +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { GetReplyOptions } from "../get-reply-options.types.js"; +import type { ReplyPayload } from "../reply-payload.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext } from "./commands-context.js"; +import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { resolveReplyDirectives } from "./get-reply-directives.js"; +import { initFastReplySessionState } from "./get-reply-fast-path.js"; +import { handleInlineActions } from "./get-reply-inline-actions.js"; +import { stripStructuralPrefixes } from "./mentions.js"; +import type { createTypingController } from "./typing.js"; + +type AgentDefaults = NonNullable["defaults"]> | undefined; + +const commandsRuntimeLoader = createLazyImportLoader(() => import("./commands.runtime.js")); +const statusCommandRuntimeLoader = createLazyImportLoader(() => import("./commands-status.js")); + +function loadCommandsRuntime() { + return commandsRuntimeLoader.load(); +} + +function loadStatusCommandRuntime() { + return statusCommandRuntimeLoader.load(); +} + +function resolveNativeSlashCommandName(ctx: MsgContext): string | undefined { + if (ctx.CommandSource !== "native") { + return undefined; + } + const commandText = stripStructuralPrefixes( + ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "", + ).trim(); + const match = commandText.match(/^\/([^\s:]+)(?::|\s|$)/); + return normalizeOptionalString(match?.[1])?.toLowerCase(); +} + +function shouldRunNativeSlashCommandFastPath(ctx: MsgContext): boolean { + const commandName = resolveNativeSlashCommandName(ctx); + return Boolean(commandName && commandName !== "new" && commandName !== "reset"); +} + +export async function maybeResolveNativeSlashCommandFastReply(params: { + ctx: MsgContext; + cfg: OpenClawConfig; + agentId: string; + agentDir: string; + agentCfg: AgentDefaults; + commandAuthorized: boolean; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + provider: string; + model: string; + workspaceDir: string; + typing: ReturnType; + opts?: GetReplyOptions; + skillFilter?: string[]; +}): Promise< + { handled: true; reply: ReplyPayload | ReplyPayload[] | undefined } | { handled: false } +> { + if (!shouldRunNativeSlashCommandFastPath(params.ctx)) { + return { handled: false }; + } + + const sessionState = initFastReplySessionState({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + commandAuthorized: params.commandAuthorized, + workspaceDir: params.workspaceDir, + }); + const command = buildCommandContext({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + sessionKey: sessionState.sessionKey, + isGroup: sessionState.isGroup, + triggerBodyNormalized: sessionState.triggerBodyNormalized, + commandAuthorized: params.commandAuthorized, + }); + if (command.commandBodyNormalized === "/status") { + const targetSessionEntry = + sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry; + const { buildStatusReply } = await loadStatusCommandRuntime(); + return { + handled: true, + reply: await buildStatusReply({ + cfg: params.cfg, + command, + sessionEntry: targetSessionEntry, + sessionKey: sessionState.sessionKey, + parentSessionKey: targetSessionEntry?.parentSessionKey ?? params.ctx.ParentSessionKey, + sessionScope: sessionState.sessionScope, + storePath: sessionState.storePath, + provider: params.provider, + model: params.model, + workspaceDir: params.workspaceDir, + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: sessionState.isGroup, + defaultGroupActivation: () => "always", + mediaDecisions: params.ctx.MediaUnderstandingDecisions, + }), + }; + } + + const commandResult = await ( + await loadCommandsRuntime() + ).handleCommands({ + ctx: sessionState.sessionCtx, + rootCtx: params.ctx, + cfg: params.cfg, + command, + agentId: params.agentId, + agentDir: params.agentDir, + directives: clearInlineDirectives(sessionState.triggerBodyNormalized), + elevated: { + enabled: false, + allowed: false, + failures: [], + }, + sessionEntry: sessionState.sessionEntry, + previousSessionEntry: sessionState.previousSessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + storePath: sessionState.storePath, + sessionScope: sessionState.sessionScope, + workspaceDir: params.workspaceDir, + opts: params.opts, + defaultGroupActivation: () => "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: "text_end", + resolveDefaultThinkingLevel: async () => undefined, + provider: params.provider, + model: params.model, + contextTokens: params.agentCfg?.contextTokens ?? 0, + isGroup: sessionState.isGroup, + skillCommands: [], + typing: params.typing, + }); + if (!commandResult.shouldContinue) { + return { handled: true, reply: commandResult.reply }; + } + + const directiveResult = await resolveReplyDirectives({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + agentCfg: params.agentCfg, + sessionCtx: sessionState.sessionCtx, + sessionEntry: sessionState.sessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + storePath: sessionState.storePath, + sessionScope: sessionState.sessionScope, + groupResolution: sessionState.groupResolution, + isGroup: sessionState.isGroup, + triggerBodyNormalized: sessionState.triggerBodyNormalized, + resetTriggered: false, + commandAuthorized: params.commandAuthorized, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + provider: params.provider, + model: params.model, + hasResolvedHeartbeatModelOverride: false, + typing: params.typing, + opts: params.opts, + skillFilter: params.skillFilter, + }); + if (directiveResult.kind === "reply") { + return { handled: true, reply: directiveResult.reply }; + } + + const inlineActionResult = await handleInlineActions({ + ctx: params.ctx, + sessionCtx: sessionState.sessionCtx, + cfg: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + sessionEntry: sessionState.sessionEntry, + previousSessionEntry: sessionState.previousSessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + storePath: sessionState.storePath, + sessionScope: sessionState.sessionScope, + workspaceDir: params.workspaceDir, + isGroup: sessionState.isGroup, + opts: params.opts, + typing: params.typing, + allowTextCommands: directiveResult.result.allowTextCommands, + inlineStatusRequested: directiveResult.result.inlineStatusRequested, + command: directiveResult.result.command, + skillCommands: directiveResult.result.skillCommands, + directives: directiveResult.result.directives, + cleanedBody: directiveResult.result.cleanedBody, + elevatedEnabled: directiveResult.result.elevatedEnabled, + elevatedAllowed: directiveResult.result.elevatedAllowed, + elevatedFailures: directiveResult.result.elevatedFailures, + defaultActivation: () => directiveResult.result.defaultActivation, + resolvedThinkLevel: directiveResult.result.resolvedThinkLevel, + resolvedVerboseLevel: directiveResult.result.resolvedVerboseLevel, + resolvedReasoningLevel: directiveResult.result.resolvedReasoningLevel, + resolvedElevatedLevel: directiveResult.result.resolvedElevatedLevel, + blockReplyChunking: directiveResult.result.blockReplyChunking, + resolvedBlockStreamingBreak: directiveResult.result.resolvedBlockStreamingBreak, + resolveDefaultThinkingLevel: directiveResult.result.modelState.resolveDefaultThinkingLevel, + provider: directiveResult.result.provider, + model: directiveResult.result.model, + contextTokens: directiveResult.result.contextTokens, + directiveAck: directiveResult.result.directiveAck, + abortedLastRun: sessionState.abortedLastRun, + skillFilter: params.skillFilter, + }); + if (inlineActionResult.kind === "reply") { + return { handled: true, reply: inlineActionResult.reply }; + } + return { handled: false }; +} diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index ae23f0160a1..a534bdd7a5f 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -931,7 +931,7 @@ describe("runPreparedReply media-only handling", () => { await import("../../agents/auth-profiles/session-override.js"); const queueSettings = await import("./queue/settings-runtime.js"); - let resolveAuth!: () => void; + let resolveAuth: (() => void) | undefined; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); @@ -957,6 +957,9 @@ describe("runPreparedReply media-only handling", () => { resetTriggered: false, }); intruderRun.setPhase("running"); + if (!resolveAuth) { + throw new Error("Expected auth profile resolver to be initialized"); + } resolveAuth(); await Promise.resolve(); @@ -1019,7 +1022,7 @@ describe("runPreparedReply media-only handling", () => { await import("../../agents/auth-profiles/session-override.js"); const queueSettings = await import("./queue/settings-runtime.js"); - let resolveAuth!: () => void; + let resolveAuth: (() => void) | undefined; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); @@ -1060,6 +1063,9 @@ describe("runPreparedReply media-only handling", () => { }; rotatedRun.updateSessionId("session-after-rotation"); + if (!resolveAuth) { + throw new Error("Expected auth profile resolver to be initialized"); + } resolveAuth(); await Promise.resolve(); diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 069961e2687..dce4d111db6 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -130,6 +130,86 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce(); }); + it("handles native /status before workspace bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-fast-")); + const targetSessionKey = "agent:main:telegram:123"; + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "openai/gpt-5.5", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: path.join(home, "sessions.json") }, + } as OpenClawConfig); + + const reply = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/status", + BodyForAgent: "/status", + RawBody: "/status", + CommandBody: "/status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ); + + expect(reply).toEqual(expect.objectContaining({ text: expect.stringContaining("OpenClaw") })); + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + + it("handles native slash directives before workspace bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-")); + const targetSessionKey = "agent:main:telegram:123"; + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: path.join(home, "sessions.json") }, + } as OpenClawConfig); + mocks.resolveReplyDirectives.mockResolvedValueOnce({ + kind: "reply", + reply: { text: "model status" }, + }); + + await expect( + getReplyFromConfig( + buildGetReplyCtx({ + Body: "/model status", + BodyForAgent: "/model status", + RawBody: "/model status", + CommandBody: "/model status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ), + ).resolves.toEqual({ text: "model status" }); + + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: targetSessionKey, + workspaceDir: expect.any(String), + }), + ); + }); + it("uses native command target session keys during fast bootstrap", () => { const result = initFastReplySessionState({ ctx: buildGetReplyCtx({ diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index be72e482e92..0910e8b7b40 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -36,6 +36,7 @@ import { shouldUseReplyFastTestRuntime, } from "./get-reply-fast-path.js"; import { handleInlineActions } from "./get-reply-inline-actions.js"; +import { maybeResolveNativeSlashCommandFastReply } from "./get-reply-native-slash-fast-path.js"; import { runPreparedReply } from "./get-reply-run.js"; import { finalizeInboundContext } from "./inbound-context.js"; import { hasInboundMedia } from "./inbound-media.js"; @@ -248,16 +249,7 @@ export async function getReplyFromConfig( } const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; - const workspace = await traceGetReplyPhase("reply.ensure_workspace", async () => - useFastTestBootstrap - ? (await fs.mkdir(workspaceDirRaw, { recursive: true }), { dir: workspaceDirRaw }) - : await ensureAgentWorkspace({ - dir: workspaceDirRaw, - ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, - skipOptionalBootstrapFiles: agentCfg?.skipOptionalBootstrapFiles, - }), - ); - const workspaceDir = workspace.dir; + const workspaceDirForNativeCommand = workspaceDirRaw; const agentDir = resolveAgentDir(cfg, agentId); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideSeconds: opts?.timeoutOverrideSeconds }); const configuredTypingSeconds = @@ -274,6 +266,41 @@ export async function getReplyFromConfig( opts?.onTypingController?.(typing); const finalized = finalizeInboundContext(ctx); + const nativeSlashCommandFastReply = await traceGetReplyPhase( + "reply.native_slash_command_fast_path", + () => + maybeResolveNativeSlashCommandFastReply({ + ctx: finalized, + cfg, + agentId, + agentDir, + agentCfg, + commandAuthorized: finalized.CommandAuthorized, + defaultProvider, + defaultModel, + aliasIndex, + provider, + model, + workspaceDir: workspaceDirForNativeCommand, + typing, + opts: resolvedOpts, + skillFilter: mergedSkillFilter, + }), + ); + if (nativeSlashCommandFastReply.handled) { + return nativeSlashCommandFastReply.reply; + } + + const workspace = await traceGetReplyPhase("reply.ensure_workspace", async () => + useFastTestBootstrap + ? (await fs.mkdir(workspaceDirRaw, { recursive: true }), { dir: workspaceDirRaw }) + : await ensureAgentWorkspace({ + dir: workspaceDirRaw, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, + skipOptionalBootstrapFiles: agentCfg?.skipOptionalBootstrapFiles, + }), + ); + const workspaceDir = workspace.dir; if (!isFastTestEnv && hasInboundMedia(finalized)) { await traceGetReplyPhase("reply.apply_media_understanding", () => diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 9f123ea81fc..1de1fdd4f3a 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -389,7 +389,7 @@ describe("buildInboundUserContextPrefix", () => { } as TemplateContext); const conversationInfo = parseConversationInfoPayload(text); - expect(conversationInfo["timestamp"]).toEqual(expect.any(String)); + expect(conversationInfo["timestamp"]).toMatch(/^Sun 2026-02-15 13:35 (?:GMT|UTC)$/); }); it("honors envelope user timezone for conversation timestamps", () => { @@ -412,14 +412,6 @@ describe("buildInboundUserContextPrefix", () => { }); it("omits invalid timestamps instead of throwing", () => { - expect(() => - buildInboundUserContextPrefix({ - ChatType: "group", - MessageSid: "msg-with-bad-ts", - Timestamp: 1e20, - } as TemplateContext), - ).not.toThrow(); - const text = buildInboundUserContextPrefix({ ChatType: "group", MessageSid: "msg-with-bad-ts", diff --git a/src/auto-reply/reply/pending-tool-task-drain.test.ts b/src/auto-reply/reply/pending-tool-task-drain.test.ts index ed37dab6492..75695d78c10 100644 --- a/src/auto-reply/reply/pending-tool-task-drain.test.ts +++ b/src/auto-reply/reply/pending-tool-task-drain.test.ts @@ -2,12 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { drainPendingToolTasks } from "./pending-tool-task-drain.js"; function deferredTask() { - let resolve!: () => void; - let reject!: (error: Error) => void; + let resolve: (() => void) | undefined; + let reject: ((error: Error) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred task callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 981304c4a90..860814ebf45 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -29,7 +29,6 @@ describe("readPostCompactionContext", () => { }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Do startup things"); expect(result).toContain("Be safe"); if (expectDefaultProse) { @@ -63,7 +62,6 @@ Not relevant. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Session Startup"); expect(result).toContain("WORKFLOW_AUTO.md"); expect(result).toContain("Post-compaction context refresh"); @@ -84,7 +82,6 @@ Stuff. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Red Lines"); expect(result).toContain("Never do X"); }); @@ -106,7 +103,6 @@ Ignore this. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Session Startup"); expect(result).toContain("Red Lines"); expect(result).not.toContain("Other"); @@ -116,9 +112,8 @@ Ignore this. const longContent = "## Session Startup\n\n" + "A".repeat(4000) + "\n\n## Other\n\nStuff."; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), longContent); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("[truncated]"); - expect(result!.length).toBeLessThan(2600); + expect(result?.length).toBeLessThan(2600); }); it("honors per-agent post-compaction context limit overrides", async () => { @@ -144,9 +139,8 @@ Ignore this. } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg, agentId: "writer" }); - expect(result).not.toBeNull(); expect(result).toContain("[truncated]"); - expect(result!.length).toBeLessThan(1_200); + expect(result?.length).toBeLessThan(1_200); }); it("matches section names case-insensitively", async () => { @@ -160,7 +154,6 @@ Read WORKFLOW_AUTO.md `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("WORKFLOW_AUTO.md"); }); @@ -175,7 +168,6 @@ Read these files. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Read these files"); }); @@ -195,7 +187,6 @@ Real red lines here. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Real red lines here"); expect(result).not.toContain("inside a code block"); }); @@ -213,7 +204,6 @@ Never do Y. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Rule 1"); expect(result).toContain("Rule 2"); expect(result).not.toContain("Other Section"); @@ -259,12 +249,10 @@ Never modify memory/YYYY-MM-DD.md destructively. // 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); const result = await readPostCompactionContext(tmpDir, { cfg, nowMs }); - expect(result).not.toBeNull(); expect(result).toContain("memory/2026-03-03.md"); expect(result).not.toContain("memory/YYYY-MM-DD.md"); - expect(result).toContain( - "Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York) / 2026-03-03 14:00 UTC", - ); + expect(result).toContain("Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York)"); + expect(result).toContain("Reference UTC: 2026-03-03 14:00 UTC"); }); it("appends current time line even when no YYYY-MM-DD placeholder is present", async () => { @@ -275,7 +263,6 @@ Read WORKFLOW.md on startup. fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); const result = await readPostCompactionContext(tmpDir, { nowMs }); - expect(result).not.toBeNull(); expect(result).toContain("Current time:"); }); @@ -303,7 +290,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Critical Rules"); expect(result).toContain("My custom rules"); // Default sections must not be included when overridden @@ -322,7 +308,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Onboard things"); expect(result).toContain("Safe things"); expect(result).not.toContain("Ignore"); @@ -371,7 +356,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); // Must not reference the hardcoded default section name expect(result).not.toContain("Session Startup"); // Must reference the actual configured section names @@ -382,7 +366,6 @@ Read WORKFLOW.md on startup. const content = `## Session Startup\n\nDo startup.\n`; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); expect(result).toContain("Run your Session Startup sequence"); }); @@ -408,7 +391,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - expect(result).not.toBeNull(); expect(result).toContain("Init things"); }); }); diff --git a/src/auto-reply/reply/queue.drain-restart.test.ts b/src/auto-reply/reply/queue.drain-restart.test.ts index 588652db2dc..a68a6de6e28 100644 --- a/src/auto-reply/reply/queue.drain-restart.test.ts +++ b/src/auto-reply/reply/queue.drain-restart.test.ts @@ -210,7 +210,7 @@ describe("followup queue drain restart after idle window", () => { const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 }; const allProcessed = createDeferred(); - let runFollowupResolve!: () => void; + let runFollowupResolve: (() => void) | undefined; const runFollowupGate = new Promise((res) => { runFollowupResolve = res; }); @@ -225,6 +225,9 @@ describe("followup queue drain restart after idle window", () => { enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); scheduleFollowupDrain(key, runFollowup); enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); + if (!runFollowupResolve) { + throw new Error("Expected followup run release callback to be initialized"); + } runFollowupResolve(); await allProcessed.promise; diff --git a/src/auto-reply/reply/reply-delivery.test.ts b/src/auto-reply/reply/reply-delivery.test.ts index 01b3ff9cd3f..f5b6aa838ac 100644 --- a/src/auto-reply/reply/reply-delivery.test.ts +++ b/src/auto-reply/reply/reply-delivery.test.ts @@ -309,7 +309,15 @@ describe("createBlockReplyDeliveryHandler", () => { await handler(payload); - const enqueuedPayload = enqueue.mock.calls[0]?.[0]; + expect(enqueue).toHaveBeenCalledTimes(1); + const [firstCall] = enqueue.mock.calls; + if (!firstCall) { + throw new Error("Expected block reply pipeline enqueue call"); + } + const [enqueuedPayload] = firstCall; + if (enqueuedPayload === undefined) { + throw new Error("Expected block reply pipeline payload"); + } expect(enqueuedPayload).toEqual({ text: "Alpha", mediaUrl: undefined, diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 35ef750db99..4d242382927 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -293,6 +293,22 @@ describe("shouldRunMemoryFlush", () => { ).toBe(true); }); + it("runs on consecutive compaction cycles when flush records the pre-increment count", () => { + const params = { + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }; + + for (const entry of [ + { totalTokens: 95_000, compactionCount: 1 }, + { totalTokens: 95_000, compactionCount: 2, memoryFlushCompactionCount: 1 }, + { totalTokens: 95_000, compactionCount: 3, memoryFlushCompactionCount: 2 }, + ]) { + expect(shouldRunMemoryFlush({ entry, ...params })).toBe(true); + } + }); + it("ignores stale cached totals", () => { expect( shouldRunMemoryFlush({ diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 91b438462b3..24bf5ca78e2 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -18,6 +18,17 @@ import { createMockTypingController } from "./test-helpers.js"; import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; import { createTypingController } from "./typing.js"; +type NormalizedReplyPayload = NonNullable>; + +function expectNormalizedReply( + result: ReturnType, +): NormalizedReplyPayload { + if (result === null) { + throw new Error("Expected normalized reply payload"); + } + return result; +} + describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bopenclaw\b/i]; @@ -92,9 +103,9 @@ describe("normalizeReplyPayload", () => { const normalized = normalizeReplyPayload(payload); - expect(normalized).not.toBeNull(); - expect(normalized?.text).toBeUndefined(); - expect(normalized?.channelData).toEqual(payload.channelData); + const reply = expectNormalizedReply(normalized); + expect(reply.text).toBeUndefined(); + expect(reply.channelData).toEqual(payload.channelData); }); it("records skip reasons for silent/empty payloads", () => { @@ -114,50 +125,45 @@ describe("normalizeReplyPayload", () => { it("strips NO_REPLY from mixed emoji message (#30916)", () => { const result = normalizeReplyPayload({ text: "😄 NO_REPLY" }); - expect(result).not.toBeNull(); - expect(result!.text).toContain("😄"); - expect(result!.text).not.toContain("NO_REPLY"); + const reply = expectNormalizedReply(result); + expect(reply.text).toContain("😄"); + expect(reply.text).not.toContain("NO_REPLY"); }); it("strips NO_REPLY appended after substantive text (#30916)", () => { const result = normalizeReplyPayload({ text: "File's there. Not urgent.\n\nNO_REPLY", }); - expect(result).not.toBeNull(); - expect(result!.text).toContain("File's there"); - expect(result!.text).not.toContain("NO_REPLY"); + const reply = expectNormalizedReply(result); + expect(reply.text).toContain("File's there"); + expect(reply.text).not.toContain("NO_REPLY"); }); it("strips glued leading NO_REPLY text without leaking the token", () => { const result = normalizeReplyPayload({ text: "NO_REPLYThe user is saying hello", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("The user is saying hello"); + expect(expectNormalizedReply(result).text).toBe("The user is saying hello"); }); it("strips glued leading NO_REPLY text case-insensitively", () => { const result = normalizeReplyPayload({ text: "no_replyThe user is saying hello", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("The user is saying hello"); + expect(expectNormalizedReply(result).text).toBe("The user is saying hello"); }); it("keeps NO_REPLY when used as leading substantive text", () => { const result = normalizeReplyPayload({ text: "NO_REPLY -- nope" }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("NO_REPLY -- nope"); + expect(expectNormalizedReply(result).text).toBe("NO_REPLY -- nope"); }); it("keeps punctuation-start content after a leading NO_REPLY token", () => { const colonResult = normalizeReplyPayload({ text: "NO_REPLY: explanation" }); - expect(colonResult).not.toBeNull(); - expect(colonResult!.text).toBe("NO_REPLY: explanation"); + expect(expectNormalizedReply(colonResult).text).toBe("NO_REPLY: explanation"); const dashResult = normalizeReplyPayload({ text: "NO_REPLY—note" }); - expect(dashResult).not.toBeNull(); - expect(dashResult!.text).toBe("NO_REPLY—note"); + expect(expectNormalizedReply(dashResult).text).toBe("NO_REPLY—note"); }); it("suppresses message when stripping NO_REPLY leaves nothing", () => { @@ -184,8 +190,7 @@ describe("normalizeReplyPayload", () => { const result = normalizeReplyPayload({ text: '{"action":"NO_REPLY","note":"example"}', }); - expect(result).not.toBeNull(); - expect(result!.text).toBe('{"action":"NO_REPLY","note":"example"}'); + expect(expectNormalizedReply(result).text).toBe('{"action":"NO_REPLY","note":"example"}'); }); it("strips NO_REPLY but keeps media payload", () => { @@ -193,9 +198,9 @@ describe("normalizeReplyPayload", () => { text: "NO_REPLY", mediaUrl: "https://example.com/img.png", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe(""); - expect(result!.mediaUrl).toBe("https://example.com/img.png"); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe(""); + expect(reply.mediaUrl).toBe("https://example.com/img.png"); }); it("strips JSON NO_REPLY action text but keeps media payload", () => { @@ -203,9 +208,9 @@ describe("normalizeReplyPayload", () => { text: '{"action":"NO_REPLY"}', mediaUrl: "https://example.com/img.png", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe(""); - expect(result!.mediaUrl).toBe("https://example.com/img.png"); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe(""); + expect(reply.mediaUrl).toBe("https://example.com/img.png"); }); it("strips legacy uppercase TOOL_CALL blocks from normalized replies", () => { @@ -217,8 +222,7 @@ describe("normalizeReplyPayload", () => { ].join("\n"), }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Before\n\nAfter"); + expect(expectNormalizedReply(result).text).toBe("Before\n\nAfter"); }); it("strips legacy uppercase TOOL_RESULT blocks from normalized replies", () => { @@ -226,8 +230,7 @@ describe("normalizeReplyPayload", () => { text: ["Before", '[TOOL_RESULT]{"output":"secret result"}[/TOOL_RESULT]', "After"].join("\n"), }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Before\n\nAfter"); + expect(expectNormalizedReply(result).text).toBe("Before\n\nAfter"); }); it("does not compile Slack directives unless interactive replies are enabled", () => { @@ -235,9 +238,9 @@ describe("normalizeReplyPayload", () => { text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); - expect(result!.interactive).toBeUndefined(); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); + expect(reply.interactive).toBeUndefined(); }); it("applies responsePrefix before channel-owned transforms run", () => { @@ -248,9 +251,9 @@ describe("normalizeReplyPayload", () => { { responsePrefix: "[bot]" }, ); - expect(result).not.toBeNull(); - expect(result!.text).toBe("[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); - expect(result!.interactive).toBeUndefined(); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe("[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); + expect(reply.interactive).toBeUndefined(); }); it("leaves trailing Options lines for channel-owned transforms", () => { @@ -258,9 +261,9 @@ describe("normalizeReplyPayload", () => { text: "Current verbose level: off.\nOptions: on, full, off.", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Current verbose level: off.\nOptions: on, full, off."); - expect(result!.interactive).toBeUndefined(); + const reply = expectNormalizedReply(result); + expect(reply.text).toBe("Current verbose level: off.\nOptions: on, full, off."); + expect(reply.interactive).toBeUndefined(); }); it("leaves larger Options lists for channel-owned transforms", () => { @@ -268,11 +271,11 @@ describe("normalizeReplyPayload", () => { text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe( + const reply = expectNormalizedReply(result); + expect(reply.text).toBe( "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", ); - expect(result!.interactive).toBeUndefined(); + expect(reply.interactive).toBeUndefined(); }); it("leaves complex Options lines as plain text", () => { @@ -280,11 +283,11 @@ describe("normalizeReplyPayload", () => { text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", }); - expect(result).not.toBeNull(); - expect(result!.text).toBe( + const reply = expectNormalizedReply(result); + expect(reply.text).toBe( "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", ); - expect(result!.interactive).toBeUndefined(); + expect(reply.interactive).toBeUndefined(); }); }); @@ -850,12 +853,12 @@ describe("block reply coalescer", () => { }, }); - coalescer.enqueue({ text: "Reasoning:\n_hidden_", isReasoning: true }); + coalescer.enqueue({ text: "hidden", isReasoning: true }); coalescer.enqueue({ text: "Visible answer" }); await coalescer.flush({ force: true }); expect(flushes).toEqual([ - { text: "Reasoning:\n_hidden_", isReasoning: true }, + { text: "hidden", isReasoning: true }, { text: "Visible answer", isReasoning: undefined }, ]); coalescer.stop(); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 444bfc8671f..ffcb93cf3d4 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -204,7 +204,7 @@ describe("routeReply", () => { }); it("suppresses reasoning payloads", async () => { - await expectSlackNoDelivery({ text: "Reasoning:\n_step_", isReasoning: true }); + await expectSlackNoDelivery({ text: "step", isReasoning: true }); }); it("drops silent token payloads", async () => { diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts index 02d1f1d36f3..5e66ba85a97 100644 --- a/src/auto-reply/reply/session-delivery.test.ts +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -58,12 +58,11 @@ describe("inter-session lastRoute preservation (fixes #54441)", () => { sessionKey: "agent:samantha:main", isInterSession: true, }); - // No external route existed — falls through to normal resolution (webchat or undefined) - // The important thing is it does NOT throw and returns a defined or undefined value. - expect(result === "webchat" || result === undefined).toBe(true); + // No external route existed — falls through to normal resolution (webchat or undefined). + expect(["webchat", undefined]).toContain(result); }); - it("inter-session on session with no persisted lastTo does not crash", () => { + it("inter-session on session with no persisted lastTo preserves session route", () => { const result = resolveLastToRaw({ originatingChannelRaw: "webchat", originatingToRaw: "session:somekey", @@ -74,7 +73,7 @@ describe("inter-session lastRoute preservation (fixes #54441)", () => { isInterSession: true, }); // No external route — falls through to normal resolution - expect(result === "session:somekey" || result === undefined).toBe(true); + expect(["session:somekey", undefined]).toContain(result); }); }); diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts index e67a38a5ca5..caa4a63db26 100644 --- a/src/auto-reply/reply/session-fork.runtime.test.ts +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -286,10 +286,12 @@ describe("forkSessionFromParentRuntime", () => { sessionsDir, }); - expect(fork).not.toBeNull(); - expect(fork?.sessionFile).toContain(sessionsDir); - expect(fork?.sessionId).not.toBe(parentSessionId); - const raw = await fs.readFile(fork?.sessionFile ?? "", "utf-8"); + if (fork === null) { + throw new Error("Expected forked session"); + } + expect(fork.sessionFile).toContain(sessionsDir); + expect(fork.sessionId).not.toBe(parentSessionId); + const raw = await fs.readFile(fork.sessionFile, "utf-8"); const forkedEntries = raw .trim() .split(/\r?\n/u) @@ -297,7 +299,7 @@ describe("forkSessionFromParentRuntime", () => { const resolvedParentSessionFile = await fs.realpath(parentSessionFile); expect(forkedEntries[0]).toMatchObject({ type: "session", - id: fork?.sessionId, + id: fork.sessionId, cwd, parentSession: resolvedParentSessionFile, }); @@ -342,14 +344,16 @@ describe("forkSessionFromParentRuntime", () => { sessionsDir, }); - expect(fork).not.toBeNull(); - const raw = await fs.readFile(fork?.sessionFile ?? "", "utf-8"); + if (!fork) { + throw new Error("expected forked session entry"); + } + const raw = await fs.readFile(fork.sessionFile, "utf-8"); const lines = raw.trim().split(/\r?\n/u); expect(lines).toHaveLength(1); const resolvedParentSessionFile = await fs.realpath(parentSessionFile); expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ type: "session", - id: fork?.sessionId, + id: fork.sessionId, parentSession: resolvedParentSessionFile, }); }); diff --git a/src/auto-reply/reply/session-reset-cleanup.ts b/src/auto-reply/reply/session-reset-cleanup.ts index c36a33364ad..ecf88c29567 100644 --- a/src/auto-reply/reply/session-reset-cleanup.ts +++ b/src/auto-reply/reply/session-reset-cleanup.ts @@ -1,5 +1,5 @@ import { drainSystemEventEntries } from "../../infra/system-events.js"; -import { clearSessionQueues, type ClearSessionQueueResult } from "./queue.js"; +import { clearSessionQueues, type ClearSessionQueueResult } from "./queue/cleanup.js"; export type ClearSessionResetRuntimeStateResult = ClearSessionQueueResult & { systemEventsCleared: number; diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index d4f18acf6f4..1e8aa20280d 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -50,9 +50,8 @@ describe("buildBareSessionResetPrompt", () => { // 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); const prompt = buildBareSessionResetPrompt(cfg, nowMs); - expect(prompt).toContain( - "Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York) / 2026-03-03 14:00 UTC", - ); + expect(prompt).toContain("Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York)"); + expect(prompt).toContain("Reference UTC: 2026-03-03 14:00 UTC"); }); it("does not append a duplicate current time line", () => { diff --git a/src/auto-reply/reply/session-transcript-replay.test.ts b/src/auto-reply/reply/session-transcript-replay.test.ts index 76d83a62a89..581d777efd8 100644 --- a/src/auto-reply/reply/session-transcript-replay.test.ts +++ b/src/auto-reply/reply/session-transcript-replay.test.ts @@ -9,6 +9,27 @@ import { const j = (obj: unknown): string => `${JSON.stringify(obj)}\n`; +type ReplayRecord = { + type?: string; + id?: string; + message?: { + role?: string; + content?: string; + }; +}; + +async function readJsonlRecords(filePath: string): Promise { + const records: ReplayRecord[] = []; + const raw = await fs.readFile(filePath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + if (line.trim().length === 0) { + continue; + } + records.push(JSON.parse(line) as ReplayRecord); + } + return records; +} + describe("replayRecentUserAssistantMessages", () => { let root = ""; beforeEach(async () => { @@ -37,14 +58,11 @@ describe("replayRecentUserAssistantMessages", () => { await fs.writeFile(source, lines.join(""), "utf8"); expect(await call(source, target)).toBe(DEFAULT_REPLAY_MAX_MESSAGES); - const records = (await fs.readFile(target, "utf8")) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); + const records = await readJsonlRecords(target); expect(records[0]).toMatchObject({ type: "session", id: "new-session" }); expect(records).toHaveLength(1 + DEFAULT_REPLAY_MAX_MESSAGES); for (const r of records.slice(1)) { - expect(["user", "assistant"]).toContain(r.message.role); + expect(["user", "assistant"]).toContain(r.message?.role); } expect(await call(path.join(root, "missing.jsonl"), path.join(root, "out.jsonl"))).toBe(0); @@ -69,13 +87,10 @@ describe("replayRecentUserAssistantMessages", () => { await fs.writeFile(source, lines.join(""), "utf8"); expect(await call(source, target)).toBe(DEFAULT_REPLAY_MAX_MESSAGES - 1); - const records = (await fs.readFile(target, "utf8")) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); - expect(records.filter((r) => r.type === "session")).toHaveLength(1); + const records = await readJsonlRecords(target); + expect(records.reduce((count, r) => count + (r.type === "session" ? 1 : 0), 0)).toBe(1); expect(records[0]).toMatchObject({ id: "existing" }); - expect(records[1].message.role).toBe("user"); + expect(records[1].message?.role).toBe("user"); }); it("coalesces same-role runs so replayed records strictly alternate", async () => { @@ -95,17 +110,14 @@ describe("replayRecentUserAssistantMessages", () => { ); expect(await call(source, target)).toBe(4); - const records = (await fs.readFile(target, "utf8")) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); - expect(records.slice(1).map((r) => r.message.role)).toEqual([ + const records = await readJsonlRecords(target); + expect(records.slice(1).map((r) => r.message?.role)).toEqual([ "user", "assistant", "user", "assistant", ]); - expect(records.slice(1).map((r) => r.message.content)).toEqual([ + expect(records.slice(1).map((r) => r.message?.content)).toEqual([ "latest user", "latest assistant", "follow-up", diff --git a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts index fa1d515ccaa..583b59cdd3f 100644 --- a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts +++ b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts @@ -82,6 +82,14 @@ describe("initSessionState - heartbeat should not trigger session reset", () => }); }; + const expectPersistedSession = (sessionStore: Record): SessionEntry => { + const entry = sessionStore["main:user123"]; + if (!entry) { + throw new Error("Expected persisted session for main:user123"); + } + return entry; + }; + it("should NOT reset session when Provider is 'heartbeat'", async () => { // Setup: Create a session entry that is "stale" (older than idle timeout) const now = Date.now(); @@ -191,7 +199,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => expect(heartbeatResult.sessionEntry.lastInteractionAt).toBe(staleTime); const persistedAfterHeartbeat = loadSessionStore(storePath); - expect(persistedAfterHeartbeat["main:user123"]?.lastInteractionAt).toBe(staleTime); + expect(expectPersistedSession(persistedAfterHeartbeat).lastInteractionAt).toBe(staleTime); const userResult = await initSessionState({ ctx: createBaseCtx({ @@ -278,7 +286,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => expect(heartbeatResult.sessionId).toBe("legacy-idle-session"); const persistedAfterHeartbeat = loadSessionStore(storePath); - expect(persistedAfterHeartbeat["main:user123"]?.lastInteractionAt).toBeUndefined(); + expect(expectPersistedSession(persistedAfterHeartbeat).lastInteractionAt).toBeUndefined(); const userResult = await initSessionState({ ctx: createBaseCtx({ diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 0121badb8be..c0678a5024f 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2693,7 +2693,10 @@ describe("initSessionState preserves behavior overrides across /new and /reset", expect(result.isNewSession).toBe(false); expect(result.sessionId).toBe(existingSessionId); expect(result.sessionEntry.cliSessionBindings?.["claude-cli"]).toEqual(cliBinding); - expect(await fs.stat(transcriptPath).catch(() => null)).not.toBeNull(); + const transcriptStat = await fs.stat(transcriptPath).catch(() => null); + if (!transcriptStat) { + throw new Error("expected transcript file to remain after stale reset"); + } const archived = (await fs.readdir(path.dirname(storePath))).filter((entry) => entry.startsWith(`${existingSessionId}.jsonl.reset.`), ); diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts index c457c1e4a03..b2c5b556c7c 100644 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ b/src/auto-reply/reply/subagents-utils.test.ts @@ -40,6 +40,18 @@ function resolveTarget(runs: SubagentRunRecord[], token: string | undefined) { }); } +function expectResolvedRunId( + runs: SubagentRunRecord[], + token: string | undefined, + expectedRunId: string, +): void { + const resolved = resolveTarget(runs, token); + if (!resolved.entry) { + throw new Error(`Expected ${String(token)} to resolve, got ${resolved.error ?? "no target"}`); + } + expect(resolved.entry.runId).toBe(expectedRunId); +} + describe("subagents utils", () => { afterEach(() => { vi.restoreAllMocks(); @@ -65,8 +77,7 @@ describe("subagents utils", () => { makeRun({ runId: "old", createdAt: NOW_MS - 2_000 }), makeRun({ runId: "new", createdAt: NOW_MS - 500 }), ]; - const resolved = resolveTarget(runs, " last "); - expect(resolved.entry?.runId).toBe("new"); + expectResolvedRunId(runs, " last ", "new"); }); it("resolves numeric index from running then recent finished order", () => { @@ -91,14 +102,14 @@ describe("subagents utils", () => { }), ]; - expect(resolveTarget(runs, "1").entry?.runId).toBe("running"); - expect(resolveTarget(runs, "2").entry?.runId).toBe("recent-finished"); + expectResolvedRunId(runs, "1", "running"); + expectResolvedRunId(runs, "2", "recent-finished"); expect(resolveTarget(runs, "3").error).toBe("invalid:3"); }); it("resolves session key target and unknown session errors", () => { const run = makeRun({ runId: "abc123", childSessionKey: "agent:beta:subagent:xyz" }); - expect(resolveTarget([run], "agent:beta:subagent:xyz").entry?.runId).toBe("abc123"); + expectResolvedRunId([run], "agent:beta:subagent:xyz", "abc123"); expect(resolveTarget([run], "agent:beta:subagent:missing").error).toBe( "unknown-session:agent:beta:subagent:missing", ); @@ -111,11 +122,11 @@ describe("subagents utils", () => { makeRun({ runId: "run-beta-1", label: "Beta Worker" }), ]; - expect(resolveTarget(runs, "beta worker").entry?.runId).toBe("run-beta-1"); - expect(resolveTarget(runs, "beta").entry?.runId).toBe("run-beta-1"); - expect(resolveTarget(runs, "run-beta").entry?.runId).toBe("run-beta-1"); + expectResolvedRunId(runs, "beta worker", "run-beta-1"); + expectResolvedRunId(runs, "beta", "run-beta-1"); + expectResolvedRunId(runs, "run-beta", "run-beta-1"); - expect(resolveTarget(runs, "alpha core").entry?.runId).toBe("run-alpha-1"); + expectResolvedRunId(runs, "alpha core", "run-alpha-1"); expect(resolveTarget(runs, "alpha").error).toBe("ambiguous-prefix:alpha"); expect(resolveTarget(runs, "run-alpha").error).toBe("ambiguous-run:run-alpha"); expect(resolveTarget(runs, "missing").error).toBe("unknown:missing"); @@ -149,6 +160,6 @@ describe("subagents utils", () => { }), ]; - expect(resolveTarget(runs, "same worker").entry?.runId).toBe("run-new"); + expectResolvedRunId(runs, "same worker", "run-new"); }); }); diff --git a/src/channels/message/lifecycle.test.ts b/src/channels/message/lifecycle.test.ts index faeaa9e5d72..dfe14d72122 100644 --- a/src/channels/message/lifecycle.test.ts +++ b/src/channels/message/lifecycle.test.ts @@ -295,11 +295,12 @@ describe("message lifecycle primitives", () => { expect(ctx.shouldAckAfter("receive_record")).toBe(false); expect(ctx.shouldAckAfter("durable_send")).toBe(true); + const beforeAck = Date.now(); await ctx.ack(); await ctx.ack(); expect(onAck).toHaveBeenCalledTimes(1); expect(ctx.ackState).toBe("acked"); - expect(ctx.ackedAt).toEqual(expect.any(Number)); + expect(ctx.ackedAt).toBeGreaterThanOrEqual(beforeAck); await ctx.nack(new Error("offset failed")); expect(onNack).toHaveBeenCalledWith(expect.any(Error)); diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 01b8cee6b0f..b84640e3520 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -784,8 +784,8 @@ describe("bundled channel entry shape guards", () => { it("keeps plugin-sdk channel-core free of chat metadata bootstrap imports", () => { const source = fs.readFileSync(path.resolve("src/plugin-sdk/channel-core.ts"), "utf8"); - expect(source.includes("../channels/chat-meta.js")).toBe(false); - expect(source.includes("getChatChannelMeta")).toBe(false); + expect(source).not.toContain("../channels/chat-meta.js"); + expect(source).not.toContain("getChatChannelMeta"); }); it("keeps bundled hot runtime barrels off the broad core SDK surface", () => { @@ -816,7 +816,7 @@ describe("bundled channel entry shape guards", () => { it("keeps extension-shared off the broad runtime barrel", () => { const source = fs.readFileSync(path.resolve("src/plugin-sdk/extension-shared.ts"), "utf8"); - expect(source.includes('from "./runtime.js"')).toBe(false); + expect(source).not.toContain('from "./runtime.js"'); }); it("keeps bundled doctor surfaces off the broad runtime barrel", () => { diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts index e0025a76ef7..ebff136cd21 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -22,8 +22,12 @@ function createDirectTextMediaHarness(params: OutboundPayloadHarnessParams) { text: "", payload: params.payload, }; + const sendPayload = outbound.sendPayload; + if (!sendPayload) { + throw new Error("Expected direct text/media outbound sendPayload"); + } return { - run: async () => await outbound.sendPayload!(ctx), + run: async () => await sendPayload(ctx), sendMock: sendFn, to: ctx.to, }; diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 7fc2bced245..73531190179 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -22,6 +22,10 @@ const moduleLoaderParams = vi.hoisted( }>, ); +function pluginIds(plugins: ReturnType): string[] { + return plugins.map((entry) => entry.id); +} + vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -473,7 +477,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("external-chat"); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -610,7 +614,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "alpha-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("alpha-chat"); const betaPlugin = plugins.find((entry) => entry.id === "beta-chat"); expect(betaPlugin?.meta.id).toBe("beta-chat"); expect( @@ -792,7 +796,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === unsafeChannelId)).toBe(false); + expect(pluginIds(plugins)).not.toContain(unsafeChannelId); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -907,7 +911,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("external-chat"); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -927,7 +931,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === channelId)).toBe(false); + expect(pluginIds(plugins)).not.toContain(channelId); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -953,7 +957,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === channelId)).toBe(false); + expect(pluginIds(plugins)).not.toContain(channelId); expect(fs.existsSync(setupMarker)).toBe(false); expect(fs.existsSync(fullMarker)).toBe(false); }); @@ -1096,8 +1100,8 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); - expect(plugins.some((entry) => entry.id === "spoofed-chat")).toBe(false); - expect(plugins.some((entry) => entry.id === "external-chat")).toBe(false); + expect(pluginIds(plugins)).not.toContain("spoofed-chat"); + expect(pluginIds(plugins)).not.toContain("external-chat"); expect(fs.existsSync(setupMarker)).toBe(true); expect(fs.existsSync(fullMarker)).toBe(false); }); diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 884a1c2a9ed..48498b204c3 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -60,6 +60,53 @@ function expectSetEmojiCall(calls: Array<{ method: string; emoji: string }>, emo expect(calls).toContainEqual({ method: "set", emoji }); } +function collectEmojisForMethod( + calls: Array<{ method: string; emoji: string }>, + method: string, +): string[] { + const emojis: string[] = []; + for (const call of calls) { + if (call.method === method) { + emojis.push(call.emoji); + } + } + return emojis; +} + +function countCallsForMethod(calls: Array<{ method: string; emoji: string }>, method: string) { + let count = 0; + for (const call of calls) { + if (call.method === method) { + count += 1; + } + } + return count; +} + +function countCallsForEmoji(calls: Array<{ method: string; emoji: string }>, emoji: string) { + let count = 0; + for (const call of calls) { + if (call.emoji === emoji) { + count += 1; + } + } + return count; +} + +function countCallsForMethodAndEmoji( + calls: Array<{ method: string; emoji: string }>, + method: string, + emoji: string, +) { + let count = 0; + for (const call of calls) { + if (call.method === method && call.emoji === emoji) { + count += 1; + } + } + return count; +} + function expectArrayContainsAll(values: readonly string[], expected: readonly string[]) { expected.forEach((value) => { expect(values).toContain(value); @@ -264,7 +311,7 @@ describe("createStatusReactionController", () => { await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should only have the last one (exec → display emoji) - const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji); + const setEmojis = collectEmojisForMethod(calls, "set"); expect(setEmojis).toEqual(["🛠️"]); }); @@ -292,7 +339,7 @@ describe("createStatusReactionController", () => { void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); - const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji); + const setEmojis = collectEmojisForMethod(calls, "set"); expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]); }); @@ -325,7 +372,7 @@ describe("createStatusReactionController", () => { await controller.setDone(); - const removeEmojis = calls.filter((call) => call.method === "remove").map((call) => call.emoji); + const removeEmojis = collectEmojisForMethod(calls, "remove"); expect(removeEmojis).toEqual(expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, "🛠️"])); expect(removeEmojis).not.toContain(DEFAULT_EMOJIS.done); }); @@ -354,10 +401,7 @@ describe("createStatusReactionController", () => { void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); - const thinkingSets = calls.filter( - (call) => call.method === "set" && call.emoji === DEFAULT_EMOJIS.thinking, - ); - expect(thinkingSets).toHaveLength(1); + expect(countCallsForMethodAndEmoji(calls, "set", DEFAULT_EMOJIS.thinking)).toBe(1); expect(calls).not.toContainEqual({ method: "remove", emoji: DEFAULT_EMOJIS.thinking }); }); @@ -371,9 +415,8 @@ describe("createStatusReactionController", () => { await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should only have set calls, no remove - const removeCalls = calls.filter((c) => c.method === "remove"); - expect(removeCalls).toHaveLength(0); - expect(calls.filter((c) => c.method === "set").length).toBeGreaterThan(0); + expect(countCallsForMethod(calls, "remove")).toBe(0); + expect(calls.some((c) => c.method === "set")).toBe(true); }); it("should clear all known emojis when adapter supports removeReaction", async () => { @@ -385,8 +428,7 @@ describe("createStatusReactionController", () => { await controller.clear(); // Should have removed multiple emojis - const removeCalls = calls.filter((c) => c.method === "remove"); - expect(removeCalls.length).toBeGreaterThan(0); + expect(countCallsForMethod(calls, "remove")).toBeGreaterThan(0); }); it("should handle clear gracefully when adapter lacks removeReaction", async () => { @@ -395,8 +437,7 @@ describe("createStatusReactionController", () => { await controller.clear(); // Should not throw, no remove calls - const removeCalls = calls.filter((c) => c.method === "remove"); - expect(removeCalls).toHaveLength(0); + expect(countCallsForMethod(calls, "remove")).toBe(0); }); it("should restore initial emoji", async () => { @@ -497,8 +538,7 @@ describe("createStatusReactionController", () => { await runUpdate(controller); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); - const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft); - expect(stallCalls).toHaveLength(0); + expect(countCallsForEmoji(calls, DEFAULT_EMOJIS.stallSoft)).toBe(0); }); it("should call onError callback when adapter throws", async () => { diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts index d8ddae0a592..7f78c9e2eef 100644 --- a/src/channels/typing.test.ts +++ b/src/channels/typing.test.ts @@ -73,7 +73,7 @@ describe("createTypingCallbacks", () => { }); it("does not block reply start on a pending typing request", async () => { - let resolveStart!: () => void; + let resolveStart: (() => void) | undefined; const { start, callbacks } = createTypingHarness({ start: vi.fn( () => @@ -86,6 +86,9 @@ describe("createTypingCallbacks", () => { await callbacks.onReplyStart(); expect(start).toHaveBeenCalledTimes(1); + if (!resolveStart) { + throw new Error("Expected typing start resolver to be initialized"); + } resolveStart(); }); diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 0bc8c1f4bce..edb2b81c94f 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -154,6 +154,9 @@ vi.mock("../config/config.js", () => ({ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "main", resolveAgentDir: () => "/tmp/agent", + resolveAgentConfig: () => ({}), + resolveAgentEffectiveModelPrimary: () => undefined, + resolveAgentModelFallbacksOverride: () => [], })); vi.mock("../agents/model-catalog.js", () => ({ @@ -374,6 +377,42 @@ describe("capability cli", () => { }) as never); }); + async function runModelRunWithModel(model: string, transport: "local" | "gateway") { + await runRegisteredCli({ + register: registerCapabilityCli as (program: Command) => void, + argv: [ + "capability", + "model", + "run", + "--model", + model, + "--prompt", + "hello", + ...(transport === "gateway" ? ["--gateway"] : []), + "--json", + ], + }); + } + + function expectModelRunDispatch(transport: "local" | "gateway", modelRef: string) { + if (transport === "gateway") { + const slash = modelRef.indexOf("/"); + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "agent", + params: expect.objectContaining({ + provider: modelRef.slice(0, slash), + model: modelRef.slice(slash + 1), + }), + }), + ); + return; + } + expect(mocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( + expect.objectContaining({ modelRef }), + ); + } + it("lists canonical capabilities", async () => { await runRegisteredCli({ register: registerCapabilityCli as (program: Command) => void, @@ -841,6 +880,50 @@ describe("capability cli", () => { ); }); + it.each(["local", "gateway"] as const)( + "canonicalizes case-only catalog model refs before %s dispatch", + async (transport) => { + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-opus-4-7", provider: "anthropic", name: "Claude Opus 4.7" }, + ] as never); + + await runModelRunWithModel("Anthropic/CLAUDE-OPUS-4-7", transport); + + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ); + expectModelRunDispatch(transport, "anthropic/claude-opus-4-7"); + }, + ); + + it("canonicalizes case-only catalog refs and preserves auth profiles before local dispatch", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-opus-4-7", provider: "anthropic", name: "Claude Opus 4.7" }, + ] as never); + + await runModelRunWithModel("Anthropic/CLAUDE-OPUS-4-7@work", "local"); + + expectModelRunDispatch("local", "anthropic/claude-opus-4-7@work"); + }); + + it("leaves auth profile refs unchanged before gateway dispatch", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([ + { id: "claude-opus-4-7", provider: "anthropic", name: "Claude Opus 4.7" }, + ] as never); + + await runModelRunWithModel("Anthropic/CLAUDE-OPUS-4-7@work", "gateway"); + + expectModelRunDispatch("gateway", "Anthropic/CLAUDE-OPUS-4-7@work"); + }); + + it("preserves custom mixed-case profile refs before local dispatch when the catalog has no match", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([] as never); + + await runModelRunWithModel("custom/MyModel@work", "local"); + + expectModelRunDispatch("local", "custom/MyModel@work"); + }); + it("rejects empty model run prompts before gateway dispatch", async () => { await expect( runRegisteredCli({ diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index 3f8a94270dc..ee84dd17c3a 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -10,8 +10,10 @@ import { loadAuthProfileStoreForRuntime, } from "../agents/auth-profiles.js"; import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import { canonicalizeCaseOnlyCatalogModelRef } from "../agents/model-selection.js"; import { completeWithPreparedSimpleCompletionModel, prepareSimpleCompletionModelForAgent, @@ -559,6 +561,20 @@ function resolveModelRefOverride(raw: string | undefined): { provider?: string; }; } +async function canonicalizeModelRunRef(params: { + raw: string | undefined; + cfg: OpenClawConfig; + preserveAuthProfile: boolean; +}): Promise { + return await canonicalizeCaseOnlyCatalogModelRef({ + cfg: params.cfg, + raw: params.raw, + defaultProvider: DEFAULT_PROVIDER, + loadCatalog: () => loadModelCatalog({ config: params.cfg, readOnly: true }), + preserveAuthProfile: params.preserveAuthProfile, + }); +} + function requireProviderModelOverride( raw: string | undefined, ): { provider: string; model: string } | undefined { @@ -642,6 +658,11 @@ async function runModelRun(params: { }) { const cfg = getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); + const modelRef = await canonicalizeModelRunRef({ + raw: params.model, + cfg, + preserveAuthProfile: params.transport === "local", + }); const imageFiles = await readModelRunImageFiles(params.files); const messageContent = imageFiles.length > 0 @@ -658,7 +679,7 @@ async function runModelRun(params: { const prepared = await prepareSimpleCompletionModelForAgent({ cfg, agentId, - modelRef: params.model, + modelRef, allowMissingApiKeyModes: ["aws-sdk"], skipPiDiscovery: true, }); @@ -731,7 +752,7 @@ async function runModelRun(params: { } satisfies CapabilityEnvelope; } - const { provider, model } = resolveModelRefOverride(params.model); + const { provider, model } = resolveModelRefOverride(modelRef); // Provider/model overrides require trusted-operator scope. Use the backend // shared-secret lane so local gateway smokes do not depend on paired CLI device scopes. const hasModelOverride = Boolean(provider || model); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 355593e7a53..a2812b12b41 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -105,8 +105,8 @@ describe("command secret target ids", () => { expect(scoped.targetIds.size).toBeGreaterThan(0); const targetIds = [...scoped.targetIds]; - expect(targetIds.filter((id) => !id.startsWith("channels.discord."))).toEqual([]); - expect(targetIds.filter((id) => id.startsWith("channels.telegram."))).toEqual([]); + expect(targetIds.every((id) => id.startsWith("channels.discord."))).toBe(true); + expect(targetIds.some((id) => id.startsWith("channels.telegram."))).toBe(false); }); it("does not coerce missing accountId to default when channel is scoped", () => { @@ -128,7 +128,7 @@ describe("command secret target ids", () => { expect(scoped.allowedPaths).toBeUndefined(); expect(scoped.targetIds.size).toBeGreaterThan(0); - expect([...scoped.targetIds].filter((id) => !id.startsWith("channels.discord."))).toEqual([]); + expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); }); it("scopes allowed paths to channel globals + selected account", () => { diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index ac34facaaf8..a3a24e27962 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -215,8 +215,8 @@ describe("config cli integration", () => { const afterDryRun = fs.readFileSync(configPath, "utf8"); expect(afterDryRun).toBe(before); expect(runtime.errors).toEqual([]); - expect(runtime.logs.some((line) => line.includes("Dry run successful: 2 update(s)"))).toBe( - true, + expect(runtime.logs).toEqual( + expect.arrayContaining([expect.stringContaining("Dry run successful: 2 update(s)")]), ); await runConfigSet({ @@ -303,9 +303,11 @@ describe("config cli integration", () => { }; expect(payload.ok).toBe(false); expect(payload.checks?.resolvability).toBe(true); - expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); - expect(payload.errors?.some((entry) => entry.ref?.includes("MISSING_TEST_SECRET"))).toBe( - true, + expect(payload.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "resolvability" })]), + ); + expect(payload.errors?.map((entry) => entry.ref ?? "")).toEqual( + expect.arrayContaining([expect.stringContaining("MISSING_TEST_SECRET")]), ); } finally { envSnapshot.restore(); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index eb68e6b2be5..6b9034ef28e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -673,7 +673,10 @@ describe("config cli", () => { properties?: Record; }; expect(payload.properties?.$schema).toEqual({ type: "string" }); - expect(payload.properties?.channels).toEqual(expect.any(Object)); + expect(payload.properties?.channels).toMatchObject({ + type: "object", + properties: { telegram: { type: "object" } }, + }); expect(payload.properties?.plugins).toBeUndefined(); expect(mockError).not.toHaveBeenCalled(); }); @@ -1972,10 +1975,11 @@ describe("config cli", () => { errors?: Array<{ kind: string; message: string; ref?: string }>; }; expect(payload.ok).toBe(false); - expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); - expect( - payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), - ).toBe(true); + const errorKinds = (payload.errors ?? []).map((entry) => entry.kind); + expect(errorKinds).toContain("resolvability"); + const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? ""); + const discordTokenRefs = errorRefs.filter((ref) => ref.includes("default:DISCORD_BOT_TOKEN")); + expect(discordTokenRefs.length).toBeGreaterThan(0); }); it("keeps distinct resolvability failures when messages are identical but refs differ", async () => { @@ -2048,11 +2052,11 @@ describe("config cli", () => { errors?: Array<{ kind: string; message: string; ref?: string }>; }; expect(payload.ok).toBe(false); - expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true); - expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true); - expect( - payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")), - ).toBe(true); + const errorKinds = (payload.errors ?? []).map((entry) => entry.kind); + expect(errorKinds).toEqual(expect.arrayContaining(["schema", "resolvability"])); + const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? ""); + const discordTokenRefs = errorRefs.filter((ref) => ref.includes("default:DISCORD_BOT_TOKEN")); + expect(discordTokenRefs.length).toBeGreaterThan(0); }); it("fails dry-run when provider updates make existing refs unresolvable", async () => { diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index 7397f68cf1a..40570c68455 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -26,6 +26,11 @@ function createRuntimeLogCapture(): { logs: string[]; runtime: RuntimeEnv } { return { logs, runtime }; } +function expectLogsToInclude(logs: readonly string[], text: string): void { + const matches = logs.filter((line) => line.includes(text)); + expect(matches.length).toBeGreaterThan(0); +} + function createBaseJob(overrides: Partial): CronJob { const now = Date.now(); return { @@ -58,12 +63,11 @@ describe("printCronList", () => { // sessionTarget is intentionally omitted to simulate the bug }); - // This should not throw "Cannot read properties of undefined (reading 'trim')" - expect(() => printCronList([jobWithUndefinedTarget], runtime)).not.toThrow(); + printCronList([jobWithUndefinedTarget], runtime); // Verify output contains the job expect(logs.length).toBeGreaterThan(1); - expect(logs.some((line) => line.includes("test-job-id"))).toBe(true); + expectLogsToInclude(logs, "test-job-id"); }); it("handles job with defined sessionTarget", () => { @@ -74,8 +78,8 @@ describe("printCronList", () => { sessionTarget: "isolated", }); - expect(() => printCronList([jobWithTarget], runtime)).not.toThrow(); - expect(logs.some((line) => line.includes("isolated"))).toBe(true); + printCronList([jobWithTarget], runtime); + expectLogsToInclude(logs, "isolated"); }); it("tolerates malformed rows in human-readable output", () => { @@ -90,8 +94,8 @@ describe("printCronList", () => { state: undefined, } as unknown as CronJob; - expect(() => printCronList([malformedJob], runtime)).not.toThrow(); - expect(logs.some((line) => line.includes("malformed-job"))).toBe(true); + printCronList([malformedJob], runtime); + expectLogsToInclude(logs, "malformed-job"); }); it("shows stagger label for cron schedules", () => { @@ -106,7 +110,7 @@ describe("printCronList", () => { }); printCronList([job], runtime); - expect(logs.some((line) => line.includes("(stagger 5m)"))).toBe(true); + expectLogsToInclude(logs, "(stagger 5m)"); }); it("shows dash for unset agentId instead of default", () => { @@ -224,7 +228,7 @@ describe("printCronList", () => { }); printCronList([job], runtime); - expect(logs.some((line) => line.includes("(exact)"))).toBe(true); + expectLogsToInclude(logs, "(exact)"); }); }); diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 968113ace74..e9cffc0139b 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -28,6 +28,21 @@ const inspectPortUsage = vi.fn(async (port: number) => ({ listeners: [], hints: [], })); + +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + const buildGatewayInstallPlan = vi.fn( async (params: { port: number; @@ -325,7 +340,12 @@ describe("daemon-cli coverage", () => { expect(serviceStop).toHaveBeenCalledTimes(1); const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); - expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); - expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); + expect( + collectMatching( + parsed, + (entry) => Boolean(entry.ok), + (entry) => entry.action, + ), + ).toEqual(expect.arrayContaining(["start", "stop"])); }); }); diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 30e64766434..7bf325cc84c 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -312,7 +312,9 @@ describe("runDaemonInstall", () => { ); expectFirstInstallPlanCallOmitsToken(); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); - expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); + expect(actionState.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("Auto-generated")]), + ); }); it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => { diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index 4338f10920d..9ce7fa7738f 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -426,8 +426,12 @@ describe("logs cli", () => { const messages = noticeRecords .filter((record) => record.type === "notice") .map((record) => record.message ?? ""); - expect(messages.some((message) => message.includes("gateway disconnected"))).toBe(true); - expect(messages.some((message) => message.includes("gateway reconnected"))).toBe(true); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining("gateway disconnected")]), + ); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining("gateway reconnected")]), + ); expect(stdoutWrites.join("")).toContain('"type":"meta"'); expect(exitSpy).toHaveBeenCalledWith(1); }); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 527322bcdf4..b12ed4a855f 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -475,8 +475,10 @@ describe("plugins cli install", () => { nextConfig: enabledCfg, }), ); - expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); - expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); + expect(runtimeLogs).toEqual(expect.arrayContaining([expect.stringContaining("slot adjusted")])); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed plugin: alpha")]), + ); }); it("passes force through as overwrite mode for marketplace installs", async () => { @@ -539,7 +541,9 @@ describe("plugins cli install", () => { }), }); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); - expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed plugin: demo")]), + ); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); @@ -618,7 +622,9 @@ describe("plugins cli install", () => { }); expect(enablePluginInConfig).not.toHaveBeenCalled(); expect(applyExclusiveSlotSelection).not.toHaveBeenCalled(); - expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("requires configuration first")]), + ); }); it("enables config-gated bundled installs when provider-backed config is explicit", async () => { @@ -656,7 +662,9 @@ describe("plugins cli install", () => { expect(enablePluginInConfig).toHaveBeenCalled(); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); - expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(false); + expect(runtimeLogs).not.toEqual( + expect.arrayContaining([expect.stringContaining("requires configuration first")]), + ); }); it("passes force through as overwrite mode for ClawHub installs", async () => { @@ -1807,7 +1815,9 @@ describe("plugins cli install", () => { path: localHookDir, }), ); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed hook pack: demo-hooks")]), + ); }); it("still falls back to npm hook pack when dangerous force unsafe install is set for non-security errors", async () => { @@ -1862,7 +1872,9 @@ describe("plugins cli install", () => { spec: "@acme/demo-hooks", }), ); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed hook pack: demo-hooks")]), + ); }); it("does not fall back to npm when explicit ClawHub rejects a real package", async () => { @@ -1899,7 +1911,9 @@ describe("plugins cli install", () => { }), ); expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); - expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Installed hook pack: demo-hooks")]), + ); }); it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => { diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index a2b86bc3e38..5701656dcca 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -41,6 +41,23 @@ describe("plugins cli policy mutations", () => { }); } + function requireFirstWrittenConfig(): OpenClawConfig { + const [config] = writeConfigFile.mock.calls[0] ?? []; + if (!config) { + throw new Error("expected writeConfigFile to receive a config"); + } + return config; + } + + function requirePluginEntries( + config: OpenClawConfig, + ): NonNullable["entries"]> { + if (!config.plugins?.entries) { + throw new Error("expected plugin entries in config"); + } + return config.plugins.entries; + } + it("refreshes the persisted plugin registry after enabling a plugin", async () => { const sourceConfig = {} as OpenClawConfig; const enabledConfig = { @@ -103,8 +120,9 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", "alpha"]); - const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; - expect(nextConfig.plugins?.entries?.alpha?.enabled).toBe(false); + const nextConfig = requireFirstWrittenConfig(); + const entries = requirePluginEntries(nextConfig); + expect(entries.alpha).toMatchObject({ enabled: false }); expect(refreshPluginRegistry).toHaveBeenCalledWith({ config: nextConfig, installRecords: {}, @@ -154,9 +172,10 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", alias]); - const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; - expect(nextConfig.plugins?.entries?.[pluginId]?.enabled).toBe(false); - expect(nextConfig.plugins?.entries?.[alias]).toBeUndefined(); + const nextConfig = requireFirstWrittenConfig(); + const entries = requirePluginEntries(nextConfig); + expect(entries[pluginId]).toMatchObject({ enabled: false }); + expect(entries[alias]).toBeUndefined(); }, ); @@ -184,8 +203,9 @@ describe("plugins cli policy mutations", () => { await runPluginsCommand(["plugins", "disable", "twitch"]); - const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; - expect(nextConfig.plugins?.entries?.twitch?.enabled).toBe(false); + const nextConfig = requireFirstWrittenConfig(); + const entries = requirePluginEntries(nextConfig); + expect(entries.twitch).toMatchObject({ enabled: false }); expect(nextConfig.channels?.twitch).toBeUndefined(); }); }); diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 54bd569a420..7353c514b61 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -104,8 +104,12 @@ describe("plugins cli uninstall", () => { expect(planPluginUninstall).toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); - expect(runtimeLogs.some((line) => line.includes("context engine slot"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Dry run, no changes made.")]), + ); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("context engine slot")]), + ); }); it("uninstalls with --force and --keep-files without prompting", async () => { @@ -515,7 +519,9 @@ describe("plugins cli uninstall", () => { ); expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({}); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - expect(runtimeLogs.some((line) => line.includes("channel config (channels.alpha)"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("channel config (channels.alpha)")]), + ); expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"'); }); diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 17725c892b1..bfd8d011e4c 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -140,9 +140,11 @@ describe("plugins cli update", () => { ); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect( - runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), - ).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([ + expect.stringContaining("Restart the gateway to load plugins and hooks."), + ]), + ); }); it("exits when update is called without id and without --all", async () => { @@ -253,9 +255,11 @@ describe("plugins cli update", () => { installRecords: nextConfig.plugins?.installs, reason: "source-changed", }); - expect( - runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), - ).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([ + expect.stringContaining("Restart the gateway to load plugins and hooks."), + ]), + ); }); it("exits non-zero when a plugin update reports an error after persisting successes", async () => { diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 6c7ce2d54bf..9348e76690f 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -123,9 +123,9 @@ describe("persistPluginInstall", () => { expect(next).toEqual(enabledConfig); expect(refreshPluginRegistry).toHaveBeenCalled(); - expect( - runtimeLogs.some((line) => line.includes("Plugin runtime cache invalidation failed")), - ).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Plugin runtime cache invalidation failed")]), + ); }); it("removes a replaced managed install directory before refreshing the registry", async () => { @@ -388,7 +388,9 @@ describe("persistPluginInstall", () => { expect(next).toEqual(enabledConfig); expect(refreshPluginRegistry).toHaveBeenCalled(); expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1); - expect(runtimeLogs.some((line) => line.includes("Plugin registry refresh failed"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Plugin registry refresh failed")]), + ); }); it("removes stale denylist entries before enabling installed plugins", async () => { diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index cea3413356e..8d7c963de3d 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -74,7 +74,10 @@ describe("cli program (nodes media)", () => { runtime.error.mockClear(); await expect(parseProgram.parseAsync(args, { from: "user" })).rejects.toThrow(/exit/i); - expect(runtime.error.mock.calls.some(([msg]) => expectedError.test(String(msg)))).toBe(true); + const matchingErrors = runtime.error.mock.calls + .map(([msg]) => String(msg)) + .filter((msg) => expectedError.test(msg)); + expect(matchingErrors.length).toBeGreaterThan(0); } async function runAndExpectUrlPayloadMediaFile(params: { diff --git a/src/cli/program/build-program.version-alias.test.ts b/src/cli/program/build-program.version-alias.test.ts index 98ff282beb5..957962701ee 100644 --- a/src/cli/program/build-program.version-alias.test.ts +++ b/src/cli/program/build-program.version-alias.test.ts @@ -32,7 +32,7 @@ describe("buildProgram version alias handling", () => { throw new Error(`unexpected process.exit:${String(code)}`); }) as typeof process.exit); - expect(() => buildProgram()).not.toThrow(); + buildProgram(); expect(exitSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index c6c2bbc06d2..231d1f09dd9 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -173,8 +173,10 @@ describe("command-registry", () => { } const names = namesOf(program); - expect(names.filter((name) => name === "commitments")).toHaveLength(1); - expect(names.filter((name) => name === "tasks")).toHaveLength(1); + const countName = (target: string) => + names.reduce((count, name) => count + (name === target ? 1 : 0), 0); + expect(countName("commitments")).toBe(1); + expect(countName("tasks")).toBe(1); }); it("replaces placeholders when loading a grouped entry by secondary command name", async () => { diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 601975fa741..9cea56efd08 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -192,7 +192,7 @@ describe("registerSubCliCommands", () => { await registerSubCliByName(program, "acp"); const names = program.commands.map((cmd) => cmd.name()); - expect(names.filter((name) => name === "acp")).toHaveLength(1); + expect(names.reduce((count, name) => count + (name === "acp" ? 1 : 0), 0)).toBe(1); await program.parseAsync(["acp"], { from: "user" }); expect(registerAcpCli).toHaveBeenCalledTimes(1); diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 4054ac072ed..1ae2265e4a4 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -59,46 +59,51 @@ describe("program routes", () => { vi.clearAllMocks(); }); - function expectRoute(path: string[]) { - const route = findRoutedCommand(path); - expect(route).not.toBeNull(); + type ProgramRoute = NonNullable>; + + function expectRoute(path: string[], argv?: string[]): ProgramRoute { + const route = findRoutedCommand(path, argv); + expect(route).toEqual(expect.objectContaining({ run: expect.any(Function) })); + if (route === null) { + throw new Error(`Expected routed command for ${path.join(" ")}`); + } return route; } async function expectRunFalse(path: string[], argv: string[]) { const route = expectRoute(path); - await expect(route?.run(argv)).resolves.toBe(false); + await expect(route.run(argv)).resolves.toBe(false); } it("matches status route without plugin preload", () => { const route = expectRoute(["status"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route.loadPlugins).toBeUndefined(); }); it("matches health route without plugin preload", () => { const route = expectRoute(["health"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route.loadPlugins).toBeUndefined(); }); it("matches channel read-only routes without plugin preload", () => { - expect(expectRoute(["channels", "list"])?.loadPlugins).toBeUndefined(); - expect(expectRoute(["channels", "status"])?.loadPlugins).toBeUndefined(); + expect(expectRoute(["channels", "list"]).loadPlugins).toBeUndefined(); + expect(expectRoute(["channels", "status"]).loadPlugins).toBeUndefined(); }); it("matches agents read-only routes without plugin preload", () => { - expect(expectRoute(["agents"])?.loadPlugins).toBeUndefined(); - expect(expectRoute(["agents", "list"])?.loadPlugins).toBeUndefined(); + expect(expectRoute(["agents"]).loadPlugins).toBeUndefined(); + expect(expectRoute(["agents", "list"]).loadPlugins).toBeUndefined(); }); it("passes parsed agents list flags through", async () => { - await expect(expectRoute(["agents"])?.run(["node", "openclaw", "agents"])).resolves.toBe(true); + await expect(expectRoute(["agents"]).run(["node", "openclaw", "agents"])).resolves.toBe(true); expect(agentsListCommandMock).toHaveBeenCalledWith( { json: false, bindings: false }, expect.any(Object), ); await expect( - expectRoute(["agents", "list"])?.run([ + expectRoute(["agents", "list"]).run([ "node", "openclaw", "agents", @@ -115,7 +120,7 @@ describe("program routes", () => { it("passes parsed channel read-only route flags through", async () => { const listRoute = expectRoute(["channels", "list"]); - await expect(listRoute?.run(["node", "openclaw", "channels", "list", "--json"])).resolves.toBe( + await expect(listRoute.run(["node", "openclaw", "channels", "list", "--json"])).resolves.toBe( true, ); expect(channelsListCommandMock).toHaveBeenCalledWith( @@ -125,7 +130,7 @@ describe("program routes", () => { const statusRoute = expectRoute(["channels", "status"]); await expect( - statusRoute?.run([ + statusRoute.run([ "node", "openclaw", "channels", @@ -144,7 +149,7 @@ describe("program routes", () => { it("matches gateway status route without plugin preload", () => { const route = expectRoute(["gateway", "status"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route.loadPlugins).toBeUndefined(); }); it("returns false for gateway status route when option values are missing", async () => { @@ -181,7 +186,7 @@ describe("program routes", () => { it("passes parsed gateway status flags through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "--profile", @@ -217,7 +222,7 @@ describe("program routes", () => { it("passes --no-probe through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); - await expect(route?.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( + await expect(route.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( true, ); @@ -242,16 +247,7 @@ describe("program routes", () => { it("routes status --json through the lean JSON command", async () => { const route = expectRoute(["status"]); await expect( - route?.run([ - "node", - "openclaw", - "status", - "--json", - "--deep", - "--usage", - "--timeout", - "5000", - ]), + route.run(["node", "openclaw", "status", "--json", "--deep", "--usage", "--timeout", "5000"]), ).resolves.toBe(true); expect(statusJsonCommandMock).toHaveBeenCalledWith( { deep: true, all: false, usage: true, timeoutMs: 5000 }, @@ -290,7 +286,7 @@ describe("program routes", () => { it("passes config get path correctly when root option values precede command", async () => { const route = expectRoute(["config", "get"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "--log-level", @@ -307,7 +303,7 @@ describe("program routes", () => { it("passes config unset path correctly when root option values precede command", async () => { const route = expectRoute(["config", "unset"]); await expect( - route?.run(["node", "openclaw", "--profile", "work", "config", "unset", "update.channel"]), + route.run(["node", "openclaw", "--profile", "work", "config", "unset", "update.channel"]), ).resolves.toBe(true); expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" }); }); @@ -315,7 +311,7 @@ describe("program routes", () => { it("passes config get path when root value options appear after subcommand", async () => { const route = expectRoute(["config", "get"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "config", @@ -332,7 +328,7 @@ describe("program routes", () => { it("passes config unset path when root value options appear after subcommand", async () => { const route = expectRoute(["config", "unset"]); await expect( - route?.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]), + route.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]), ).resolves.toBe(true); expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" }); }); @@ -381,7 +377,7 @@ describe("program routes", () => { it("accepts negative-number probe profile values", async () => { const route = expectRoute(["models", "status"]); await expect( - route?.run([ + route.run([ "node", "openclaw", "models", @@ -415,10 +411,10 @@ describe("program routes", () => { it("routes tasks list JSON through the lean task JSON command", async () => { const rootRoute = expectRoute(["tasks"]); - expect(rootRoute?.loadPlugins).toBeUndefined(); - expect(rootRoute?.canRun?.(["node", "openclaw", "tasks"])).toBe(false); + expect(rootRoute.loadPlugins).toBeUndefined(); + expect(rootRoute.canRun?.(["node", "openclaw", "tasks"])).toBe(false); await expect( - rootRoute?.run([ + rootRoute.run([ "node", "openclaw", "tasks", @@ -434,9 +430,9 @@ describe("program routes", () => { ); const listRoute = expectRoute(["tasks", "list"]); - expect(listRoute?.loadPlugins).toBeUndefined(); + expect(listRoute.loadPlugins).toBeUndefined(); await expect( - listRoute?.run(["node", "openclaw", "tasks", "list", "--json", "--runtime=cron"]), + listRoute.run(["node", "openclaw", "tasks", "list", "--json", "--runtime=cron"]), ).resolves.toBe(true); expect(tasksListJsonCommandMock).toHaveBeenLastCalledWith( { json: true, runtime: "cron", status: undefined }, @@ -455,9 +451,8 @@ describe("program routes", () => { "--status", "running", ]; - const separateValueRoute = findRoutedCommand(["tasks", "cli"], separateValueArgv); - expect(separateValueRoute).not.toBeNull(); - await expect(separateValueRoute?.run(separateValueArgv)).resolves.toBe(true); + const separateValueRoute = expectRoute(["tasks", "cli"], separateValueArgv); + await expect(separateValueRoute.run(separateValueArgv)).resolves.toBe(true); expect(tasksListJsonCommandMock).toHaveBeenCalledWith( { json: true, runtime: "cli", status: "running" }, expect.any(Object), @@ -472,13 +467,12 @@ describe("program routes", () => { "list", "--json", ]; - const parentOptionBeforeSubcommandRoute = findRoutedCommand( + const parentOptionBeforeSubcommandRoute = expectRoute( ["tasks", "cli"], parentOptionBeforeSubcommandArgv, ); - expect(parentOptionBeforeSubcommandRoute).not.toBeNull(); await expect( - parentOptionBeforeSubcommandRoute?.run(parentOptionBeforeSubcommandArgv), + parentOptionBeforeSubcommandRoute.run(parentOptionBeforeSubcommandArgv), ).resolves.toBe(true); expect(tasksListJsonCommandMock).toHaveBeenLastCalledWith( { json: true, runtime: "cli", status: undefined }, @@ -488,10 +482,10 @@ describe("program routes", () => { it("routes tasks audit JSON through the lean task JSON command", async () => { const route = expectRoute(["tasks", "audit"]); - expect(route?.loadPlugins).toBeUndefined(); - expect(route?.canRun?.(["node", "openclaw", "tasks", "audit"])).toBe(false); + expect(route.loadPlugins).toBeUndefined(); + expect(route.canRun?.(["node", "openclaw", "tasks", "audit"])).toBe(false); await expect( - route?.run([ + route.run([ "node", "openclaw", "tasks", diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index e1784420b83..04f66fa110b 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -464,6 +464,7 @@ describe("proxy cli runtime", () => { const { runDebugProxyRunCommand } = await import("./proxy-cli.runtime.js"); const { getDebugProxyCaptureStore } = await import("../proxy-capture/store.sqlite.js"); + const beforeRun = Date.now(); await expect( runDebugProxyRunCommand({ commandArgs: ["does-not-exist"], @@ -478,6 +479,6 @@ describe("proxy cli runtime", () => { ); const [session] = store.listSessions(5); expect(session?.mode).toBe("proxy-run"); - expect(session?.endedAt).toEqual(expect.any(Number)); + expect(session?.endedAt).toBeGreaterThanOrEqual(beforeRun); }); }); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 53f8d2ff969..09b81a5f028 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -582,7 +582,12 @@ describe("runCli exit behavior", () => { try { const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]); await vi.waitFor(() => { - expect(processOnceSpy.mock.calls.filter(([event]) => event === "exit")).toHaveLength(2); + expect( + processOnceSpy.mock.calls.reduce( + (count, [event]) => count + (event === "exit" ? 1 : 0), + 0, + ), + ).toBe(2); }); const exitHandler = processOnceSpy.mock.calls.find(([event]) => event === "exit")?.[1]; @@ -800,7 +805,7 @@ describe("runCli exit behavior", () => { const hostUnreachable = Object.assign(new Error("connect EHOSTUNREACH 149.154.167.220:443"), { code: "EHOSTUNREACH", }); - expect(() => handler(hostUnreachable)).not.toThrow(); + expect(handler(hostUnreachable)).toBeUndefined(); expect(consoleWarnSpy).toHaveBeenCalledWith( "[openclaw] Non-fatal uncaught exception (continuing):", expect.stringContaining("EHOSTUNREACH"), diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index c1e32ecb360..5165acd0722 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -363,7 +363,6 @@ describe("resolveMissingPluginCommandMessage", () => { }, { registry: memoryWikiCommandAliasRegistry }, ); - expect(message).not.toBeNull(); expect(message).toContain('"memory-wiki"'); expect(message).toContain("plugins.allow"); }); diff --git a/src/cli/secrets-cli.test.ts b/src/cli/secrets-cli.test.ts index a6e8d7cf6d3..25db25d1dd8 100644 --- a/src/cli/secrets-cli.test.ts +++ b/src/cli/secrets-cli.test.ts @@ -328,9 +328,10 @@ describe("secrets CLI", () => { await createProgram().parseAsync(["secrets", "apply", "--from", planPath, "--dry-run"], { from: "user", }); - expect(runtimeLogs.some((line) => line.includes("Secrets apply dry-run note: skipped"))).toBe( - false, + const skippedExecNotes = runtimeLogs.filter((line) => + line.includes("Secrets apply dry-run note: skipped"), ); + expect(skippedExecNotes).toEqual([]); }); }); @@ -341,7 +342,10 @@ describe("secrets CLI", () => { confirm.mockResolvedValue(false); await createProgram().parseAsync(["secrets", "configure"], { from: "user" }); - expect(runtimeLogs.some((line) => line.includes("Preflight note: skipped"))).toBe(false); + const preflightSkippedExecNotes = runtimeLogs.filter((line) => + line.includes("Preflight note: skipped"), + ); + expect(preflightSkippedExecNotes).toEqual([]); }); it("forwards --allow-exec to configure preflight and apply", async () => { diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 2829f8a418e..27614cfe231 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -220,7 +220,9 @@ describe("skills cli commands", () => { query: "calendar", limit: undefined, }); - expect(runtimeLogs.some((line) => line.includes("calendar v1.2.3 Calendar"))).toBe(true); + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("calendar v1.2.3 Calendar")]), + ); }); it("installs a skill from ClawHub into the active workspace", async () => { @@ -335,8 +337,8 @@ describe("skills cli commands", () => { slug: undefined, logger: expect.any(Object), }); - expect(runtimeLogs.some((line) => line.includes("Updated calendar: 1.2.2 -> 1.2.3"))).toBe( - true, + expect(runtimeLogs).toEqual( + expect.arrayContaining([expect.stringContaining("Updated calendar: 1.2.2 -> 1.2.3")]), ); expect(runtimeErrors).toEqual([]); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a8f7d4dc75d..4a3a33b3870 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -646,9 +646,11 @@ describe("update-cli", () => { await updateCliShared.tryWriteCompletionCache(root, false); const logs = vi.mocked(runtimeCapture.log).mock.calls.map((call) => String(call[0])); - expect(logs.some((line) => line.includes("timed out after 30s"))).toBe(true); - expect(logs.some((line) => line.includes("openclaw completion --write-state"))).toBe(true); - expect(logs.some((line) => line.includes("Error: spawnSync"))).toBe(false); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("timed out after 30s")])); + expect(logs).toEqual( + expect.arrayContaining([expect.stringContaining("openclaw completion --write-state")]), + ); + expect(logs).not.toEqual(expect.arrayContaining([expect.stringContaining("Error: spawnSync")])); }); it("respawns into the updated package root before running post-update tasks", async () => { @@ -1427,7 +1429,7 @@ describe("update-cli", () => { await updateCommand({ dryRun: true }); }, assert: () => { - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); expect(runGatewayUpdate).not.toHaveBeenCalled(); }, }, @@ -2967,8 +2969,8 @@ describe("update-cli", () => { }, assert: () => { const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( - false, + expect(logLines).not.toEqual( + expect.arrayContaining([expect.stringContaining("Daemon restarted successfully.")]), ); }, }, @@ -3296,9 +3298,11 @@ describe("update-cli", () => { .mocked(defaultRuntime.error) .mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required.")); expect(downgradeMessageSeen).toBe(shouldExit); - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe( - shouldExit, - ); + if (shouldExit) { + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + } else { + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + } expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(false); expect( vi diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index e0343f34538..3d37203cf89 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -87,6 +87,14 @@ function mockLocalAgentReply(text = "local") { }); } +function requireFirstCallArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown { + const [arg] = mock.mock.calls[0] ?? []; + if (arg === undefined) { + throw new Error(`expected ${label} call`); + } + return arg; +} + function createGatewayTimeoutError() { const err = new Error("gateway timeout after 90000ms"); err.name = "GatewayTransportError"; @@ -142,7 +150,7 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555", timeout: "0" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - const request = callGateway.mock.calls[0]?.[0] as { timeoutMs?: number }; + const request = requireFirstCallArg(callGateway, "gateway") as { timeoutMs?: number }; expect(request.timeoutMs).toBe(2_147_000_000); }); }); @@ -154,7 +162,10 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - expect(callGateway.mock.calls[0]?.[0]?.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd"); + const request = requireFirstCallArg(callGateway, "gateway") as { + params?: Record; + }; + expect(request.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd"); expect(agentCommand).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("hello"); }); @@ -203,7 +214,8 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555", model: "ollama/qwen3.5:9b" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - expect(callGateway.mock.calls[0]?.[0]).toMatchObject({ + const request = requireFirstCallArg(callGateway, "gateway"); + expect(request).toMatchObject({ params: { model: "ollama/qwen3.5:9b", }, @@ -242,7 +254,8 @@ describe("agentCliCommand", () => { expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(fallbackOpts).toMatchObject({ resultMetaOverrides: { transport: "embedded", fallbackFrom: "gateway", @@ -290,7 +303,7 @@ describe("agentCliCommand", () => { expect(callGateway).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1); - const fallbackOpts = agentCommand.mock.calls[0]?.[0] as { + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { sessionId?: string; sessionKey?: string; runId?: string; @@ -329,7 +342,7 @@ describe("agentCliCommand", () => { runtime, ); - const fallbackOpts = agentCommand.mock.calls[0]?.[0] as { + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { sessionId?: string; sessionKey?: string; to?: string; @@ -375,7 +388,8 @@ describe("agentCliCommand", () => { const result = await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(fallbackOpts).toMatchObject({ resultMetaOverrides: { transport: "embedded", fallbackFrom: "gateway", @@ -386,7 +400,8 @@ describe("agentCliCommand", () => { ); expect(loggingState.forceConsoleToStderr).toBe(true); expect(jsonRuntime.log).toHaveBeenCalledTimes(1); - const payload = JSON.parse(String(jsonRuntime.log.mock.calls[0]?.[0])); + const jsonPayload = requireFirstCallArg(jsonRuntime.log, "json runtime log"); + const payload = JSON.parse(String(jsonPayload)); expect(payload).toMatchObject({ payloads: [{ text: "local" }], meta: { @@ -420,11 +435,12 @@ describe("agentCliCommand", () => { expect(callGateway).not.toHaveBeenCalled(); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const localOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(localOpts).toMatchObject({ cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, }); - expect(agentCommand.mock.calls[0]?.[0]).not.toHaveProperty("resultMetaOverrides"); + expect(localOpts).not.toHaveProperty("resultMetaOverrides"); expect(runtime.log).toHaveBeenCalledWith("local"); }); }); @@ -437,7 +453,8 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(agentCommand).toHaveBeenCalledTimes(1); - expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent"); + expect(fallbackOpts).toMatchObject({ cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, }); diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index 01a2823ff51..5da295c83cf 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -374,7 +374,7 @@ describe("agentCommand ACP runtime routing", () => { { text: "bo", delta: "bo" }, { text: "book", delta: "ok" }, ]); - expect(repeated.logLines.some((line) => line.includes("book"))).toBe(true); + expect(repeated.logLines).toEqual(expect.arrayContaining([expect.stringContaining("book")])); }); }); @@ -382,8 +382,8 @@ describe("agentCommand ACP runtime routing", () => { await withAcpSessionEnv(async () => { const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents(["NO_REPLY"]); - expect(assistantEvents.map((event) => event.text).filter(Boolean)).toEqual([]); - expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false); + expect(assistantEvents.every((event) => !event.text)).toBe(true); + expect(logLines).not.toEqual(expect.arrayContaining([expect.stringContaining("NO_REPLY")])); expect(logLines).toEqual([]); }); }); diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index 9aa9a5df94b..c622c0a861c 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -207,13 +207,13 @@ describe("agentCommand runtime config", () => { const resolved = resolveSession({ cfg, to: "+1555" }); expect(resolved.storePath).toBe(store); - expect(resolved.sessionKey).toEqual(expect.any(String)); + expect(resolved.sessionKey).toBeTypeOf("string"); const sessionKey = resolved.sessionKey; if (!sessionKey) { throw new Error("expected session key"); } expect(sessionKey.length).toBeGreaterThan(0); - expect(resolved.sessionId).toEqual(expect.any(String)); + expect(resolved.sessionId).toBeTypeOf("string"); expect(resolved.sessionId.length).toBeGreaterThan(0); expect(resolved.isNewSession).toBe(true); }); diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 6827c317e8a..57a9c2fe2e3 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -276,8 +276,7 @@ describe("agents delete command", () => { await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); // Workspace should still exist — it was shared - const stat = await fs.stat(sharedWorkspace).catch(() => null); - expect(stat).not.toBeNull(); + await expect(fs.stat(sharedWorkspace)).resolves.toEqual(expect.any(Object)); // The JSON output should report why the workspace was retained. const jsonOutput = readJsonLogs(); diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index 15d1f1469ca..e5ef4d8bc93 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -43,8 +43,12 @@ async function writeIdentityFile(workspace: string, lines: string[]) { } function getWrittenMainIdentity() { - const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as ConfigWritePayload; - return written.agents?.list?.find((entry) => entry.id === "main")?.identity; + const [written] = configMocks.writeConfigFile.mock.calls[0] ?? []; + if (!written) { + throw new Error("expected written agent config"); + } + const payload = written as ConfigWritePayload; + return payload.agents?.list?.find((entry) => entry.id === "main")?.identity; } async function runIdentityCommandFromWorkspace(workspace: string, fromIdentity = true) { diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index fef97a24821..8b7842d9bb3 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -88,12 +88,19 @@ describe("backup commands", () => { plan: Awaited>, ) { expect(plan.included).toHaveLength(1); - expect(plan.included[0]?.kind).toBe("state"); + const [included] = plan.included; + expect(included).toMatchObject({ kind: "state" }); expect(plan.skipped).toEqual( expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), ); } + function expectOnlyAssetKind(assets: Array<{ kind: string }>, kind: string) { + expect(assets).toHaveLength(1); + const [asset] = assets; + expect(asset).toMatchObject({ kind }); + } + it("collapses default config, credentials, and workspace into the state backup root", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); @@ -210,8 +217,11 @@ describe("backup commands", () => { expect(result.archivePath).toBe( path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); - expect(capturedManifest).not.toBeNull(); - expect(capturedOnWriteEntry).not.toBeNull(); + expect(capturedManifest).toEqual(expect.objectContaining({ assets: expect.any(Array) })); + expect(capturedOnWriteEntry).toEqual(expect.any(Function)); + if (capturedManifest === null || capturedOnWriteEntry === null) { + throw new Error("Expected backup manifest and archive entry callback"); + } const manifest = capturedManifest as unknown as { assets: Array<{ kind: string; archivePath: string }>; }; @@ -226,8 +236,10 @@ describe("backup commands", () => { const stateAsset = result.assets.find((asset) => asset.kind === "state"); const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace"); + expect(stateAsset).toBeDefined(); + expect(workspaceAsset).toBeDefined(); if (!stateAsset || !workspaceAsset) { - throw new Error("expected state and workspace backup assets"); + throw new Error("Expected backup assets to include state and workspace entries."); } expect(capturedEntryPaths).toHaveLength(result.assets.length + 1); @@ -391,8 +403,7 @@ describe("backup commands", () => { dryRun: true, onlyConfig: true, }); - expect(configOnly.assets).toHaveLength(1); - expect(configOnly.assets[0]?.kind).toBe("config"); + expectOnlyAssetKind(configOnly.assets, "config"); }); }); @@ -425,7 +436,6 @@ describe("backup commands", () => { expect(result.onlyConfig).toBe(true); expect(result.includeWorkspace).toBe(false); - expect(result.assets).toHaveLength(1); - expect(result.assets[0]?.kind).toBe("config"); + expectOnlyAssetKind(result.assets, "config"); }); }); diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 00c3ec57c0f..604c6e67941 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -339,7 +339,11 @@ describe("channels command", () => { // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe written config shape. function getWrittenConfig(): T { expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); - return configMocks.writeConfigFile.mock.calls[0]?.[0] as T; + const [config] = configMocks.writeConfigFile.mock.calls[0] ?? []; + if (config === undefined) { + throw new Error("expected written channel config"); + } + return config as T; } async function runRemoveWithConfirm( diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 8c9f2055207..81b7a0550d3 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -45,6 +45,14 @@ describe("requireValidConfigSnapshot", () => { }; } + function requireFirstLog(runtime: ReturnType): string { + const [message] = runtime.log.mock.calls[0] ?? []; + if (message === undefined) { + throw new Error("expected runtime log message"); + } + return String(message); + } + it("returns config without emitting compatibility advice by default", async () => { createValidSnapshot(); const runtime = createRuntime(); @@ -69,10 +77,9 @@ describe("requireValidConfigSnapshot", () => { expect(config).toEqual({ plugins: {} }); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); - expect(String(runtime.log.mock.calls[0]?.[0])).toContain("Plugin compatibility: 1 notice."); - expect(String(runtime.log.mock.calls[0]?.[0])).toContain( - "legacy-plugin still uses legacy before_agent_start", - ); + const logMessage = requireFirstLog(runtime); + expect(logMessage).toContain("Plugin compatibility: 1 notice."); + expect(logMessage).toContain("legacy-plugin still uses legacy before_agent_start"); }); it("blocks invalid config before emitting compatibility advice", async () => { diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts index 1a8144fc8ae..2cdff2563bd 100644 --- a/src/commands/configure.gateway.test.ts +++ b/src/commands/configure.gateway.test.ts @@ -83,7 +83,10 @@ async function runGatewayPrompt(params: { ); const result = await promptGatewayConfig(params.baseConfig ?? {}, makeRuntime()); - const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0]; + const [call] = mocks.buildGatewayAuthConfig.mock.calls[0] ?? []; + if (!call) { + throw new Error("expected gateway auth config input"); + } return { result, call }; } @@ -116,8 +119,8 @@ describe("promptGatewayConfig", () => { randomToken: "unused", authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }), }); - expect(call?.password).not.toBe("undefined"); - expect(call?.password).toBe(""); + expect(call.password).not.toBe("undefined"); + expect(call.password).toBe(""); }); it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => { @@ -131,8 +134,8 @@ describe("promptGatewayConfig", () => { ], }); - expect(call?.mode).toBe("trusted-proxy"); - expect(call?.trustedProxy).toEqual({ + expect(call.mode).toBe("trusted-proxy"); + expect(call.trustedProxy).toEqual({ userHeader: "x-forwarded-user", requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], allowUsers: ["nick@example.com"], @@ -146,8 +149,8 @@ describe("promptGatewayConfig", () => { textQueue: ["18789", "x-remote-user", "", "", "10.0.0.1"], }); - expect(call?.mode).toBe("trusted-proxy"); - expect(call?.trustedProxy).toEqual({ + expect(call.mode).toBe("trusted-proxy"); + expect(call.trustedProxy).toEqual({ userHeader: "x-remote-user", // requiredHeaders and allowUsers should be undefined when empty }); @@ -249,7 +252,7 @@ describe("promptGatewayConfig", () => { authConfigFactory: ({ mode, token }) => ({ mode, token }), }); - expect(call?.token).toEqual({ + expect(call.token).toEqual({ source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN", diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 70f81cdc553..9bc4e76954f 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -340,7 +340,7 @@ describe("runConfigureWizard", () => { expect(mocks.setupSearch).toHaveBeenCalledOnce(); }); - it("does not crash when web search providers are unavailable under plugin policy", async () => { + it("notes unavailable web search providers under plugin policy", async () => { setupBaseWizardState(); mocks.resolveSearchProviderOptions.mockReturnValue([]); queueWizardPrompts({ diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index d069a9078b1..91fe202329e 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -931,7 +931,7 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { serviceEnvironment: { HOME: "/from-service", OPENCLAW_PORT: "3000", - PATH: "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + PATH: "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", TMPDIR: "/tmp", }, }); @@ -953,7 +953,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }, }); - expect(plan.environment.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + expect(plan.environment.PATH).toBe( + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ); }); it("drops legacy inline env values when the key is now managed by .env", async () => { diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 7d680ab3183..2b40d6531ef 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -50,6 +50,13 @@ function makePrompter(confirmValue: boolean): DoctorPrompter { }; } +function requireAuthConfig(config: OpenClawConfig): NonNullable { + if (!config.auth) { + throw new Error("expected repaired auth config"); + } + return config.auth; +} + beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); @@ -144,13 +151,14 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { provider: "anthropic", legacyProfileId: "anthropic:default", }); - expect(next.auth?.profiles?.["anthropic:default"]).toBeUndefined(); - expect(next.auth?.profiles?.["anthropic:user@example.com"]).toMatchObject({ + const auth = requireAuthConfig(next); + expect(auth.profiles?.["anthropic:default"]).toBeUndefined(); + expect(auth.profiles?.["anthropic:user@example.com"]).toMatchObject({ provider: "anthropic", mode: "oauth", email: "user@example.com", }); - expect(next.auth?.order?.anthropic).toEqual(["anthropic:user@example.com"]); + expect(auth.order?.anthropic).toEqual(["anthropic:user@example.com"]); }); it("strips provider-controlled terminal escapes from repair prompts", async () => { diff --git a/src/commands/doctor-browser.facade.test.ts b/src/commands/doctor-browser.facade.test.ts index 9b110e05eb0..9f89ac36891 100644 --- a/src/commands/doctor-browser.facade.test.ts +++ b/src/commands/doctor-browser.facade.test.ts @@ -8,6 +8,14 @@ vi.mock("../plugin-sdk/facade-loader.js", () => ({ loadBundledPluginPublicSurfaceModuleSync, })); +function requireFirstNoteCall(noteFn: ReturnType): unknown[] { + const call = noteFn.mock.calls[0]; + if (!call) { + throw new Error("expected browser doctor note"); + } + return call; +} + describe("doctor browser facade", () => { beforeEach(() => { loadBundledPluginPublicSurfaceModuleSync.mockReset(); @@ -45,8 +53,9 @@ describe("doctor browser facade", () => { await expect(noteChromeMcpBrowserReadiness({}, { noteFn })).resolves.toBeUndefined(); expect(noteFn).toHaveBeenCalledTimes(1); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("Browser health check is unavailable"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("missing browser doctor facade"); - expect(noteFn.mock.calls[0]?.[1]).toBe("Browser"); + const noteCall = requireFirstNoteCall(noteFn); + expect(String(noteCall[0])).toContain("Browser health check is unavailable"); + expect(String(noteCall[0])).toContain("missing browser doctor facade"); + expect(noteCall[1]).toBe("Browser"); }); }); diff --git a/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts index e9e4c998413..477207cbe16 100644 --- a/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts +++ b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts @@ -121,7 +121,9 @@ describe("collectMissingExplicitDefaultAccountWarnings", () => { const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); expect(warnings).toHaveLength(2); - expect(warnings.some((line) => line.includes("channels.telegram"))).toBe(true); - expect(warnings.some((line) => line.includes("channels.slack"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("channels.telegram")]), + ); + expect(warnings).toEqual(expect.arrayContaining([expect.stringContaining("channels.slack")])); }); }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index d63fe7a995a..16fad04fae9 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1352,7 +1352,13 @@ async function collectDoctorWarnings(config: Record): Promise call[1] === "Doctor warnings").map((call) => call[0]); + const warnings: string[] = []; + for (const [message, title] of noteSpy.mock.calls) { + if (title === "Doctor warnings") { + warnings.push(message); + } + } + return warnings; } type DiscordGuildRule = { @@ -1408,7 +1414,9 @@ describe("doctor config flow", () => { }, }, }); - expect(doctorWarnings.some((line) => line.includes("mutable allowlist"))).toBe(false); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("mutable allowlist")]), + ); }); it("warns when hooks transformsDir points outside the hook transforms root", async () => { @@ -1533,14 +1541,16 @@ describe("doctor config flow", () => { }, }); - expect( - doctorWarnings.some((line) => - line.includes( + expect(doctorWarnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( 'channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false.', ), - ), - ).toBe(true); - expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false); + ]), + ); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("first-time setup mode")]), + ); }); it("shows plugin-blocked guidance instead of first-time Telegram guidance when plugins are disabled globally", async () => { @@ -1556,14 +1566,16 @@ describe("doctor config flow", () => { }, }); - expect( - doctorWarnings.some((line) => - line.includes( + expect(doctorWarnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( "channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally.", ), - ), - ).toBe(true); - expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false); + ]), + ); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("first-time setup mode")]), + ); }); it("warns on mutable Zalouser group entries when dangerous name matching is disabled", async () => { @@ -1597,7 +1609,9 @@ describe("doctor config flow", () => { }, }); - expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false); + expect(doctorWarnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("channels.zalouser.groups")]), + ); }); it("warns when imessage group allowlist is empty even if allowFrom is set", async () => { @@ -2016,8 +2030,8 @@ describe("doctor config flow", () => { .filter((call) => call[1] === "Doctor warnings" || call[1] === "Doctor changes") .map((call) => call[0]); const joinedOutputs = outputs.join("\n"); - expect(outputs.filter((line) => line.includes("\u001b"))).toEqual([]); - expect(outputs.filter((line) => line.includes("\nforged"))).toEqual([]); + expect(outputs.some((line) => line.includes("\u001b"))).toBe(false); + expect(outputs.some((line) => line.includes("\nforged"))).toBe(false); expect(joinedOutputs).toContain('channels.slack.accounts.opsopen.allowFrom: set to ["*"]'); expect(joinedOutputs).toContain('required by dmPolicy="open"'); expect( diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index e2f949fb346..de85eb80c76 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -76,6 +76,21 @@ async function writeCronStore(storePath: string, jobs: Array>> { + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + return persisted.jobs; +} + +function requirePersistedJob(jobs: Array>, index: number) { + const job = jobs[index]; + if (!job) { + throw new Error(`expected persisted cron job ${index}`); + } + return job; +} + describe("maybeRepairLegacyCronStore", () => { it("repairs legacy cron store fields and migrates notify fallback to webhook delivery", async () => { const storePath = await makeTempStorePath(); @@ -90,23 +105,21 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - const [job] = persisted.jobs; - expect(job?.jobId).toBeUndefined(); - expect(job?.id).toBe("legacy-job"); - expect(job?.notify).toBeUndefined(); - expect(job?.schedule).toMatchObject({ + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.jobId).toBeUndefined(); + expect(job.id).toBe("legacy-job"); + expect(job.notify).toBeUndefined(); + expect(job.schedule).toMatchObject({ kind: "cron", expr: "0 7 * * *", tz: "UTC", }); - expect(job?.delivery).toMatchObject({ + expect(job.delivery).toMatchObject({ mode: "webhook", to: "https://example.invalid/cron-finished", }); - expect(job?.payload).toMatchObject({ + expect(job.payload).toMatchObject({ kind: "systemEvent", text: "Morning brief", }); @@ -143,12 +156,12 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.id).toBe("42"); - expect(typeof persisted.jobs[1]?.id).toBe("string"); - expect(String(persisted.jobs[1]?.id)).toMatch(/^cron-/); + const jobs = await readPersistedJobs(storePath); + const firstJob = requirePersistedJob(jobs, 0); + const secondJob = requirePersistedJob(jobs, 1); + expect(firstJob.id).toBe("42"); + expect(typeof secondJob.id).toBe("string"); + expect(String(secondJob.id)).toMatch(/^cron-/); expect(noteMock).toHaveBeenCalledWith( expect.stringContaining("stores `id` as a non-string value"), "Cron", @@ -202,10 +215,9 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.notify).toBe(true); + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.notify).toBe(true); expect(noteSpy).toHaveBeenCalledWith( expect.stringContaining('uses legacy notify fallback alongside delivery mode "announce"'), "Doctor warnings", @@ -225,15 +237,14 @@ describe("maybeRepairLegacyCronStore", () => { prompter, }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); expect(prompter.confirm).toHaveBeenCalledWith({ message: "Repair legacy cron jobs now?", initialValue: true, }); - expect(persisted.jobs[0]?.jobId).toBe("legacy-job"); - expect(persisted.jobs[0]?.notify).toBe(true); + expect(job.jobId).toBe("legacy-job"); + expect(job.notify).toBe(true); expect(noteSpy).not.toHaveBeenCalledWith( expect.stringContaining("Cron store normalized"), "Doctor changes", @@ -282,11 +293,10 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.notify).toBeUndefined(); - expect(persisted.jobs[0]?.delivery).toMatchObject({ + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.notify).toBeUndefined(); + expect(job.delivery).toMatchObject({ mode: "webhook", to: "https://example.invalid/cron-finished", }); @@ -321,13 +331,12 @@ describe("maybeRepairLegacyCronStore", () => { prompter: makePrompter(true), }); - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { - jobs: Array>; - }; - expect(persisted.jobs[0]?.channel).toBeUndefined(); - expect(persisted.jobs[0]?.to).toBeUndefined(); - expect(persisted.jobs[0]?.threadId).toBeUndefined(); - expect(persisted.jobs[0]?.delivery).toMatchObject({ + const jobs = await readPersistedJobs(storePath); + const job = requirePersistedJob(jobs, 0); + expect(job.channel).toBeUndefined(); + expect(job.to).toBeUndefined(); + expect(job.threadId).toBeUndefined(); + expect(job.delivery).toMatchObject({ mode: "announce", channel: "telegram", to: "-1001234567890", diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index 1d9b22ba9a2..7ef9c8e7d8f 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -14,6 +14,16 @@ import { repairBrokenSessionTranscriptFile, } from "./doctor-session-transcripts.js"; +function countNonEmptyLines(value: string): number { + let count = 0; + for (const line of value.split(/\r?\n/)) { + if (line) { + count += 1; + } + } + return count; +} + describe("doctor session transcript repair", () => { let root: string; @@ -129,7 +139,7 @@ describe("doctor session transcript repair", () => { expect(title).toBe("Session transcripts"); expect(message).toContain("duplicated prompt-rewrite branches"); expect(message).toContain('Run "openclaw doctor --fix"'); - expect((await fs.readFile(filePath, "utf-8")).split(/\r?\n/).filter(Boolean)).toHaveLength(3); + expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3); }); it("ignores ordinary branch history without internal runtime context", async () => { @@ -152,6 +162,6 @@ describe("doctor session transcript repair", () => { const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: true }); expect(result.broken).toBe(false); - expect((await fs.readFile(filePath, "utf-8")).split(/\r?\n/).filter(Boolean)).toHaveLength(3); + expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3); }); }); diff --git a/src/commands/doctor-state-integrity.linux-storage.test.ts b/src/commands/doctor-state-integrity.linux-storage.test.ts index 9d1ea696ce8..6c3789b3b8e 100644 --- a/src/commands/doctor-state-integrity.linux-storage.test.ts +++ b/src/commands/doctor-state-integrity.linux-storage.test.ts @@ -115,8 +115,11 @@ describe("detectLinuxSdBackedStateDir", () => { }, }); - expect(result).not.toBeNull(); - const warning = formatLinuxSdBackedStateDirWarning(stateDir, result!); + expect(result).toEqual(expect.any(Object)); + if (result === null) { + throw new Error("Expected Linux state storage warning details"); + } + const warning = formatLinuxSdBackedStateDirWarning(stateDir, result); expect(warning).toContain("device /dev/disk/by-uuid/mmc\\nsource"); expect(warning).toContain("mount /home/pi/mnt\\nspoofed"); expect(warning).not.toContain("device /dev/disk/by-uuid/mmc\nsource"); diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 7556002ad8c..f934bad29e7 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -379,7 +379,10 @@ describe("doctor state integrity oauth dir checks", () => { }), ); const files = fs.readdirSync(sessionsDir); - expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(true); + const archivedOrphanTranscripts = files.filter((name) => + name.startsWith("orphan-session.jsonl.deleted."), + ); + expect(archivedOrphanTranscripts.length).toBeGreaterThan(0); }); it("does not auto-archive orphan transcripts from non-interactive repair mode", async () => { @@ -401,7 +404,10 @@ describe("doctor state integrity oauth dir checks", () => { ); const files = fs.readdirSync(sessionsDir); expect(files).toContain("orphan-session.jsonl"); - expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(false); + const archivedOrphanTranscripts = files.filter((name) => + name.startsWith("orphan-session.jsonl.deleted."), + ); + expect(archivedOrphanTranscripts).toEqual([]); }); it.skipIf(process.platform === "win32")( @@ -440,7 +446,9 @@ describe("doctor state integrity oauth dir checks", () => { await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock }); expect(fs.existsSync(transcriptPath)).toBe(true); - expect(fs.readdirSync(sessionsDir).some((name) => name.includes(".deleted."))).toBe(false); + expect(fs.readdirSync(sessionsDir)).not.toEqual( + expect.arrayContaining([expect.stringContaining(".deleted.")]), + ); expect(stateIntegrityText()).not.toContain("These .jsonl files are no longer referenced"); } finally { fs.rmSync(symlinkHome, { force: true, recursive: true }); @@ -573,7 +581,9 @@ describe("doctor state integrity oauth dir checks", () => { const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); const store = JSON.parse(fs.readFileSync(storePath, "utf8")) as Record; expect(store["agent:main:main"]?.sessionId).toBe("mixed-session"); - expect(Object.keys(store).some((key) => key.includes("heartbeat-recovered"))).toBe(false); + expect(Object.keys(store)).not.toEqual( + expect.arrayContaining([expect.stringContaining("heartbeat-recovered")]), + ); expect(confirmRuntimeRepair).not.toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("Move heartbeat-owned main session"), diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts index 71369a59672..e84009650d0 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts @@ -174,6 +174,7 @@ describe("doctor command", () => { throw new Error("Expected doctor to write migrated auth profiles"); } const profiles = (written.auth as { profiles: Record }).profiles; + expect(profiles).toHaveProperty("anthropic:me@example.com"); expect(profiles["anthropic:me@example.com"]).toEqual(expect.any(Object)); expect(profiles["anthropic:default"]).toBeUndefined(); }, 30_000); diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts index 982cac36e53..cad0c7f536d 100644 --- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts +++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts @@ -44,18 +44,17 @@ describe("doctor command", () => { await doctorCommand(createDoctorRuntime(), { nonInteractive: true }); - expect( - terminalNoteMock.mock.calls.some(([message, title]) => { - if (title !== "Sandbox" || typeof message !== "string") { - return false; - } - const normalized = message.replace(/\s+/g, " ").trim(); - return ( - normalized.includes('agents.list (id "work") sandbox docker') && - normalized.includes('scope resolves to "shared"') - ); - }), - ).toBe(true); + const matchingSandboxNotes = terminalNoteMock.mock.calls.filter(([message, title]) => { + if (title !== "Sandbox" || typeof message !== "string") { + return false; + } + const normalized = message.replace(/\s+/g, " ").trim(); + return ( + normalized.includes('agents.list (id "work") sandbox docker') && + normalized.includes('scope resolves to "shared"') + ); + }); + expect(matchingSandboxNotes.length).toBeGreaterThan(0); }, 30_000); it("does not warn when only the active workspace is present", async () => { @@ -82,9 +81,8 @@ describe("doctor command", () => { await doctorCommand(createDoctorRuntime(), { nonInteractive: true }); - expect(terminalNoteMock.mock.calls.some(([_, title]) => title === "Extra workspace")).toBe( - false, - ); + const noteTitles = terminalNoteMock.mock.calls.map(([_, title]) => title); + expect(noteTitles).not.toContain("Extra workspace"); homedirSpy.mockRestore(); existsSpy.mockRestore(); diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 65af434c683..985e7558e23 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -19,6 +19,13 @@ function migrateLegacyConfigForTest(raw: unknown): { : { config: next as OpenClawConfig, changes }; } +function expectMigrationChangesToIncludeFragments(changes: string[], fragments: string[]): void { + const unmatchedFragments = fragments.filter((fragment) => + changes.every((change) => !change.includes(fragment)), + ); + expect({ changes, unmatchedFragments }).toMatchObject({ unmatchedFragments: [] }); +} + describe("legacy session maintenance migrate", () => { it("removes deprecated session.maintenance.rotateBytes", () => { const res = migrateLegacyConfigForTest({ @@ -649,8 +656,11 @@ describe("legacy migrate heartbeat config", () => { }); expect(res.changes).toContain("Removed empty top-level heartbeat."); - expect(res.config).not.toBeNull(); - expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined(); + expect(res.config).toEqual(expect.any(Object)); + if (res.config === null) { + throw new Error("Expected migrated config"); + } + expect((res.config as { heartbeat?: unknown }).heartbeat).toBeUndefined(); }); }); @@ -666,8 +676,10 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { "http://localhost:18789", "http://127.0.0.1:18789", ]); - expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true); - expect(res.changes.some((c) => c.includes("bind=lan"))).toBe(true); + expectMigrationChangesToIncludeFragments(res.changes, [ + "gateway.controlUi.allowedOrigins", + "bind=lan", + ]); }); it("seeds allowedOrigins using configured port", () => { @@ -734,7 +746,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => { "http://localhost:18789", "http://127.0.0.1:18789", ]); - expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true); + expectMigrationChangesToIncludeFragments(res.changes, ["gateway.controlUi.allowedOrigins"]); }); it("does not migrate loopback bind — returns null", () => { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 4114a8d09f6..f61451d1387 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -109,7 +109,10 @@ function addConfiguredAgentRuntimePluginIds( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): void { - for (const runtime of collectConfiguredAgentHarnessRuntimes(cfg, env ?? process.env)) { + for (const runtime of collectConfiguredAgentHarnessRuntimes(cfg, env ?? process.env, { + includeEnvRuntime: false, + includeLegacyAgentRuntimes: false, + })) { addConfiguredPluginId(ids, runtime); } } diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 144e54818da..2c9e17b551a 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -268,7 +268,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); - expect(result.warnings.filter((message) => message.includes("Auto-generated"))).toEqual([]); + expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); @@ -300,7 +300,7 @@ describe("resolveGatewayInstallToken", () => { }); expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); - expect(result.warnings.filter((message) => message.includes("Auto-generated"))).toEqual([]); + expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 783c43cbfbf..b677d189a97 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -772,7 +772,8 @@ describe("gateway-status command", () => { const parsed = JSON.parse(runtimeLogs.join("\n")) as Record; const targets = parsed.targets as Array>; - expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); + const targetKinds = targets.map((target) => target.kind); + expect(targetKinds).toContain("sshTunnel"); }); it("uses local TLS target strategy and fingerprint for local loopback probes", async () => { diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 3db34d09f80..e298695effc 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -507,8 +507,8 @@ describe("getHealthSnapshot", () => { expect(telegram.probe?.ok).toBe(true); expect(telegram.probe?.bot?.username).toBe("bot"); expect(telegram.probe?.webhook?.url).toMatch(/^https:/); - expect(calls.some((c) => c.includes("/getMe"))).toBe(true); - expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); + expect(calls).toEqual(expect.arrayContaining([expect.stringContaining("/getMe")])); + expect(calls).toEqual(expect.arrayContaining([expect.stringContaining("/getWebhookInfo")])); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-health-")); const tokenFile = path.join(tmpDir, "telegram-token"); @@ -520,7 +520,9 @@ describe("getHealthSnapshot", () => { ); expect(tokenFileProbe.telegram.configured).toBe(true); expect(tokenFileProbe.telegram.probe?.ok).toBe(true); - expect(tokenFileProbe.calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); + expect(tokenFileProbe.calls).toEqual( + expect.arrayContaining([expect.stringContaining("bott-file/getMe")]), + ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 4ff32c2bb35..0dee0a5ad03 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -56,6 +56,14 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); +function requireFirstRuntimeLog(): string { + const [message] = runtime.log.mock.calls[0] ?? []; + if (message === undefined) { + throw new Error("expected health command log output"); + } + return String(message); +} + describe("healthCommand", () => { beforeEach(() => { vi.clearAllMocks(); @@ -90,8 +98,7 @@ describe("healthCommand", () => { await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never); expect(runtime.exit).not.toHaveBeenCalled(); - const logged = runtime.log.mock.calls[0]?.[0] as string; - const parsed = JSON.parse(logged) as HealthSummary; + const parsed = JSON.parse(requireFirstRuntimeLog()) as HealthSummary; expect(parsed.channels.whatsapp?.linked).toBe(true); expect(parsed.channels.telegram?.configured).toBe(true); expect(parsed.sessions.count).toBe(1); diff --git a/src/commands/migrate/selection.test.ts b/src/commands/migrate/selection.test.ts index 38d59123e6c..5689f2c263b 100644 --- a/src/commands/migrate/selection.test.ts +++ b/src/commands/migrate/selection.test.ts @@ -87,15 +87,25 @@ function codexPluginConfigItem(pluginNames: string[]): MigrationItem { } function plan(items: MigrationItem[]): MigrationPlan { + const countStatus = (status: MigrationItem["status"]): number => { + let count = 0; + for (const item of items) { + if (item.status === status) { + count += 1; + } + } + return count; + }; + return { providerId: "codex", source: "/tmp/codex", summary: { total: items.length, - planned: items.filter((item) => item.status === "planned").length, + planned: countStatus("planned"), migrated: 0, - skipped: items.filter((item) => item.status === "skipped").length, - conflicts: items.filter((item) => item.status === "conflict").length, + skipped: countStatus("skipped"), + conflicts: countStatus("conflict"), errors: 0, sensitive: 0, }, diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 69919aa901c..b557b0f7a91 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -691,7 +691,7 @@ describe("models list/status", () => { ]); }); - it("toModelRow does not crash without cfg/authStore when availability is undefined", () => { + it("toModelRow marks unavailable when cfg/authStore and availability are undefined", () => { const row = toModelRow({ model: makeGoogleAntigravityTemplate( "claude-opus-4-6-thinking", diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index 922c76fce90..cb3b45af317 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -96,7 +96,7 @@ describe("resolveProviderAuthOverview", () => { persistedStores.clear(); }); - it("does not throw when token profile only has tokenRef", () => { + it("labels token profiles that only have tokenRef", () => { const overview = resolveProviderAuthOverview({ provider: "github-copilot", cfg: {}, diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 371ae1f0cbe..e4aaeadac71 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -585,7 +585,9 @@ describe("modelsStatusCommand auth overview", () => { await modelsStatusCommand({ json: true }, aliasRuntime as never); const aliasPayload = JSON.parse(String((aliasRuntime.log as Mock).mock.calls[0]?.[0])); const providers = aliasPayload.auth.providers as Array<{ provider: string }>; - expect(providers.filter((provider) => provider.provider === "zai")).toHaveLength(1); + expect( + providers.reduce((count, provider) => count + (provider.provider === "zai" ? 1 : 0), 0), + ).toBe(1); expect(providers.map((provider) => provider.provider)).not.toContain("z.ai"); } finally { if (originalLoadConfig) { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index fc7f1f2bf07..36ce0c9e84b 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -748,7 +748,8 @@ describe("setupChannels", () => { expect(entries.find((entry) => entry.value === "external-chat")?.label).toBe( "Healthy Chat", ); - expect(entries.some((entry) => entry.value === "broken-channel")).toBe(false); + const entryValues = entries.map((entry) => entry.value); + expect(entryValues).not.toContain("broken-channel"); return "__done__"; } return "__done__"; diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 64d3a580716..3682d4d6197 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -64,6 +64,11 @@ function makeRuntime(): { runtime: RuntimeEnv; logs: string[] } { }; } +function expectLogsToInclude(logs: readonly string[], text: string): void { + const matches = logs.filter((line) => line.includes(text)); + expect(matches.length).toBeGreaterThan(0); +} + describe("sessionsCleanupCommand", () => { beforeEach(() => { vi.clearAllMocks(); @@ -437,11 +442,16 @@ describe("sessionsCleanupCommand", () => { runtime, ); - expect(logs.some((line) => line.includes("Planned session actions:"))).toBe(true); - expect(logs.some((line) => line.includes("Would prune unreferenced artifacts: 2"))).toBe(true); - expect(logs.some((line) => line.includes("Action") && line.includes("Key"))).toBe(true); - expect(logs.some((line) => line.includes("fresh") && line.includes("keep"))).toBe(true); - expect(logs.some((line) => line.includes("stale") && line.includes("prune-stale"))).toBe(true); + expectLogsToInclude(logs, "Planned session actions:"); + expectLogsToInclude(logs, "Would prune unreferenced artifacts: 2"); + const tableHeaderLines = logs.filter((line) => line.includes("Action") && line.includes("Key")); + expect(tableHeaderLines.length).toBeGreaterThan(0); + const freshKeepLines = logs.filter((line) => line.includes("fresh") && line.includes("keep")); + expect(freshKeepLines.length).toBeGreaterThan(0); + const stalePruneLines = logs.filter( + (line) => line.includes("stale") && line.includes("prune-stale"), + ); + expect(stalePruneLines.length).toBeGreaterThan(0); }); it("returns grouped JSON for --all-agents dry-runs", async () => { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index ad8fb9a00df..40aa4a7d9b2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -85,6 +85,14 @@ function getRuntimeLogs() { return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0])); } +function getRuntimeLog(index: number): string { + const call = runtimeLogMock.mock.calls[index]; + if (!call) { + throw new Error(`expected runtime log call ${index}`); + } + return String(call[0]); +} + function getJoinedRuntimeLogs() { return getRuntimeLogs().join("\n"); } @@ -967,14 +975,14 @@ describe("statusCommand", () => { createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), ]); await statusCommand({ json: true }, runtime as never); - const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); + const payload = JSON.parse(getRuntimeLog(0)); expect(payload.linkChannel).toBeUndefined(); expect(payload.memory).toBeNull(); expect(payload.memoryPlugin.enabled).toBe(true); expect(payload.memoryPlugin.slot).toBe("memory-core"); expect(payload.sessions.count).toBe(1); expect(payload.sessions.paths).toContain("/tmp/sessions.json"); - expect(payload.sessions.defaults.model).toEqual(expect.any(String)); + expect(payload.sessions.defaults.model).toBe("pi:opus"); expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0); expect(payload.sessions.recent[0].percentUsed).toBe(50); expect(payload.sessions.recent[0].cacheRead).toBe(2_000); @@ -1001,7 +1009,7 @@ describe("statusCommand", () => { runtimeLogMock.mockClear(); await statusCommand({ json: true, all: true }, runtime as never); - const allPayload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); + const allPayload = JSON.parse(getRuntimeLog(0)); expect(allPayload.securityAudit.summary.critical).toBe(1); expect(allPayload.securityAudit.summary.warn).toBe(1); expect(mocks.runSecurityAudit).toHaveBeenCalledWith( @@ -1088,21 +1096,21 @@ describe("statusCommand", () => { "Troubleshooting:", "Next steps:", ]) { - expect(logs.some((line) => line.includes(token))).toBe(true); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining(token)])); } - expect( - logs.some((line) => line.includes("legacy-plugin still uses legacy before_agent_start")), - ).toBe(true); - expect( - logs.some( - (line) => - line.includes("openclaw status --all") || - line.includes("openclaw --profile isolated status --all"), - ), - ).toBe(true); - expect(logs.some((line) => line.includes("Cache"))).toBe(true); - expect(logs.some((line) => line.includes("40% hit"))).toBe(true); - expect(logs.some((line) => line.includes("read 2.0k"))).toBe(true); + expect(logs).toEqual( + expect.arrayContaining([ + expect.stringContaining("legacy-plugin still uses legacy before_agent_start"), + ]), + ); + expect(logs).toEqual( + expect.arrayContaining([ + expect.stringMatching(/openclaw (?:--profile isolated )?status --all/), + ]), + ); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("Cache")])); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("40% hit")])); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("read 2.0k")])); }); it("shows a maintenance hint when task audit errors are present", async () => { @@ -1157,8 +1165,8 @@ describe("statusCommand", () => { }, }); const logs = await runStatusAndGetLogs(); - expect(logs.some((line) => line.includes("100% cached"))).toBe(true); - expect(logs.some((line) => line.includes("120% cached"))).toBe(false); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("100% cached")])); + expect(logs).not.toEqual(expect.arrayContaining([expect.stringContaining("120% cached")])); mocks.loadSessionStore.mockReturnValue({ "+1000": { @@ -1170,8 +1178,10 @@ describe("statusCommand", () => { }, }); const promptSideLogs = await runStatusAndGetLogs(); - expect(promptSideLogs.some((line) => line.includes("67% cached"))).toBe(true); - expect(promptSideLogs.some((line) => line.includes("40% cached"))).toBe(false); + expect(promptSideLogs).toEqual(expect.arrayContaining([expect.stringContaining("67% cached")])); + expect(promptSideLogs).not.toEqual( + expect.arrayContaining([expect.stringContaining("40% cached")]), + ); }); it("shows node-only gateway info when no local gateway service is installed", async () => { @@ -1216,7 +1226,7 @@ describe("statusCommand", () => { presence: [], }); const logs = await runStatusAndGetLogs(); - expect(logs.some((l: string) => l.includes("auth token"))).toBe(true); + expect(logs).toEqual(expect.arrayContaining([expect.stringContaining("auth token")])); }); }); @@ -1239,7 +1249,7 @@ describe("statusCommand", () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); - expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull(); + expect(payload.gateway.error ?? payload.gateway.authWarning).toEqual(expect.any(String)); if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) { expect( payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")), diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index a3eda024aa1..5e7039d3c1a 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -20,6 +20,15 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } +const zeroTaskAuditCounts = { + delivery_failed: 0, + inconsistent_timestamps: 0, + lost: 0, + missing_cleanup: 0, + stale_queued: 0, + stale_running: 0, +}; + async function withTaskCommandStateDir(run: () => Promise): Promise { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-tasks-command-" }, @@ -150,11 +159,9 @@ describe("tasks commands", () => { expect(payload.mode).toBe("preview"); expect(payload.maintenance.taskFlows.pruned).toBe(1); - expect(payload.auditBefore.byCode).toEqual(expect.any(Object)); - expect(Array.isArray(payload.auditBefore.byCode)).toBe(false); + expect(payload.auditBefore.byCode).toStrictEqual(zeroTaskAuditCounts); expect(payload.auditBefore.taskFlows.byCode.stale_running).toBe(0); - expect(payload.auditAfter.byCode).toEqual(expect.any(Object)); - expect(Array.isArray(payload.auditAfter.byCode)).toBe(false); + expect(payload.auditAfter.byCode).toStrictEqual(zeroTaskAuditCounts); expect(payload.auditAfter.taskFlows.byCode.stale_running).toBe(0); }); }); diff --git a/src/commitments/commitments-full-chain.integration.test.ts b/src/commitments/commitments-full-chain.integration.test.ts index 944dd2ef818..97401227a60 100644 --- a/src/commitments/commitments-full-chain.integration.test.ts +++ b/src/commitments/commitments-full-chain.integration.test.ts @@ -61,23 +61,29 @@ describe("commitments full-chain integration", () => { }: { items: CommitmentExtractionItem[]; }): Promise => ({ - candidates: [ - { - itemId: items[0]?.itemId ?? "", - kind: "event_check_in", - sensitivity: "routine", - source: "inferred_user_context", - reason: "The user mentioned an interview happening today.", - suggestedText: "How did the interview go?", - dedupeKey: "interview:2026-04-29", - confidence: 0.93, - dueWindow: { - earliest: new Date(dueMs).toISOString(), - latest: new Date(dueMs + 60 * 60_000).toISOString(), - timezone: "America/Los_Angeles", + candidates: (() => { + const [firstItem] = items; + if (!firstItem) { + throw new Error("Expected commitment extraction item"); + } + return [ + { + itemId: firstItem.itemId, + kind: "event_check_in", + sensitivity: "routine", + source: "inferred_user_context", + reason: "The user mentioned an interview happening today.", + suggestedText: "How did the interview go?", + dedupeKey: "interview:2026-04-29", + confidence: 0.93, + dueWindow: { + earliest: new Date(dueMs).toISOString(), + latest: new Date(dueMs + 60 * 60_000).toISOString(), + timezone: "America/Los_Angeles", + }, }, - }, - ], + ]; + })(), }), ), setTimer: () => ({ unref() {} }) as ReturnType, @@ -102,7 +108,11 @@ describe("commitments full-chain integration", () => { const pendingStore = await loadCommitmentStore(); expect(pendingStore.commitments).toHaveLength(1); - expect(pendingStore.commitments[0]).toMatchObject({ + const [pendingCommitment] = pendingStore.commitments; + if (!pendingCommitment) { + throw new Error("Expected pending commitment"); + } + expect(pendingCommitment).toMatchObject({ status: "pending", agentId: "main", sessionKey, @@ -110,9 +120,9 @@ describe("commitments full-chain integration", () => { to: "155462274", suggestedText: "How did the interview go?", }); - expect(pendingStore.commitments[0]?.dueWindow.earliestMs).toBe(dueMs); - expect(pendingStore.commitments[0]).not.toHaveProperty("sourceUserText"); - expect(pendingStore.commitments[0]).not.toHaveProperty("sourceAssistantText"); + expect(pendingCommitment.dueWindow.earliestMs).toBe(dueMs); + expect(pendingCommitment).not.toHaveProperty("sourceUserText"); + expect(pendingCommitment).not.toHaveProperty("sourceAssistantText"); vi.setSystemTime(dueMs + 60_000); const sendTelegram = vi.fn().mockResolvedValue({ @@ -124,13 +134,16 @@ describe("commitments full-chain integration", () => { ctx: { Body?: string; OriginatingChannel?: string; OriginatingTo?: string }, opts?: { disableTools?: boolean }, ) => { + if (!opts) { + throw new Error("Expected commitment heartbeat reply options"); + } expect(ctx.Body).toContain("Due inferred follow-up commitments"); expect(ctx.Body).toContain("How did the interview go?"); expect(ctx.Body).not.toContain("I have an interview later today."); expect(ctx.Body).not.toContain("Good luck, I hope it goes well."); expect(ctx.OriginatingChannel).toBe("telegram"); expect(ctx.OriginatingTo).toBe("155462274"); - expect(opts?.disableTools).toBe(true); + expect(opts.disableTools).toBe(true); return { text: "How did the interview go?" }; }, ); @@ -154,7 +167,11 @@ describe("commitments full-chain integration", () => { expect.objectContaining({ accountId: "primary" }), ); const deliveredStore = await loadCommitmentStore(); - expect(deliveredStore.commitments[0]).toMatchObject({ + const [deliveredCommitment] = deliveredStore.commitments; + if (!deliveredCommitment) { + throw new Error("Expected delivered commitment"); + } + expect(deliveredCommitment).toMatchObject({ status: "sent", attempts: 1, sentAtMs: dueMs + 60_000, diff --git a/src/commitments/extraction.test.ts b/src/commitments/extraction.test.ts index 660b96a7d6b..9ff2e9a7753 100644 --- a/src/commitments/extraction.test.ts +++ b/src/commitments/extraction.test.ts @@ -68,6 +68,17 @@ describe("commitment extraction", () => { }; } + function expectSingleValidCandidate( + valid: ReturnType, + ): ReturnType[number] { + expect(valid).toHaveLength(1); + const [entry] = valid; + if (!entry) { + throw new Error("Expected one valid commitment candidate"); + } + return entry; + } + it("parses valid candidates from JSON output with surrounding text", () => { const parsed = parseCommitmentExtractionOutput( `noise {"candidates":[${JSON.stringify(candidate())}]} trailing`, @@ -149,9 +160,9 @@ describe("commitment extraction", () => { nowMs: writeMs, }); - expect(valid).toHaveLength(1); - expect(valid[0]?.earliestMs).toBe(writeMs + 10 * 60_000); - expect(valid[0]?.latestMs).toBe(writeMs + 10 * 60_000 + 12 * 60 * 60_000); + const validCandidate = expectSingleValidCandidate(valid); + expect(validCandidate.earliestMs).toBe(writeMs + 10 * 60_000); + expect(validCandidate.latestMs).toBe(writeMs + 10 * 60_000 + 12 * 60 * 60_000); }); it("persists inferred commitments and dedupes by scope and dedupe key", async () => { diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts index d82f178f27b..0c5b66aa21a 100644 --- a/src/commitments/runtime.test.ts +++ b/src/commitments/runtime.test.ts @@ -145,12 +145,20 @@ describe("commitment extraction runtime", () => { const store = await loadCommitmentStore(); expect(extractBatch).toHaveBeenCalledTimes(1); - const batchItems = extractBatch.mock.calls[0]?.[0].items; + const [extractCall] = extractBatch.mock.calls; + if (!extractCall) { + throw new Error("Expected commitment extraction batch call"); + } + const batchItems = extractCall[0].items; expect(batchItems).toHaveLength(2); - expect(batchItems?.[0]?.itemId).not.toContain("main"); - expect(batchItems?.[0]?.itemId).not.toContain("telegram"); - expect(batchItems?.[0]?.itemId).not.toContain("15551234567"); - expect(batchItems?.[0]?.itemId).not.toContain("m1"); + const [firstBatchItem] = batchItems; + if (!firstBatchItem) { + throw new Error("Expected first commitment extraction batch item"); + } + expect(firstBatchItem.itemId).not.toContain("main"); + expect(firstBatchItem.itemId).not.toContain("telegram"); + expect(firstBatchItem.itemId).not.toContain("15551234567"); + expect(firstBatchItem.itemId).not.toContain("m1"); expect(store.commitments.map((commitment) => commitment.dedupeKey)).toEqual([ "event:1", "event:2", diff --git a/src/config/allowed-values.test.ts b/src/config/allowed-values.test.ts index f62b95dae9b..addb8d2fe33 100644 --- a/src/config/allowed-values.test.ts +++ b/src/config/allowed-values.test.ts @@ -4,24 +4,16 @@ import { summarizeAllowedValues } from "./allowed-values.js"; describe("summarizeAllowedValues", () => { it("does not collapse mixed-type entries that stringify similarly", () => { const summary = summarizeAllowedValues([1, "1", 1, "1"]); - expect(summary).not.toBeNull(); - if (!summary) { - return; - } - expect(summary.hiddenCount).toBe(0); - expect(summary.formatted).toContain('1, "1"'); - expect(summary.values).toHaveLength(2); + expect(summary?.hiddenCount).toBe(0); + expect(summary?.formatted).toContain('1, "1"'); + expect(summary?.values).toHaveLength(2); }); it("keeps distinct long values even when labels truncate the same way", () => { const prefix = "a".repeat(200); const summary = summarizeAllowedValues([`${prefix}x`, `${prefix}y`]); - expect(summary).not.toBeNull(); - if (!summary) { - return; - } - expect(summary.hiddenCount).toBe(0); - expect(summary.values).toHaveLength(2); - expect(summary.values[0]).not.toBe(summary.values[1]); + expect(summary?.hiddenCount).toBe(0); + expect(summary?.values).toHaveLength(2); + expect(summary?.values[0]).not.toBe(summary?.values[1]); }); }); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 722081005db..03dab470221 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -11,21 +11,21 @@ type BundledChannelConfigMetadata = { }; const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ - '[{"pluginId":"discord","channelId":"discord","label":"Discord","description":"very well supported right now.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"model":{"type":"string","minLength":1},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eve', - 'ntQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"model":{"type":"string","minLength":1},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const"', - ':4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"dm.policy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"] (legacy: channels.discord.dm.allowFrom)."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Discord Retry Attempts","help":"Max retry attempts for outbound Discord API calls (default: 3)."},"retry.minDelayMs":{"label":"Discord Retry Min Delay (ms)","help":"Minimum retry delay in ms for Discord outbound calls."},"retry.maxDelayMs":{"label":"Discord Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Discord outbound calls."},"retry.jitter":{"label":"Discord Retry Jitter","help":"Jitter factor (0-1) applied to Discord retry delays."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"eventQueue.listenerTimeout":{"label":"Discord EventQueue Listener Timeout (ms)","help":"Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout."},"eventQueue.maxQueueSize":{"label":"Discord EventQueue Max Queue Size","help":"Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize."},"eventQueue.maxConcurrency":{"label":"Discord EventQueue Max Concurrency","help":"Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"ui.components.accentColor":{"label":"Discord Component Accent Color","help":"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"gatewayInfoTimeoutMs":{"label":"Discord Gateway Metadata Timeout (ms)","help":"Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset."},"gatewayReadyTimeoutMs":{"label":"Discord Gateway READY Timeout (ms)","help":"Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset."},"gatewayRuntimeReadyTimeoutMs":{"label":"Discord Gateway Runtime READY Timeout (ms)","help":"Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses (for example openai/gpt-5.4-mini). Leave unset to inherit the routed agent model."},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2500."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with messages.tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"autoPresence.healthyText":{"label":"Discord Auto Presence Healthy Text","help":"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set."},"autoPresence.degradedText":{"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle)."},"autoPresence.exhaustedText":{"label":"Discord Auto Presence Exhausted Text","help":"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationId":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pa', - 'ttern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"required":["domain","connectionMode","webhookPath","dmPolicy","groupPolicy","reactionNotifications","typingIndicator","resolveSenderNames"],"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockS', - 'treaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."}}},{"pluginId":"irc","channelId":"irc","label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{', - '"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"blockStreaming":{"type":"boolean"},"streaming":{"anyOf":[{"type":"string","enum":["partial","quiet","progress","off"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}]},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"chunkMode":{"type":"string","enum":["length","newline"]},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"n', - 'umber"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentenc', - 'e"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"sharePointSiteId":{"type":"string"},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLengt', - 'h":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":fal', - 'se},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."}}},{"pluginId":"slack","channelId":"slack","label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provide', - 'r":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"synology-chat","channelId":"synology-chat","label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":', - '"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"', - 'type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"dm.threadReplies":{"label":"Telegram DM Thread Replies","help":"Controls whether Telegram DMs with message_thread_id use flat sessions (\\"off\\", default) or thread-scoped sessions (\\"inbound\\" or \\"always\\"). Thread IDs are still preserved for replies when sessions stay flat."},"direct.*.threadReplies":{"label":"Telegram Per-DM Thread Replies","help":"Per-DM override for message_thread_id session threading. Use \\"inbound\\" only when a specific direct chat intentionally uses Telegram DM topics as separate sessions."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)",', - '"help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":', - '{"enabled":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + '[{"pluginId":"discord","channelId":"discord","label":"Discord","description":"very well supported right now.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","talk-buffer","bidi"]},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"voice":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"i', + 'nteger","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"proxy":{"type":"string"},"gatewayInfoTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"gatewayRuntimeReadyTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"slug":{"type":"string"},"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ignoreOtherMentions":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"cleanupAfterResolve":{"type":"boolean"},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","talk-buffer","bidi"]},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"voice":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source",', + '"provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"eventQueue":{"type":"object","properties":{"listenerTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxQueueSize":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxConcurrency":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"dm.policy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"] (legacy: channels.discord.dm.allowFrom)."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Discord Retry Attempts","help":"Max retry attempts for outbound Discord API calls (default: 3)."},"retry.minDelayMs":{"label":"Discord Retry Min Delay (ms)","help":"Minimum retry delay in ms for Discord outbound calls."},"retry.maxDelayMs":{"label":"Discord Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Discord outbound calls."},"retry.jitter":{"label":"Discord Retry Jitter","help":"Jitter factor (0-1) applied to Discord retry delays."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"eventQueue.listenerTimeout":{"label":"Discord EventQueue Listener Timeout (ms)","help":"Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout."},"eventQueue.maxQueueSize":{"label":"Discord EventQueue Max Queue Size","help":"Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize."},"eventQueue.maxConcurrency":{"label":"Discord EventQueue Max Concurrency","help":"Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"ui.components.accentColor":{"label":"Discord Component Accent Color","help":"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"gatewayInfoTimeoutMs":{"label":"Discord Gateway Metadata Timeout (ms)","help":"Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset."},"gatewayReadyTimeoutMs":{"label":"Discord Gateway READY Timeout (ms)","help":"Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset."},"gatewayRuntimeReadyTimeoutMs":{"label":"Discord Gateway Runtime READY Timeout (ms)","help":"Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai-codex/gpt-5.5). Leave unset to inherit the routed agent model."},"voice.mode":{"label":"Discord Voice Mode","help":"Conversation mode: stt-tts uses batch speech-to-text plus TTS, talk-buffer uses a realtime voice shell with the OpenClaw agent as the brain, and bidi lets the realtime provider converse directly with the OpenClaw consult tool."},"voice.realtime.provider":{"label":"Discord Realtime Provider","help":"Realtime voice provider for talk-buffer or bidi Discord voice modes, such as openai."},"voice.realtime.model":{"label":"Discord Realtime Model","help":"Provider realtime session model, such as gpt-realtime-2. This is separate from voice.model, which remains the OpenClaw agent brain model."},"voice.realtime.voice":{"label":"Discord Realtime Voice","help":"Provider realtime output voice, such as cedar."},"voice.realtime.toolPolicy":{"label":"Discord Realtime Tool Policy","help":"Tool policy for the OpenClaw agent consult tool in bidi mode: safe-read-only, owner, or none."},"voice.realtime.consultPolicy":{"label":"Discord Realtime Consult Policy","help":"Use always to strongly prefer the OpenClaw agent brain for substantive bidi turns."},"voice.realtime.providers":{"label":"Discord Realtime Provider Settings","help":"Provider-specific realtime voice settings keyed by provider id.","advanced":true},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2500."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with messages.tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"autoPresence.healthyText":{"label":"Discord Auto Presence Healthy Text","help":"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set."},"autoPresence.degradedText":{"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle)."},"autoPresence.exhaustedText":{"label":"Discord Auto Presence Exhausted Text","help":"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationI', + 'd":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["open","pairing","allowlist"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","allowlist","disabled"]},{}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"enabled":{"type":"boolean"},"minDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"boolean"},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"required":["domain","connectionMode","webhookPath","dmPolicy","groupPolicy","reactionNotifications","typingIndicator","resolveSenderNames"],"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{', + '"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"defaultTo":{"type":"string"},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"required":["policy"],"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"region":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"sendReadReceipts":{"type":"boolean"},"coalesceSameSenderDms":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":[', + '"dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."}}},{"pluginId":"irc","channelId":"irc","label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["open","allowlist","pairing","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","allowlist","disabled"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"systemPrompt":{"type":"string"},"skills":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"blockStreaming":{"type":"boolean"},"streaming":{"anyOf":[{"type":"string","enum":["partial","quiet","progress","off"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}]},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"chunkMode":{"type":"string","enum":["length","newline"]},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"ty', + 'pe":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"account":{"type":"string"},"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"anyOf":[{"type":"string","enum":["off","partial","block","progress"]},{"type":"boolean"},{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false}]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enable', + 'd":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"sharePointSiteId":{"type":"string"},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"ma', + 'ximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"c2cStreamApi":{"type":"boolean"}},"required":["mode"],"additionalProperties":{}}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"account":{"type":"string"},"accountUuid":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"', + 'string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state."}}},{"pluginId":"slack","channelId":"slack","label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"mode":{"default":"socket","type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"mode":{"type":"string","enum":["socket","http"]},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"addit', + 'ionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireExplicitMention":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"allowBots":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"skills":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["mode","webhookPath","userTokenReadOnly","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"dm.policy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"] (legacy: channels.slack.dm.allowFrom)."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to', + ' keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."},"thread.requireExplicitMention":{"label":"Slack Thread Require Explicit Mention","help":"If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false)."}}},{"pluginId":"synology-chat","channelId":"synology-chat","label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additio', + 'nalProperties":false},"enabled":{"type":"boolean"},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"dm":{"type":"object","properties":{"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"timeoutSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaGroupFlushMs":{"description":"Buffer window in milliseconds for Telegram media groups/albums before dispatching them as one inbound message. Default: 500.","type":"integer","minimum":10,"maximum":60000},"pollingStallThresholdMs":{"type":"integer","minimum":30000,"maximum":600000},"retry":{"type":"object","properties":{"attempts":{"type":"integer","minimum":1,"maximum":9007199254740991},"minDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"maxDelayMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"jitter":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]},"spawnSubagentSessions":{"type":"boolean"},"spawnAcpSessions":{"type":"boolean"}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"errorCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"dm.threadReplies":{"label":"Telegram DM Thread Replies","help":"Controls whether Telegram DMs with message_thread_id use flat sessions (\\"off\\", default) or thread-scoped sessions (\\"inbound\\" or \\"always\\"). Thread IDs are still preserved for replies when sessions stay flat."},"direct.*.threadReplies":{"label":"Telegram Per-DM Thread Replies","help":"Per-DM override for message_thread_id session threading. Use \\"inbound\\" only when a specific direct chat intentionally uses Telegram DM topics as separate sessions."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred ', + 'breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"retry.attempts":{"label":"Telegram Retry Attempts","help":"Max retry attempts for outbound Telegram API calls (default: 3)."},"retry.minDelayMs":{"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls."},"retry.maxDelayMs":{"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls."},"retry.jitter":{"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"timeoutSeconds":{"label":"Telegram API Timeout (seconds)","help":"Max seconds before Telegram API requests are aborted (default: 500 per grammY)."},"mediaGroupFlushMs":{"label":"Telegram Media Group Flush (ms)","help":"Milliseconds to buffer Telegram albums/media groups before dispatching them as one inbound message. Default: 500."},"pollingStallThresholdMs":{"label":"Telegram Polling Stall Threshold (ms)","help":"Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths inside these roots are read directly; all other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"twitch","channelId":"twitch","label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"selfChatMode":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVis', + 'ibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"chunkMode":{"type":"string","enum":["length","newline"]},"blockStreaming":{"type":"boolean"},"blockStreamingCoalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","debounceMs","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 052121cf35c..6b87eaacaf6 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -47,7 +47,7 @@ describe("config env vars", () => { } }`); - expect(() => applyConfigEnvVars(cfg)).not.toThrow(); + expect(applyConfigEnvVars(cfg)).toBeUndefined(); expect(process.env.API_TOKEN).toBe("sk-test-123"); expect(process.env.PORT).toBeUndefined(); expect(process.env.DEBUG).toBeUndefined(); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 67d4a6bb4d2..dea22a1281e 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -2,6 +2,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; +import { normalizeAgentModelMapForConfig } from "./model-input.js"; import { applyProviderConfigDefaultsForConfig, normalizeProviderConfigForConfigDefaults, @@ -255,7 +256,11 @@ export function applyModelDefaults( if (!existingAgent) { return mutated ? nextCfg : cfg; } - const existingModels = existingAgent.models ?? {}; + const rawExistingModels = existingAgent.models ?? {}; + const existingModels = normalizeAgentModelMapForConfig(rawExistingModels); + if (existingModels !== rawExistingModels) { + mutated = true; + } if (Object.keys(existingModels).length === 0) { return mutated ? nextCfg : cfg; } diff --git a/src/config/future-version-guard.test.ts b/src/config/future-version-guard.test.ts index 95216649d70..d232955a58a 100644 --- a/src/config/future-version-guard.test.ts +++ b/src/config/future-version-guard.test.ts @@ -4,6 +4,7 @@ import { formatFutureConfigActionBlock, resolveFutureConfigActionBlock, } from "./future-version-guard.js"; +import type { FutureConfigActionBlock } from "./future-version-guard.js"; import type { ConfigFileSnapshot } from "./types.js"; function snapshotWithTouchedVersion( @@ -15,6 +16,13 @@ function snapshotWithTouchedVersion( }; } +function expectFutureActionBlock(block: FutureConfigActionBlock | null): FutureConfigActionBlock { + if (block === null) { + throw new Error("Expected destructive action to be blocked by future config version"); + } + return block; +} + describe("resolveFutureConfigActionBlock", () => { it("blocks destructive actions from older binaries", () => { const block = resolveFutureConfigActionBlock({ @@ -24,10 +32,11 @@ describe("resolveFutureConfigActionBlock", () => { env: {}, }); - expect(block?.message).toContain("Refusing to restart the gateway service"); - expect(block?.message).toContain("2026.4.5"); - expect(block?.message).toContain("2026.4.23"); - expect(formatFutureConfigActionBlock(block!)).toContain( + const actionBlock = expectFutureActionBlock(block); + expect(actionBlock.message).toContain("Refusing to restart the gateway service"); + expect(actionBlock.message).toContain("2026.4.5"); + expect(actionBlock.message).toContain("2026.4.23"); + expect(formatFutureConfigActionBlock(actionBlock)).toContain( ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV, ); }); diff --git a/src/config/gateway-control-ui-origins.test.ts b/src/config/gateway-control-ui-origins.test.ts index f8172c7a410..b7d65859314 100644 --- a/src/config/gateway-control-ui-origins.test.ts +++ b/src/config/gateway-control-ui-origins.test.ts @@ -27,7 +27,9 @@ describe("ensureControlUiAllowedOriginsForNonLoopbackBind", () => { ); expect(result.bind).toBe("lan"); - expect(result.seededOrigins).not.toBeNull(); + expect(result.seededOrigins).toEqual( + expect.arrayContaining(["http://localhost:18789", "http://127.0.0.1:18789"]), + ); }); it("uses runtime loopback before config non-loopback and avoids seeding", () => { diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index 78894b43b28..d60a3069854 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -24,7 +24,7 @@ describe("readBestEffortConfig", () => { expect(snapshot.sourceConfig).toEqual({ update: { channel: "beta" } }); expect(await fs.readFile(configPath, "utf-8")).toBe(directEditRaw); const entries = await fs.readdir(`${home}/.openclaw`); - expect(entries.filter((entry) => entry.startsWith("openclaw.json.clobbered."))).toEqual([]); + expect(entries.some((entry) => entry.startsWith("openclaw.json.clobbered."))).toBe(false); }); }); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index fecf0ac8f77..ccf59142494 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -156,7 +156,7 @@ describe("config io write", () => { }); await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true }); - expect(() => io.loadConfig()).not.toThrow(); + expect(io.loadConfig()).toMatchObject({ gateway: { mode: "local" } }); expect(warn).toHaveBeenCalledWith( expect.stringContaining( @@ -463,11 +463,8 @@ describe("config io write", () => { }); await fs.writeFile(path.join(unwritableStatePath, "plugins"), "not a directory", "utf-8"); - let loadedConfig: ReturnType | undefined; - expect(() => { - loadedConfig = io.loadConfig(); - }).not.toThrow(); - expect(loadedConfig?.plugins?.installs?.demo).toMatchObject({ + const loadedConfig = io.loadConfig(); + expect(loadedConfig.plugins?.installs?.demo).toMatchObject({ source: "npm", spec: "demo@1.0.0", installPath: pluginDir, @@ -741,7 +738,9 @@ describe("config io write", () => { ]); await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(cleanRaw); const entries = await fs.readdir(path.dirname(configPath)); - expect(entries.filter((entry) => entry.includes(".clobbered."))).toHaveLength(1); + expect( + entries.reduce((count, entry) => count + (entry.includes(".clobbered.") ? 1 : 0), 0), + ).toBe(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining("Config auto-stripped non-JSON prefix:"), ); @@ -830,7 +829,9 @@ describe("config io write", () => { await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); const entries = await fs.readdir(path.dirname(configPath)); - expect(entries.filter((entry) => entry.includes(".rejected."))).toHaveLength(1); + expect( + entries.reduce((count, entry) => count + (entry.includes(".rejected.") ? 1 : 0), 0), + ).toBe(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining("Config write rejected:")); }); }); @@ -1331,7 +1332,9 @@ describe("config io write", () => { expect(postWriteSnapshot.valid).toBe(true); expect(observedSources).toEqual([postWriteSnapshot.sourceConfig]); expect(getRuntimeConfigSourceSnapshot()).toEqual(postWriteSnapshot.sourceConfig); - expect(postWriteSnapshot.sourceConfig.meta?.lastTouchedAt).toEqual(expect.any(String)); + expect(postWriteSnapshot.sourceConfig.meta?.lastTouchedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); expect(postWriteSnapshot.sourceConfig.plugins?.entries?.demo?.config).toEqual({}); } finally { unsubscribe(); @@ -1386,7 +1389,7 @@ describe("config io write", () => { plugins: { entries: { "strict-plugin": { enabled: true } } }, }; - await expect(writeConfigFile(cfg, { skipPluginValidation: true })).resolves.not.toThrow(); + await writeConfigFile(cfg, { skipPluginValidation: true }); await expect(fs.readFile(configPath, "utf-8")).resolves.toContain('"strict-plugin"'); await expect(writeConfigFile(cfg, { skipPluginValidation: false })).rejects.toThrow( diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 248c4872e13..65454b15ca2 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -131,6 +131,44 @@ describe("config io write prepare", () => { expect(persisted.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]); }); + it("preserves authored Google model params under normalized config keys", () => { + const sourceConfig: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "google/gemini-3-pro-preview" }, + models: { + "google/gemini-3-pro-preview": { + alias: "Gemini", + params: { thinking: { level: "high" } }, + }, + }, + }, + }, + }; + const persisted = resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + nextConfig: { + agents: { + defaults: { + model: { primary: "google/gemini-3.1-pro-preview" }, + models: { + "google/gemini-3.1-pro-preview": {}, + }, + }, + }, + }, + }) as OpenClawConfig; + + expect(persisted.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + }); + expect(persisted.agents?.defaults?.models).not.toHaveProperty("google/gemini-3-pro-preview"); + expect(persisted.agents?.defaults?.models?.["google/gemini-3.1-pro-preview"]).toEqual({ + params: { thinking: { level: "high" } }, + }); + }); + it("allows explicit unsets to remove authored agent provider params", () => { const sourceConfig: OpenClawConfig = { agents: { diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 94ca27d9af6..d82076da61a 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from "node:util"; import { isRecord } from "../utils.js"; import { applyMergePatch } from "./merge-patch.js"; +import { normalizeAgentModelRefForConfig } from "./model-input.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; import type { OpenClawConfig } from "./types.js"; @@ -153,6 +154,23 @@ function setPathValueCreatingParents(value: unknown, path: string[], nextValue: }; } +function deletePathValue(value: unknown, path: string[]): unknown { + if (path.length === 0 || !isRecord(value)) { + return value; + } + const [head, ...tail] = path; + if (!Object.prototype.hasOwnProperty.call(value, head)) { + return value; + } + const next: Record = { ...value }; + if (tail.length === 0) { + delete next[head]; + return next; + } + next[head] = deletePathValue(value[head], tail); + return next; +} + function preserveSourceValueAtPath(params: { persistedCandidate: unknown; sourceConfig: unknown; @@ -211,8 +229,16 @@ function preserveAuthoredAgentParams(params: { if (!isRecord(modelEntry) || !Object.prototype.hasOwnProperty.call(modelEntry, "params")) { continue; } - const modelPath = ["agents", "defaults", "models", modelId]; + const modelPath = [ + "agents", + "defaults", + "models", + normalizeAgentModelRefForConfig(modelId) || modelId, + ]; const paramsPath = [...modelPath, "params"]; + if (modelPath.at(-1) !== modelId) { + next = deletePathValue(next, ["agents", "defaults", "models", modelId]); + } if (getPathValue(next, modelPath) === undefined) { next = preserveSourceValueAtPath({ ...params, diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index f7ce7f5f4eb..4a3cb85a575 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -127,6 +127,24 @@ describe("applyModelDefaults", () => { ); }); + it("normalizes retired Gemini model keys before applying aliases", () => { + const cfg = { + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": {}, + }, + }, + }, + } satisfies OpenClawConfig; + + const next = applyModelDefaults(cfg); + + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { alias: "gemini" }, + }); + }); + it("fills missing model provider defaults", () => { const cfg = buildProxyProviderConfig(); diff --git a/src/config/model-input.ts b/src/config/model-input.ts index aed1c859cd4..aeae34621ca 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -15,6 +15,10 @@ type AgentModelListLike = { const GOOGLE_CONFIG_MODEL_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]); +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function modelKeyForConfig(provider: string, model: string): string { const providerId = provider.trim(); const modelId = model.trim(); @@ -79,3 +83,32 @@ export function normalizeAgentModelRefForConfig(model: string): string { const normalizedModel = normalizeGooglePreviewModelId(trimmed.slice(slash + 1)); return modelKeyForConfig(provider, normalizedModel); } + +function mergeAgentModelEntryForConfig(existing: unknown, incoming: unknown): unknown { + if (!isPlainRecord(existing) || !isPlainRecord(incoming)) { + return incoming; + } + + const existingParams = isPlainRecord(existing.params) ? existing.params : undefined; + const incomingParams = isPlainRecord(incoming.params) ? incoming.params : undefined; + return { + ...existing, + ...incoming, + ...(existingParams || incomingParams + ? { params: { ...existingParams, ...incomingParams } } + : undefined), + }; +} + +export function normalizeAgentModelMapForConfig>(models: T): T { + let mutated = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(models)) { + const normalizedKey = normalizeAgentModelRefForConfig(key); + if (normalizedKey !== key || Object.prototype.hasOwnProperty.call(next, normalizedKey)) { + mutated = true; + } + next[normalizedKey] = mergeAgentModelEntryForConfig(next[normalizedKey], entry); + } + return (mutated ? next : models) as T; +} diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index e78c0cd3401..bfcfbd121d3 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -69,7 +69,7 @@ function extractProviderFromModelRef(value: string): string | null { } function hasConfiguredEmbeddedHarnessRuntime(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - return collectConfiguredAgentHarnessRuntimes(cfg, env).length > 0; + return collectConfiguredAgentHarnessRuntimes(cfg, env, { includeEnvRuntime: false }).length > 0; } function resolveAgentHarnessOwnerPluginIds( @@ -641,7 +641,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env)) { + for (const runtime of collectConfiguredAgentHarnessRuntimes(params.config, params.env, { + includeEnvRuntime: false, + })) { const pluginIds = resolveAgentHarnessOwnerPluginIds(params.registry, runtime); for (const pluginId of pluginIds) { changes.push({ diff --git a/src/config/runtime-snapshot.test.ts b/src/config/runtime-snapshot.test.ts index 02cb30f0723..d670f428bfe 100644 --- a/src/config/runtime-snapshot.test.ts +++ b/src/config/runtime-snapshot.test.ts @@ -225,7 +225,7 @@ describe("runtime snapshot state", () => { const loadFreshConfig = vi.fn<() => OpenClawConfig>(() => ({ gateway: { auth: { mode: "token" } }, })); - let releaseRefresh!: () => void; + let releaseRefresh: (() => void) | undefined; const refreshPending = new Promise((resolve) => { releaseRefresh = () => resolve(true); }); @@ -287,6 +287,9 @@ describe("runtime snapshot state", () => { expect(getRuntimeConfigSnapshot()?.gateway?.auth).toBeUndefined(); expect(loadFreshConfig).not.toHaveBeenCalled(); + if (!releaseRefresh) { + throw new Error("Expected runtime snapshot refresh release callback to be initialized"); + } releaseRefresh(); await writePromise; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index eded1129a48..a8139ab2916 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -109,6 +109,8 @@ export const FIELD_HELP: Record = { 'Tailscale publish mode: "off", "serve", or "funnel" for private or public exposure paths. Use "serve" for tailnet-only access and "funnel" only when public internet reachability is required.', "gateway.tailscale.resetOnExit": "Resets Tailscale Serve/Funnel state on gateway exit to avoid stale published routes after shutdown. Keep enabled unless another controller manages publish lifecycle outside the gateway.", + "gateway.tailscale.preserveFunnel": + "When mode='serve' and an externally configured Tailscale Funnel route already covers the gateway port, skip re-applying tailscale serve on startup. Lets operators keep Funnel exposure managed outside OpenClaw without losing it across gateway restarts.", "gateway.remote": "Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.", "gateway.remote.transport": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 531f818795d..6a24d598275 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -129,6 +129,7 @@ export const FIELD_LABELS: Record = { "gateway.tailscale": "Gateway Tailscale", "gateway.tailscale.mode": "Gateway Tailscale Mode", "gateway.tailscale.resetOnExit": "Gateway Tailscale Reset on Exit", + "gateway.tailscale.preserveFunnel": "Gateway Tailscale Preserve External Funnel", "gateway.remote": "Remote Gateway", "gateway.remote.transport": "Remote Gateway Transport", "gateway.reload": "Config Reload", diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 9de3d1d4d4f..ddc6f072dcc 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -760,12 +760,15 @@ describe("sessions", () => { }); const createDeferred = () => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; }; const firstStarted = createDeferred(); diff --git a/src/config/sessions/store-writer.test.ts b/src/config/sessions/store-writer.test.ts index f6e91055344..473f40264a0 100644 --- a/src/config/sessions/store-writer.test.ts +++ b/src/config/sessions/store-writer.test.ts @@ -6,12 +6,15 @@ import { } from "./store.js"; const createDeferred = () => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; }; diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 431d8e90376..19a124132db 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -395,7 +395,10 @@ describe("Integration: saveSessionStore with pruning", () => { expect(persisted["agent:main:telegram:direct:6101296751"]).toBeUndefined(); await expect(fs.stat(directTranscript)).rejects.toThrow(); const files = await fs.readdir(testDir); - expect(files.some((name) => name.startsWith("direct-session.jsonl.deleted."))).toBe(true); + const archivedDirectTranscripts = files.filter((name) => + name.startsWith("direct-session.jsonl.deleted."), + ); + expect(archivedDirectTranscripts.length).toBeGreaterThan(0); }); it("sessions cleanup dry-run does not double-count artifacts already covered by disk budget", async () => { @@ -867,7 +870,10 @@ describe("Integration: saveSessionStore with pruning", () => { await expect(fs.stat(oldestTranscript)).rejects.toThrow(); await expectPathExists(newestTranscript); const files = await fs.readdir(testDir); - expect(files.some((name) => name.startsWith(`${oldestSessionId}.jsonl.deleted.`))).toBe(true); + const archivedOldestTranscripts = files.filter((name) => + name.startsWith(`${oldestSessionId}.jsonl.deleted.`), + ); + expect(archivedOldestTranscripts.length).toBeGreaterThan(0); }); it("does not archive external transcript paths when capping entries", async () => { diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 5f7c6d77bb7..7b389f1b3e1 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -42,6 +42,16 @@ function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenCl }; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) { const customRoot = path.join(home, "custom-state"); const storePaths = await createAgentSessionStores(customRoot, agentIds); @@ -186,7 +196,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); expectTargetsToContainStores(targets, storePaths); - expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); + expect(countMatching(targets, (target) => target.storePath === storePaths.ops)).toBe(1); }); }); @@ -195,7 +205,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]); expectTargetsToContainStores(targets, storePaths); - expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); + expect(countMatching(targets, (target) => target.storePath === storePaths.ops)).toBe(1); }); }); diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 8a7a0039a02..d69ebd37b01 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -129,11 +129,40 @@ export type DiscordVoiceAutoJoinConfig = { channelId: string; }; +export type DiscordVoiceMode = "stt-tts" | "talk-buffer" | "bidi"; + +export type DiscordVoiceRealtimeConsultPolicy = "auto" | "always"; + +export type DiscordVoiceRealtimeToolPolicy = "safe-read-only" | "owner" | "none"; + +export type DiscordVoiceRealtimeConfig = { + /** Realtime voice provider id, for example "openai". */ + provider?: string; + /** Provider realtime session model, for example "gpt-realtime-2". */ + model?: string; + /** Provider realtime output voice, for example "cedar". */ + voice?: string; + /** System instructions passed to the realtime provider. */ + instructions?: string; + /** Tool policy for bidi realtime consult calls. */ + toolPolicy?: DiscordVoiceRealtimeToolPolicy; + /** Whether bidi should force the OpenClaw agent brain for every substantive turn. */ + consultPolicy?: DiscordVoiceRealtimeConsultPolicy; + /** Debounce window before buffered transcripts are sent to the OpenClaw agent. */ + debounceMs?: number; + /** Provider-specific realtime voice config keyed by provider id. */ + providers?: Record | undefined>; +}; + export type DiscordVoiceConfig = { /** Enable Discord voice channel conversations (default: true). */ enabled?: boolean; + /** Voice conversation mode. Default: stt-tts. */ + mode?: DiscordVoiceMode; /** Optional LLM model override for Discord voice channel responses. */ model?: string; + /** Realtime provider settings for talk-buffer or bidi modes. */ + realtime?: DiscordVoiceRealtimeConfig; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 830fd49d795..fdbc89e97a5 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -196,6 +196,13 @@ export type GatewayTailscaleConfig = { mode?: GatewayTailscaleMode; /** Reset serve/funnel configuration on shutdown. */ resetOnExit?: boolean; + /** + * When `mode="serve"` and an externally configured Tailscale Funnel route + * already covers the gateway port, skip re-applying `tailscale serve` on + * startup. Lets operators manage Funnel exposure outside OpenClaw without + * losing it across gateway restarts. + */ + preserveFunnel?: boolean; }; export type GatewayRemoteConfig = { diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 95d2bdceea6..04738b37815 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -5,24 +5,24 @@ import { AgentEntrySchema } from "./zod-schema.agent-runtime.js"; describe("agent defaults schema", () => { it("accepts subagent archiveAfterMinutes=0 to disable archiving", () => { - expect(() => - AgentDefaultsSchema.parse({ + expect( + AgentDefaultsSchema.safeParse({ subagents: { archiveAfterMinutes: 0, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts videoGenerationModel", () => { - expect(() => - AgentDefaultsSchema.parse({ + expect( + AgentDefaultsSchema.safeParse({ videoGenerationModel: { primary: "qwen/wan2.6-t2v", fallbacks: ["minimax/video-01"], }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts imageGenerationModel timeoutMs", () => { @@ -48,11 +48,11 @@ describe("agent defaults schema", () => { }); it("accepts mediaGenerationAutoProviderFallback", () => { - expect(() => - AgentDefaultsSchema.parse({ + expect( + AgentDefaultsSchema.safeParse({ mediaGenerationAutoProviderFallback: false, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts experimental.localModelLean", () => { diff --git a/src/config/zod-schema.cron-retention.test.ts b/src/config/zod-schema.cron-retention.test.ts index a3733872956..e15cc0f3350 100644 --- a/src/config/zod-schema.cron-retention.test.ts +++ b/src/config/zod-schema.cron-retention.test.ts @@ -3,8 +3,8 @@ import { OpenClawSchema } from "./zod-schema.js"; describe("OpenClawSchema cron retention and run-log validation", () => { it("accepts valid cron.sessionRetention and runLog values", () => { - expect(() => - OpenClawSchema.parse({ + expect( + OpenClawSchema.safeParse({ cron: { sessionRetention: "1h30m", runLog: { @@ -13,7 +13,7 @@ describe("OpenClawSchema cron retention and run-log validation", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid cron.sessionRetention", () => { diff --git a/src/config/zod-schema.logging-levels.test.ts b/src/config/zod-schema.logging-levels.test.ts index 80a970720b5..93fc1b6d7e0 100644 --- a/src/config/zod-schema.logging-levels.test.ts +++ b/src/config/zod-schema.logging-levels.test.ts @@ -3,14 +3,14 @@ import { OpenClawSchema } from "./zod-schema.js"; describe("OpenClawSchema logging levels", () => { it("accepts valid logging level values for level and consoleLevel", () => { - expect(() => - OpenClawSchema.parse({ + expect( + OpenClawSchema.safeParse({ logging: { level: "debug", consoleLevel: "warn", }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid logging level values", () => { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3a6b59e8ca1..efd0e86e698 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -540,10 +540,27 @@ const DiscordVoiceAutoJoinSchema = z }) .strict(); +const DiscordVoiceRealtimeToolPolicySchema = z.enum(["safe-read-only", "owner", "none"]); +const DiscordVoiceRealtimeConsultPolicySchema = z.enum(["auto", "always"]); +const DiscordVoiceRealtimeSchema = z + .object({ + provider: z.string().min(1).optional(), + model: z.string().min(1).optional(), + voice: z.string().min(1).optional(), + instructions: z.string().min(1).optional(), + toolPolicy: DiscordVoiceRealtimeToolPolicySchema.optional(), + consultPolicy: DiscordVoiceRealtimeConsultPolicySchema.optional(), + debounceMs: z.number().int().positive().max(10_000).optional(), + providers: z.record(z.string(), z.record(z.string(), z.unknown()).optional()).optional(), + }) + .strict(); + const DiscordVoiceSchema = z .object({ enabled: z.boolean().optional(), + mode: z.enum(["stt-tts", "talk-buffer", "bidi"]).optional(), model: z.string().min(1).optional(), + realtime: DiscordVoiceRealtimeSchema.optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), daveEncryption: z.boolean().optional(), decryptionFailureTolerance: z.number().int().min(0).optional(), diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts index a30590bc655..8782fe9d896 100644 --- a/src/config/zod-schema.session-maintenance-extensions.test.ts +++ b/src/config/zod-schema.session-maintenance-extensions.test.ts @@ -3,13 +3,13 @@ import { SessionSchema } from "./zod-schema.session.js"; describe("SessionSchema maintenance extensions", () => { it("accepts session write-lock acquire timeout", () => { - expect(() => - SessionSchema.parse({ + expect( + SessionSchema.safeParse({ writeLock: { acquireTimeoutMs: 60_000, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid session write-lock acquire timeout values", () => { @@ -23,25 +23,25 @@ describe("SessionSchema maintenance extensions", () => { }); it("accepts valid maintenance extensions", () => { - expect(() => - SessionSchema.parse({ + expect( + SessionSchema.safeParse({ maintenance: { resetArchiveRetention: "14d", maxDiskBytes: "500mb", highWaterBytes: "350mb", }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts disabling reset archive cleanup", () => { - expect(() => - SessionSchema.parse({ + expect( + SessionSchema.safeParse({ maintenance: { resetArchiveRetention: false, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects invalid maintenance extension values", () => { diff --git a/src/config/zod-schema.talk.test.ts b/src/config/zod-schema.talk.test.ts index bbb7eb9f89f..111394283e8 100644 --- a/src/config/zod-schema.talk.test.ts +++ b/src/config/zod-schema.talk.test.ts @@ -3,13 +3,13 @@ import { OpenClawSchema } from "./zod-schema.js"; describe("OpenClawSchema talk validation", () => { it("accepts a positive integer talk.silenceTimeoutMs", () => { - expect(() => - OpenClawSchema.parse({ + expect( + OpenClawSchema.safeParse({ talk: { silenceTimeoutMs: 1500, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it.each([ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 13326011a66..d39ca664192 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -911,6 +911,7 @@ export const OpenClawSchema = z .object({ mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), resetOnExit: z.boolean().optional(), + preserveFunnel: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/config/zod-schema.tts.test.ts b/src/config/zod-schema.tts.test.ts index 7d2a2a335dc..35b504620ff 100644 --- a/src/config/zod-schema.tts.test.ts +++ b/src/config/zod-schema.tts.test.ts @@ -3,8 +3,8 @@ import { TtsConfigSchema } from "./zod-schema.core.js"; describe("TtsConfigSchema openai speed and instructions", () => { it("accepts speed and instructions in openai section", () => { - expect(() => - TtsConfigSchema.parse({ + expect( + TtsConfigSchema.safeParse({ providers: { openai: { voice: "alloy", @@ -13,12 +13,12 @@ describe("TtsConfigSchema openai speed and instructions", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts openai extraBody objects for compatible TTS endpoints", () => { - expect(() => - TtsConfigSchema.parse({ + expect( + TtsConfigSchema.safeParse({ providers: { openai: { baseUrl: "http://localhost:8880/v1", @@ -31,36 +31,36 @@ describe("TtsConfigSchema openai speed and instructions", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); - it("rejects out-of-range openai speed", () => { - expect(() => - TtsConfigSchema.parse({ + it("accepts out-of-range openai speed for provider passthrough", () => { + expect( + TtsConfigSchema.safeParse({ providers: { openai: { speed: 5.0, }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); - it("rejects openai speed below minimum", () => { - expect(() => - TtsConfigSchema.parse({ + it("accepts openai speed below minimum for provider passthrough", () => { + expect( + TtsConfigSchema.safeParse({ providers: { openai: { speed: 0.1, }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("accepts provider-specific persona bindings and structured prompt fields", () => { - expect(() => - TtsConfigSchema.parse({ + expect( + TtsConfigSchema.safeParse({ persona: "alfred", personas: { alfred: { @@ -92,7 +92,7 @@ describe("TtsConfigSchema openai speed and instructions", () => { }, }, }), - ).not.toThrow(); + ).toMatchObject({ success: true }); }); it("rejects persona rewrite config until runtime behavior exists", () => { diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 70bff373e5f..5245ed853ce 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1127,8 +1127,8 @@ describe("Initialization guard", () => { it("ensureContextEnginesInitialized() is idempotent and registers legacy", async () => { const { ensureContextEnginesInitialized } = await import("./init.js"); - expect(() => ensureContextEnginesInitialized()).not.toThrow(); - expect(() => ensureContextEnginesInitialized()).not.toThrow(); + expect(ensureContextEnginesInitialized()).toBeUndefined(); + expect(ensureContextEnginesInitialized()).toBeUndefined(); const ids = listContextEngineIds(); expect(ids).toContain("legacy"); diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 653dc31a8c4..bb5d2fc4e72 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -12,6 +12,14 @@ import { type TestConfig = Record; +function parseLastJsonLine(raw: string): unknown { + const lastLine = raw.trim().split("\n").at(-1); + if (!lastLine) { + throw new Error("Expected audit log to contain at least one JSON line"); + } + return JSON.parse(lastLine) as unknown; +} + const mockConfig = vi.hoisted(() => { const initial = {}; const state = { @@ -550,7 +558,7 @@ describe("parseCrestodianOperation", () => { }); expect(lines.join("\n")).toContain("[crestodian] done: doctor.fix"); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); - const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim().split("\n").at(-1)!); + const audit = parseLastJsonLine(await fs.readFile(auditPath, "utf8")); expect(audit).toMatchObject({ operation: "doctor.fix", summary: "Ran doctor repairs", diff --git a/src/crestodian/rescue-channel.live.test.ts b/src/crestodian/rescue-channel.live.test.ts index b81ec4e0c20..b0a10b9f665 100644 --- a/src/crestodian/rescue-channel.live.test.ts +++ b/src/crestodian/rescue-channel.live.test.ts @@ -101,8 +101,8 @@ describeLive("Crestodian live rescue channel smoke", () => { expect(config.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" }); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); const auditLines = (await fs.readFile(auditPath, "utf8")).trim().split("\n"); - expect(auditLines.some((line) => line.includes('"operation":"config.setDefaultModel"'))).toBe( - true, + expect(auditLines).toEqual( + expect.arrayContaining([expect.stringContaining('"operation":"config.setDefaultModel"')]), ); }); }); diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index ba069b17013..000d3be929d 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -69,9 +69,7 @@ describe("cron protocol conformance", () => { for (const relPath of UI_FILES) { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); for (const mode of modes) { - expect(content.includes(`"${mode}"`), `${relPath} missing delivery mode ${mode}`).toBe( - true, - ); + expect(content, `${relPath} missing delivery mode ${mode}`).toContain(`"${mode}"`); } } @@ -80,7 +78,7 @@ describe("cron protocol conformance", () => { const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); for (const mode of modes) { const pattern = new RegExp(`\\bcase\\s+${mode}\\b`); - expect(pattern.test(content), `${relPath} missing case ${mode}`).toBe(true); + expect(content, `${relPath} missing case ${mode}`).toMatch(pattern); } } }); @@ -88,15 +86,15 @@ describe("cron protocol conformance", () => { it("cron status shape matches gateway fields in UI + Swift", async () => { const cwd = process.cwd(); const uiTypes = await fs.readFile(path.join(cwd, "ui/src/ui/types.ts"), "utf-8"); - expect(uiTypes.includes("export type CronStatus")).toBe(true); - expect(uiTypes.includes("jobs:")).toBe(true); - expect(uiTypes.includes("jobCount")).toBe(false); + expect(uiTypes).toContain("export type CronStatus"); + expect(uiTypes).toContain("jobs:"); + expect(uiTypes).not.toContain("jobCount"); const [swiftRelPath] = await resolveSwiftFiles(cwd, SWIFT_STATUS_CANDIDATES); const swiftPath = path.join(cwd, swiftRelPath); const swift = await fs.readFile(swiftPath, "utf-8"); - expect(swift.includes("struct CronSchedulerStatus")).toBe(true); - expect(swift.includes("let jobs:")).toBe(true); + expect(swift).toContain("struct CronSchedulerStatus"); + expect(swift).toContain("let jobs:"); }); it("cron job state schema keeps the full failover reason set", () => { diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index 9bd543d7d60..97f0f52e98c 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -55,7 +55,8 @@ describe("runCronIsolatedAgentTurn session identity", () => { const lines = call?.prompt?.split("\n") ?? []; expect(lines[0]).toContain("[cron:job-1"); expect(lines[0]).toContain("do it"); - expect(lines[1]).toMatch(/^Current time: .+ \(.+\) \/ \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/); + expect(lines[1]).toMatch(/^Current time: .+ \(.+\)$/); + expect(lines[2]).toMatch(/^Reference UTC: \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/); }); }); @@ -140,8 +141,8 @@ describe("runCronIsolatedAgentTurn session identity", () => { const first = (await runPingTurn()).res; const second = (await runPingTurn()).res; - expect(first.sessionId).toEqual(expect.any(String)); - expect(second.sessionId).toEqual(expect.any(String)); + expect(first.sessionId).toBeTypeOf("string"); + expect(second.sessionId).toBeTypeOf("string"); expect(second.sessionId).not.toBe(first.sessionId); expect(first.sessionKey).toMatch(/^agent:main:cron:job-1:run:/); expect(second.sessionKey).toMatch(/^agent:main:cron:job-1:run:/); diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 10f515c8530..188da219087 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -267,7 +267,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { describe("CLI session handoff (issue #29774)", () => { it("passes the cron abort signal to CLI runs and drops late CLI results", async () => { const abortController = new AbortController(); - let markCliStarted!: () => void; + let markCliStarted: (() => void) | undefined; const cliStarted = new Promise((resolve) => { markCliStarted = resolve; }); @@ -275,6 +275,9 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { isCliProviderMock.mockReturnValue(true); runCliAgentMock.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { expect(params.abortSignal).toBe(abortController.signal); + if (!markCliStarted) { + throw new Error("Expected CLI start marker callback to be initialized"); + } markCliStarted(); await new Promise((resolve) => { params.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); diff --git a/src/cron/service.armtimer-tight-loop.test.ts b/src/cron/service.armtimer-tight-loop.test.ts index 87e74b84387..a63af5cdd87 100644 --- a/src/cron/service.armtimer-tight-loop.test.ts +++ b/src/cron/service.armtimer-tight-loop.test.ts @@ -90,7 +90,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = extractTimeoutDelays(timeoutSpy); // Before the fix, delay would be 0 (tight loop). @@ -171,7 +171,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = extractTimeoutDelays(timeoutSpy); expect(delays).toContain(60_000); @@ -208,7 +208,7 @@ describe("CronService - armTimer tight loop prevention", () => { await onTimer(state); expect(state.running).toBe(false); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); // The re-armed timer must NOT use delay=0. It should use at least // MIN_REFIRE_GAP_MS to prevent the hot-loop. diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index 2cbdffbe096..e7d0de9e582 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -41,6 +41,19 @@ describe("CronService interval/cron jobs fire on time", () => { ); }; + const countMainSystemEvents = ( + enqueueSystemEvent: ReturnType, + expectedText: string, + ): number => { + let count = 0; + for (const [text] of enqueueSystemEvent.mock.calls) { + if (text === expectedText) { + count++; + } + } + return count; + }; + it("fires an every-type main job when the timer fires a few ms late", async () => { const store = await makeStorePath(); const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ @@ -171,10 +184,8 @@ describe("CronService interval/cron jobs fire on time", () => { const sfRun = await cron.run("legacy-every", "due"); expect(sfRun).toEqual({ ok: true, ran: true }); - const sfRuns = enqueueSystemEvent.mock.calls.filter((args) => args[0] === "sf-tick").length; - const minuteRuns = enqueueSystemEvent.mock.calls.filter( - (args) => args[0] === "minute-tick", - ).length; + const sfRuns = countMainSystemEvents(enqueueSystemEvent, "sf-tick"); + const minuteRuns = countMainSystemEvents(enqueueSystemEvent, "minute-tick"); expect(minuteRuns).toBeGreaterThan(0); expect(sfRuns).toBeGreaterThan(0); diff --git a/src/cron/service.issue-13992-regression.test.ts b/src/cron/service.issue-13992-regression.test.ts index 63c643ff73f..bbcdf399f5f 100644 --- a/src/cron/service.issue-13992-regression.test.ts +++ b/src/cron/service.issue-13992-regression.test.ts @@ -189,7 +189,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const state = createMockCronStateForJobs({ jobs: [dueJob, malformedJob], nowMs: now }); - expect(() => recomputeNextRunsForMaintenance(state)).not.toThrow(); + expect(recomputeNextRunsForMaintenance(state)).toBe(true); expect(dueJob.state.nextRunAtMs).toBe(pastDue); expect(malformedJob.state.nextRunAtMs).toBeUndefined(); expect(malformedJob.state.scheduleErrorCount).toBe(1); diff --git a/src/cron/service.issue-22895-every-next-run.test.ts b/src/cron/service.issue-22895-every-next-run.test.ts index 0104d53e040..d11d111b9bd 100644 --- a/src/cron/service.issue-22895-every-next-run.test.ts +++ b/src/cron/service.issue-22895-every-next-run.test.ts @@ -21,6 +21,13 @@ function createEveryJob(state: CronJob["state"]): CronJob { }; } +function expectTimestamp(value: number | undefined | null, label: string): number { + if (typeof value !== "number") { + throw new Error(`Expected ${label} timestamp`); + } + return value; +} + describe("Cron issue #22895 interval scheduling", () => { it("uses lastRunAtMs cadence when the next interval is still in the future", () => { const nowMs = Date.parse("2026-02-22T10:10:00.000Z"); @@ -34,9 +41,9 @@ describe("Cron issue #22895 interval scheduling", () => { nowMs, ); - expect(nextFromLast).toBe(job.state.lastRunAtMs! + EVERY_30_MIN_MS); + expect(nextFromLast).toBe(expectTimestamp(job.state.lastRunAtMs, "last run") + EVERY_30_MIN_MS); expect(nextFromAnchor).toBe(Date.parse("2026-02-22T10:14:00.000Z")); - expect(nextFromLast).toBeGreaterThan(nextFromAnchor!); + expect(nextFromLast).toBeGreaterThan(expectTimestamp(nextFromAnchor, "next anchor run")); }); it("falls back to anchor scheduling when lastRunAtMs cadence is already in the past", () => { diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index cacaa2cfd13..6aae9d4b1b4 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -213,7 +213,8 @@ describe("Cron issue regressions", () => { const cron = await startCronForStore({ storePath: store.storePath, cronEnabled: false }); const listed = await cron.list(); - expect(listed.some((job) => job.id === "missing-enabled-update")).toBe(true); + const listedJobIds = listed.map((job) => job.id); + expect(listedJobIds).toContain("missing-enabled-update"); const updated = await cron.update("missing-enabled-update", { schedule: { kind: "cron", expr: "0 */3 * * *", tz: "UTC" }, diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 6a6085e2c37..b30c76cbc69 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -59,7 +59,7 @@ describe("applyJobPatch", () => { to: "123", }); - expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); + applyJobPatch(job, switchToMainPatch()); expect(job.sessionTarget).toBe("main"); expect(job.payload.kind).toBe("systemEvent"); expect(job.delivery).toBeUndefined(); @@ -71,7 +71,7 @@ describe("applyJobPatch", () => { to: "https://example.invalid/cron", }); - expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); + applyJobPatch(job, switchToMainPatch()); expect(job.sessionTarget).toBe("main"); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" }); }); @@ -92,7 +92,7 @@ describe("applyJobPatch", () => { }, }; - expect(() => applyJobPatch(job, patch)).not.toThrow(); + applyJobPatch(job, patch); expect(job.payload.kind).toBe("agentTurn"); if (job.payload.kind === "agentTurn") { expect(job.payload.message).toBe("do it"); @@ -344,9 +344,7 @@ describe("applyJobPatch", () => { to: "https://example.invalid/original", }); - expect(() => - applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }), - ).not.toThrow(); + applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/trim" }); }); @@ -391,7 +389,7 @@ describe("applyJobPatch", () => { to: " https://example.invalid/failure ", }, }; - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + applyJobPatch(job, { enabled: true }); expect(job.delivery?.failureDestination?.to).toBe("https://example.invalid/failure"); }); @@ -402,7 +400,7 @@ describe("applyJobPatch", () => { to: "-10012345/6789", }); - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + applyJobPatch(job, { enabled: true }); expect(job.delivery?.to).toBe("-10012345/6789"); }); @@ -421,7 +419,8 @@ describe("applyJobPatch", () => { ...(to ? { to } : {}), }); - expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + applyJobPatch(job, { enabled: true }); + expect(job.enabled).toBe(true); }); }); @@ -453,7 +452,8 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { { name: "case-insensitive defaultAgentId match", defaultAgentId: "Main", agentId: "MAIN" }, ] as const)("allows creating a main-session job for $name", ({ defaultAgentId, agentId }) => { const state = createMockState(now, { defaultAgentId }); - expect(() => createJob(state, mainJobInput(agentId))).not.toThrow(); + const job = createJob(state, mainJobInput(agentId)); + expect(job.sessionTarget).toBe("main"); }); it.each([ @@ -468,7 +468,7 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { it("allows isolated session job for non-default agents", () => { const state = createMockState(now, { defaultAgentId: "main" }); - expect(() => + expect( createJob(state, { name: "isolated-job", enabled: true, @@ -478,7 +478,10 @@ describe("createJob rejects sessionTarget main for non-default agents", () => { payload: { kind: "agentTurn", message: "do it" }, agentId: "custom-agent", }), - ).not.toThrow(); + ).toMatchObject({ + agentId: "custom-agent", + sessionTarget: "isolated", + }); }); it("rejects custom session targets with path separators", () => { @@ -544,7 +547,8 @@ describe("applyJobPatch rejects sessionTarget main for non-default agents", () = ); return; } - expect(() => applyJobPatch(job, patch, { defaultAgentId: "main" })).not.toThrow(); + applyJobPatch(job, patch, { defaultAgentId: "main" }); + expect(job.agentId).toBe("main"); }); it("rejects patching to a custom session target with path separators", () => { @@ -921,7 +925,7 @@ describe("recomputeNextRuns", () => { expect(job.state.nextRunAtMs).toBe(expected); }); - it("does not throw while probing malformed cron schedules with future nextRunAtMs", () => { + it("keeps future nextRunAtMs while probing malformed cron schedules", () => { const now = Date.parse("2026-05-05T12:00:00.000Z"); const future = Date.parse("2026-05-12T16:00:00.000Z"); const job: CronJob = { @@ -941,7 +945,7 @@ describe("recomputeNextRuns", () => { store: { version: 1 as const, jobs: [job] }, } as CronServiceState; - expect(() => recomputeNextRunsForMaintenance(state)).not.toThrow(); + recomputeNextRunsForMaintenance(state); expect(job.state.nextRunAtMs).toBe(future); expect(job.state.scheduleErrorCount).toBeUndefined(); }); diff --git a/src/cron/service.list-page-sort-guards.test.ts b/src/cron/service.list-page-sort-guards.test.ts index 26a85fa0f75..01c4dbf5148 100644 --- a/src/cron/service.list-page-sort-guards.test.ts +++ b/src/cron/service.list-page-sort-guards.test.ts @@ -20,7 +20,7 @@ function createBaseJob(overrides?: Partial): CronJob { } describe("cron listPage sort guards", () => { - it("does not throw when sorting by name with malformed name fields", async () => { + it("keeps malformed name fields sortable", async () => { const jobs = [ createBaseJob({ id: "job-a", name: undefined as unknown as string }), createBaseJob({ id: "job-b", name: "beta" }), @@ -31,7 +31,7 @@ describe("cron listPage sort guards", () => { expect(page.jobs).toHaveLength(2); }); - it("does not throw when tie-break sorting encounters missing ids", async () => { + it("keeps missing ids sortable during tie-breaks", async () => { const nextRunAtMs = Date.parse("2026-02-27T15:30:00.000Z"); const jobs = [ createBaseJob({ diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index 311dffb995c..72d5c74337a 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -128,8 +128,10 @@ describe("CronService read ops while job is running", () => { await isolatedRun.runStarted; expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1); - await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object"); - await expect(cron.status()).resolves.toBeTypeOf("object"); + await expect(cron.list({ includeDisabled: true })).resolves.toHaveLength(1); + await expect(cron.status()).resolves.toEqual( + expect.objectContaining({ enabled: true, storePath: store.storePath }), + ); const running = await cron.list({ includeDisabled: true }); expect(running[0]?.state.runningAtMs).toBeTypeOf("number"); @@ -197,7 +199,7 @@ describe("CronService read ops while job is running", () => { await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during cron.run"), - ).resolves.toBeTypeOf("object"); + ).resolves.toHaveLength(1); await expect(withTimeout(cron.status(), 300, "cron.status during cron.run")).resolves.toEqual( expect.objectContaining({ enabled: true, storePath: store.storePath }), ); @@ -258,7 +260,7 @@ describe("CronService read ops while job is running", () => { await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during startup"), - ).resolves.toBeTypeOf("object"); + ).resolves.toHaveLength(1); await expect(withTimeout(cron.status(), 300, "cron.status during startup")).resolves.toEqual( expect.objectContaining({ enabled: true, storePath: store.storePath }), ); diff --git a/src/cron/service.rearm-timer-when-running.test.ts b/src/cron/service.rearm-timer-when-running.test.ts index be9be3d3704..83909cb699f 100644 --- a/src/cron/service.rearm-timer-when-running.test.ts +++ b/src/cron/service.rearm-timer-when-running.test.ts @@ -35,10 +35,13 @@ function createDueRecurringJob(params: { } function createDeferred() { - let resolve!: (value: T) => void; + let resolve: ((value: T) => void) | undefined; const promise = new Promise((res) => { resolve = res; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -78,7 +81,7 @@ describe("CronService - timer re-arm when running (#12025)", () => { // The timer must be re-armed so the scheduler continues ticking, // with a fixed 60s delay to avoid hot-looping. - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); expect(timeoutSpy).toHaveBeenCalled(); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) @@ -138,7 +141,7 @@ describe("CronService - timer re-arm when running (#12025)", () => { await Promise.resolve(); expect(settled).toBe(false); expect(state.running).toBe(true); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) diff --git a/src/cron/service.session-reaper-in-finally.test.ts b/src/cron/service.session-reaper-in-finally.test.ts index 8cec12738df..ef209fef14c 100644 --- a/src/cron/service.session-reaper-in-finally.test.ts +++ b/src/cron/service.session-reaper-in-finally.test.ts @@ -84,7 +84,7 @@ describe("CronService - session reaper runs in finally block (#31946)", () => { expect(state.running).toBe(false); // The timer must be re-armed. - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); }); }); diff --git a/src/cron/service/ops.regression.test.ts b/src/cron/service/ops.regression.test.ts index f3d8e84dc46..5f7502f127c 100644 --- a/src/cron/service/ops.regression.test.ts +++ b/src/cron/service/ops.regression.test.ts @@ -27,7 +27,7 @@ const opsRegressionFixtures = setupCronRegressionFixtures({ }); describe("cron service ops regressions", () => { - it("does not crash startup when a loaded job is missing state", async () => { + it("repairs missing job state during startup", async () => { const scheduledAt = Date.now() + 60_000; const store = opsRegressionFixtures.makeStorePath(); const state = createCronServiceState({ @@ -55,7 +55,7 @@ describe("cron service ops regressions", () => { }; await expect(start(state)).resolves.toBeUndefined(); - expect(state.store.jobs[0]?.state).toEqual(expect.any(Object)); + expect(state.store.jobs[0]?.state).toMatchObject({ nextRunAtMs: scheduledAt }); }); it("skips forced manual runs while a timer-triggered run is in progress", async () => { diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 1cbe711621a..1ef577ab6e3 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -160,7 +160,7 @@ describe("cron service ops seam coverage", () => { ); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(requestHeartbeat).not.toHaveBeenCalled(); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const persisted = (await loadCronStore(storePath)) as { jobs: CronJob[]; @@ -179,7 +179,8 @@ describe("cron service ops seam coverage", () => { const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) .filter((delay): delay is number => typeof delay === "number"); - expect(delays.some((delay) => delay > 0)).toBe(true); + const positiveDelays = delays.filter((delay) => delay > 0); + expect(positiveDelays.length).toBeGreaterThan(0); timeoutSpy.mockRestore(); stop(state); diff --git a/src/cron/service/store.load-missing-session-target.test.ts b/src/cron/service/store.load-missing-session-target.test.ts index bdcaade72c9..b609ed3f172 100644 --- a/src/cron/service/store.load-missing-session-target.test.ts +++ b/src/cron/service/store.load-missing-session-target.test.ts @@ -61,8 +61,8 @@ describe("cron service store load: missing sessionTarget", () => { message: "watch dbus", toolsAllow: ["exec"], }); - expect(job.state.nextRunAtMs).toEqual(expect.any(Number)); - expect(() => assertSupportedJobSpec(job)).not.toThrow(); + expect(job.state.nextRunAtMs).toBeGreaterThan(STORE_TEST_NOW); + expect(assertSupportedJobSpec(job)).toBeUndefined(); }); it('defaults missing sessionTarget to "main" for systemEvent payloads', async () => { @@ -85,7 +85,7 @@ describe("cron service store load: missing sessionTarget", () => { const job = findJobOrThrow(state, "missing-session-target-system-event"); expect(job.sessionTarget).toBe("main"); - expect(() => assertSupportedJobSpec(job)).not.toThrow(); + expect(assertSupportedJobSpec(job)).toBeUndefined(); }); it('defaults missing sessionTarget to "isolated" for agentTurn payloads', async () => { @@ -108,7 +108,7 @@ describe("cron service store load: missing sessionTarget", () => { const job = findJobOrThrow(state, "missing-session-target-agent-turn"); expect(job.sessionTarget).toBe("isolated"); - expect(() => assertSupportedJobSpec(job)).not.toThrow(); + expect(assertSupportedJobSpec(job)).toBeUndefined(); }); it("assertSupportedJobSpec throws a clear error when sessionTarget is missing", () => { diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 67e82ae9e77..66c4f5d9536 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -97,7 +97,7 @@ describe("cron service timer regressions", () => { await onTimer(state); expect(timeoutSpy).toHaveBeenCalled(); - expect(state.timer).not.toBeNull(); + expect(state.timer).toEqual(expect.anything()); const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) .filter((d): d is number => typeof d === "number"); diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index b9dbc54df9e..5e7ad7fcdc4 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -89,7 +89,8 @@ describe("cron service timer seam coverage", () => { const delays = timeoutSpy.mock.calls .map(([, delay]) => delay) .filter((delay): delay is number => typeof delay === "number"); - expect(delays.some((delay) => delay > 0)).toBe(true); + const positiveDelays = delays.filter((delay) => delay > 0); + expect(positiveDelays.length).toBeGreaterThan(0); timeoutSpy.mockRestore(); }); diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index bad63ad7871..acbac6dbf7c 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -136,7 +136,10 @@ describe("sweepCronRunSessions", () => { expect(result.pruned).toBe(1); expect(fs.existsSync(runTranscript)).toBe(false); const files = fs.readdirSync(tmpDir); - expect(files.some((name) => name.startsWith(`${runSessionId}.jsonl.deleted.`))).toBe(true); + const archivedRunTranscripts = files.filter((name) => + name.startsWith(`${runSessionId}.jsonl.deleted.`), + ); + expect(archivedRunTranscripts.length).toBeGreaterThan(0); }); it("does not archive external transcript paths for pruned runs", async () => { diff --git a/src/cron/stagger.test.ts b/src/cron/stagger.test.ts index a2c2cdd60ec..949f8e866db 100644 --- a/src/cron/stagger.test.ts +++ b/src/cron/stagger.test.ts @@ -35,9 +35,6 @@ describe("cron stagger helpers", () => { }); it("handles missing runtime expr values without throwing", () => { - expect(() => - resolveCronStaggerMs({ kind: "cron" } as unknown as { kind: "cron"; expr: string }), - ).not.toThrow(); expect( resolveCronStaggerMs({ kind: "cron" } as unknown as { kind: "cron"; expr: string }), ).toBe(0); diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index b8a1c7d8f24..5f4dbf4725b 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -294,7 +294,8 @@ describeLaunchdIntegration("launchd integration", () => { }); const events = await fs.readFile(eventsPath, "utf8"); const lines = events.trim().split(/\r?\n/).filter(Boolean); - expect(lines.filter((line) => line.startsWith("start "))).toHaveLength(1); - expect(lines.some((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line))).toBe(false); + expect(lines.reduce((count, line) => count + (line.startsWith("start ") ? 1 : 0), 0)).toBe(1); + const signalLines = lines.filter((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line)); + expect(signalLines).toEqual([]); }, 60_000); }); diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 01aa3fadd2a..a9543489766 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -55,6 +55,16 @@ const cleanStaleGatewayProcessesSync = vi.hoisted(() => ); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function createDefaultLaunchdEnv(): Record { return { HOME: "/Users/test", @@ -417,9 +427,11 @@ describe("launchd bootstrap repair", () => { const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ - ["kickstart", serviceId], + expect(state.launchctlCalls.find((call) => call[0] === "kickstart")).toEqual([ + "kickstart", + serviceId, ]); + expect(countMatching(state.launchctlCalls, (call) => call[0] === "kickstart")).toBe(1); }); it("skips kickstart when already-loaded service is actively running", async () => { @@ -443,9 +455,11 @@ describe("launchd bootstrap repair", () => { const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ - ["kickstart", serviceId], + expect(state.launchctlCalls.find((call) => call[0] === "kickstart")).toEqual([ + "kickstart", + serviceId, ]); + expect(countMatching(state.launchctlCalls, (call) => call[0] === "kickstart")).toBe(1); }); it("keeps genuine bootstrap failures as failures", async () => { diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 06b0b750eb2..80eaf846fe0 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -124,7 +124,8 @@ describe("auditGatewayServiceConfig", () => { it("accepts canonical macOS gateway service PATH without user-bin defaults", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-service-audit-home-")); try { - const servicePath = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; + const servicePath = + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; const audit = await auditGatewayServiceConfig({ env: { HOME: home }, @@ -472,9 +473,10 @@ describe("checkTokenDrift", () => { it("detects drift when config has token but service has different token", () => { const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" }); - expect(result).not.toBeNull(); - expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift); - expect(result?.message).toContain("differs from service token"); + expect(result).toMatchObject({ + code: SERVICE_AUDIT_CODES.gatewayTokenDrift, + message: expect.stringContaining("differs from service token"), + }); }); it("returns null when config has token but service has no token", () => { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index baef93dc5f5..c9691e57a89 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -113,7 +113,17 @@ describe("getMinimalServicePathParts - Linux user directories", () => { existsSync: allExist, }); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); + const userPathEntries = result.filter((entry) => entry.startsWith("/Users/testuser/")); + expect(userPathEntries).toEqual([]); }); it("can include env-configured version manager dirs on macOS when requested", () => { @@ -145,7 +155,7 @@ describe("getMinimalServicePathParts - Linux user directories", () => { }); const fnmIndex = result.indexOf("/Users/testuser/.fnm/aliases/default/bin"); - const systemIndex = result.indexOf("/usr/local/bin"); + const systemIndex = result.indexOf("/opt/homebrew/bin"); expect(fnmIndex).toBe(-1); expect(systemIndex).toBe(0); @@ -191,7 +201,15 @@ describe("getMinimalServicePathParts - Linux user directories", () => { existsSync: noneExist, }); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); expect(result).not.toContain("/Users/testuser/.local/bin"); expect(result).not.toContain("/Users/testuser/.npm-global/bin"); expect(result).not.toContain("/Users/testuser/bin"); @@ -355,7 +373,15 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { }); expect(result).not.toContain("/Users/testuser/.nix-profile/bin"); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); }); it("places rightmost NIX_PROFILES entry before leftmost on Linux", () => { @@ -389,7 +415,15 @@ describe("getMinimalServicePathParts - Nix Home Manager", () => { const defaultIdx = result.indexOf("/nix/var/nix/profiles/default/bin"); expect(userIdx).toBe(-1); expect(defaultIdx).toBe(-1); - expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(result).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); }); it("includes single Nix profile from NIX_PROFILES on Linux", () => { @@ -450,7 +484,15 @@ describe("buildMinimalServicePath", () => { platform: "darwin", }); const parts = splitPath(result, "darwin"); - expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); + expect(parts).toEqual([ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]); }); it("returns PATH as-is on Windows", () => { @@ -607,7 +649,9 @@ describe("buildServiceEnvironment", () => { platform: "darwin", }); - expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"); + expect(env.PATH).toBe( + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ); }); it("falls back to os.tmpdir when TMPDIR is not set on Linux", () => { @@ -699,7 +743,7 @@ describe("buildServiceEnvironment", () => { }); expect(env.PATH).toBe( - "/opt/homebrew/Cellar/node/22.16.0/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "/opt/homebrew/Cellar/node/22.16.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", ); }); }); @@ -920,7 +964,7 @@ describe("shared Node TLS env defaults focused", () => { }); it("defaults NODE_EXTRA_CA_CERTS on Linux when NVM_DIR is set", () => { - const expected = resolveLinuxSystemCaBundle(); + const expected = resolveLinuxSystemCaBundle({ platform: "linux" }); const env = buildServiceEnvironment({ env: { HOME: "/home/user", NVM_DIR: "/home/user/.nvm" }, port: 18789, @@ -931,7 +975,7 @@ describe("shared Node TLS env defaults focused", () => { }); it("defaults NODE_EXTRA_CA_CERTS on Linux when execPath is under nvm", () => { - const expected = resolveLinuxSystemCaBundle(); + const expected = resolveLinuxSystemCaBundle({ platform: "linux" }); const env = buildNodeServiceEnvironment({ env: { HOME: "/home/user" }, platform: "linux", diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index ebc4d422d3e..1d0143a09af 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -222,7 +222,15 @@ function addNixProfileBinDirs( function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { if (platform === "darwin") { - return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + return [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]; } if (platform === "linux") { return ["/usr/local/bin", "/usr/bin", "/bin"]; diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 6e9b0341881..5321d71264c 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -61,6 +61,14 @@ const createWritableStreamMock = () => { }; }; +function requireFirstWrite(write: ReturnType): string { + const value = write.mock.calls[0]?.[0]; + if (value === undefined) { + throw new Error("expected systemd status write"); + } + return String(value); +} + function pathLikeToString(pathname: unknown): string { if (typeof pathname === "string") { return pathname; @@ -123,7 +131,7 @@ const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { const { write, stdout } = createWritableStreamMock(); await restartSystemdService({ stdout, env }); expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + expect(requireFirstWrite(write)).toContain("Restarted systemd service"); }; describe("systemd availability", () => { @@ -1188,7 +1196,7 @@ describe("systemd service install and uninstall", () => { await uninstallSystemdService({ env, stdout }); await expect(fs.access(unitPath)).rejects.toMatchObject({ code: "ENOENT" }); - expect(String(write.mock.calls[0]?.[0])).toContain("Removed systemd service"); + expect(requireFirstWrite(write)).toContain("Removed systemd service"); expect(execFileMock).toHaveBeenCalledTimes(2); }); }); @@ -1216,7 +1224,7 @@ describe("systemd service control", () => { await stopSystemdService({ stdout, env: {} }); expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Stopped systemd service"); + expect(requireFirstWrite(write)).toContain("Stopped systemd service"); }); it("allows stop when systemd status is degraded but available", async () => { diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index f9933767984..3865312e61d 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -53,7 +53,11 @@ describe("docker build cache layout", () => { expect(installIndex).toBeGreaterThan(-1); expect(copyAllIndex).toBeGreaterThan(installIndex); - expect(scriptsCopyIndex === -1 || scriptsCopyIndex > installIndex).toBe(true); + if (scriptsCopyIndex === -1) { + expect(scriptsCopyIndex).toBe(-1); + } else { + expect(scriptsCopyIndex).toBeGreaterThan(installIndex); + } }); it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => { diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index ae2646beb3f..9b4600bf998 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -114,6 +114,14 @@ function requireDependabotDockerUpdate(config: DependabotConfig): DependabotUpda return dockerUpdate; } +function requireDockerImageGroup(update: DependabotUpdate): DependabotDockerGroup { + const group = update.groups?.["docker-images"]; + if (!group) { + throw new Error("expected Dependabot docker-images group"); + } + return group; +} + describe("docker base image pinning", () => { it("pins selected Dockerfile FROM lines to immutable sha256 digests", async () => { for (const dockerfilePath of DIGEST_PINNED_DOCKERFILES) { @@ -141,8 +149,9 @@ describe("docker base image pinning", () => { const raw = await readFile(resolve(repoRoot, ".github/dependabot.yml"), "utf8"); const config = parse(raw) as DependabotConfig; const dockerUpdate = requireDependabotDockerUpdate(config); + const dockerImagesGroup = requireDockerImageGroup(dockerUpdate); expect(dockerUpdate.schedule?.interval).toBe("weekly"); - expect(dockerUpdate.groups?.["docker-images"]?.patterns).toContain("*"); + expect(dockerImagesGroup.patterns).toContain("*"); }); }); diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index d317f4ed0f4..5f69cbeda40 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -274,9 +274,10 @@ describe("scripts/docker/setup.sh", () => { expect(gatewayStartIdx).toBeGreaterThanOrEqual(0); const prestartLines = lines.slice(0, gatewayStartIdx); - expect(prestartLines.some((line) => /\bcompose\b.*\brun\b.*\bopenclaw-cli\b/.test(line))).toBe( - false, + const prestartCliRunLines = prestartLines.filter((line) => + /\bcompose\b.*\brun\b.*\bopenclaw-cli\b/.test(line), ); + expect(prestartCliRunLines).toEqual([]); }); it("forces BuildKit for local and sandbox docker builds", async () => { @@ -297,7 +298,10 @@ describe("scripts/docker/setup.sh", () => { line.startsWith("build "), ); expect(buildLines.length).toBeGreaterThanOrEqual(2); - expect(buildLines.every((line) => line.includes("DOCKER_BUILDKIT=1"))).toBe(true); + const buildLinesWithoutBuildKit = buildLines.filter( + (line) => !line.includes("DOCKER_BUILDKIT=1"), + ); + expect(buildLinesWithoutBuildKit).toEqual([]); }); it("precreates config identity dir for CLI device auth writes", async () => { diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index c6909cb1a5f..13cd6e73a5c 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -43,6 +43,7 @@ describe("ClawHub plugin docs", () => { expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]); expect(typeof pluginManifest.id).toBe("string"); expect(pluginManifest.configSchema).toEqual(expect.any(Object)); + expect(Array.isArray(pluginManifest.configSchema)).toBe(false); }); it("does not tell plugin authors to use bare clawhub publish", async () => { diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts index 42c635d58e3..a8dd88da965 100644 --- a/src/entry.respawn.test.ts +++ b/src/entry.respawn.test.ts @@ -10,6 +10,16 @@ import { runCliRespawnPlan, } from "./entry.respawn.js"; +type CliRespawnPlan = NonNullable>; + +function expectCliRespawnPlan(plan: ReturnType): CliRespawnPlan { + expect(plan).toEqual(expect.any(Object)); + if (plan === null) { + throw new Error("Expected CLI respawn plan"); + } + return plan; +} + describe("buildCliRespawnPlan", () => { it("returns null when respawn policy skips the argv", () => { expect( @@ -30,12 +40,12 @@ describe("buildCliRespawnPlan", () => { autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", }); - expect(plan).not.toBeNull(); - expect(plan?.command).toBe(process.execPath); - expect(plan?.argv[0]).toBe(EXPERIMENTAL_WARNING_FLAG); - expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); - expect(plan?.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); - expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBe("1"); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.command).toBe(process.execPath); + expect(respawnPlan.argv[0]).toBe(EXPERIMENTAL_WARNING_FLAG); + expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(respawnPlan.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); + expect(respawnPlan.env[OPENCLAW_NODE_OPTIONS_READY]).toBe("1"); }); it.each(["tui", "terminal", "chat"] as const)( @@ -48,11 +58,11 @@ describe("buildCliRespawnPlan", () => { autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", }); - expect(plan).not.toBeNull(); - expect(plan?.argv).toEqual(["openclaw", command]); - expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); - expect(plan?.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); - expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBeUndefined(); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.argv).toEqual(["openclaw", command]); + expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt"); + expect(respawnPlan.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1"); + expect(respawnPlan.env[OPENCLAW_NODE_OPTIONS_READY]).toBeUndefined(); }, ); @@ -60,7 +70,7 @@ describe("buildCliRespawnPlan", () => { expect( buildCliRespawnPlan({ argv: ["node", "openclaw", "tui"], - env: {}, + env: { [OPENCLAW_NODE_EXTRA_CA_CERTS_READY]: "1" }, execArgv: [], autoNodeExtraCaCerts: undefined, }), @@ -75,7 +85,8 @@ describe("buildCliRespawnPlan", () => { autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", }); - expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca.pem"); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca.pem"); }); it("returns null when both respawn guards are already satisfied", () => { @@ -118,8 +129,13 @@ describe("buildCliRespawnPlan", () => { platform: "linux", }); - expect(plan?.command).toBe("node"); - expect(plan?.argv).toEqual([EXPERIMENTAL_WARNING_FLAG, "/usr/local/bin/openclaw", "status"]); + const respawnPlan = expectCliRespawnPlan(plan); + expect(respawnPlan.command).toBe("node"); + expect(respawnPlan.argv).toEqual([ + EXPERIMENTAL_WARNING_FLAG, + "/usr/local/bin/openclaw", + "status", + ]); }); }); diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index b36de2fa8a7..6e41daaba5b 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -21,6 +21,7 @@ import { import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js"; import { formatTokenK } from "../commands/models/shared.js"; import { + normalizeAgentModelMapForConfig, normalizeAgentModelRefForConfig, resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, @@ -1158,7 +1159,7 @@ export function applyModelAllowlist( }; } - const existingModels = defaults?.models ?? {}; + const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {}); if (scopeKeySet) { const nextModels = { ...existingModels }; for (const key of scopeKeySet) { diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index e845365a07f..4bad8ff82b6 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -56,6 +56,12 @@ function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function expectNonEmptyString(value: unknown, label: string): string { + const text = readString(value); + expect(text, label).toEqual(expect.any(String)); + return text as string; +} + function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -120,8 +126,8 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("canvas.snapshot", payload); - expect(readString(obj.format)).not.toBeNull(); - expect(readString(obj.base64)).not.toBeNull(); + expectNonEmptyString(obj.format, "canvas.snapshot format"); + expectNonEmptyString(obj.base64, "canvas.snapshot base64"); }, }, "canvas.a2ui.push": { @@ -154,7 +160,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.snap", payload); - expect(readString(obj.base64)).not.toBeNull(); + expectNonEmptyString(obj.base64, "camera.snap base64"); }, }, "camera.clip": { @@ -163,7 +169,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("camera.clip", payload); - expect(readString(obj.base64)).not.toBeNull(); + expectNonEmptyString(obj.base64, "camera.clip base64"); }, }, "location.get": { @@ -188,8 +194,8 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("device.info", payload); - expect(readString(obj.systemName)).not.toBeNull(); - expect(readString(obj.systemVersion)).not.toBeNull(); + expectNonEmptyString(obj.systemName, "device.info systemName"); + expectNonEmptyString(obj.systemVersion, "device.info systemVersion"); }, }, "device.permissions": { @@ -238,7 +244,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("sms.search", payload); - expect(typeof obj.count === "number" || typeof obj.count === "string").toBe(true); + expect(["number", "string"]).toContain(typeof obj.count); expect(Array.isArray(obj.messages)).toBe(true); }, }, @@ -248,7 +254,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("debug.logs", payload); - expect(readString(obj.logs)).not.toBeNull(); + expectNonEmptyString(obj.logs, "debug.logs logs"); }, }, "debug.ed25519": { @@ -257,7 +263,7 @@ const COMMAND_PROFILES: Record = { outcome: "success", onSuccess: (payload) => { const obj = assertObjectPayload("debug.ed25519", payload); - expect(readString(obj.diagnostics)).not.toBeNull(); + expectNonEmptyString(obj.diagnostics, "debug.ed25519 diagnostics"); }, }, }; diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index b970cc98b71..e53f8a61721 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -249,7 +249,7 @@ describe("gateway auth", () => { }); }); - it("does not throw when req is missing socket", async () => { + it("authorizes matching token auth when req is missing socket", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: false }, connectAuth: { token: "secret" }, @@ -384,7 +384,7 @@ describe("gateway auth", () => { lockoutMs: 60_000, exemptLoopback: false, }); - let releaseWhois!: () => void; + let releaseWhois: (() => void) | undefined; const whoisGate = new Promise((resolve) => { releaseWhois = resolve; }); @@ -408,6 +408,9 @@ describe("gateway auth", () => { const first = authorizeGatewayConnect(baseParams); const second = authorizeGatewayConnect(baseParams); + if (!releaseWhois) { + throw new Error("Expected Tailscale whois release callback to be initialized"); + } releaseWhois(); const [firstResult, secondResult] = await Promise.all([first, second]); @@ -550,12 +553,12 @@ describe("gateway auth", () => { }); expect(auth.password).toBe("env-password"); - expect(() => + expect( assertGatewayAuthConfigured(auth, { mode: "password", password: rawPasswordRef, }), - ).not.toThrow(); + ).toBeUndefined(); }); it("throws generic error when password mode has no password at all", () => { diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index fab3f0b26da..bb6922887b9 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -614,13 +614,15 @@ describe("callGateway url resolution", () => { it("waits for event-loop readiness before starting CLI pairing requests", async () => { setLocalLoopbackGatewayConfig(); - let resolveReady!: (result: { - ready: boolean; - elapsedMs: number; - maxDriftMs: number; - checks: number; - aborted: boolean; - }) => void; + let resolveReady: + | ((result: { + ready: boolean; + elapsedMs: number; + maxDriftMs: number; + checks: number; + aborted: boolean; + }) => void) + | undefined; eventLoopReadyState.promise = new Promise((resolve) => { resolveReady = resolve; }); @@ -638,6 +640,9 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.CLI); expect(startCalls).toBe(0); + if (!resolveReady) { + throw new Error("Expected gateway event-loop readiness resolver to be initialized"); + } resolveReady({ ready: true, elapsedMs: 0, maxDriftMs: 0, checks: 2, aborted: false }); await promise; @@ -963,7 +968,7 @@ describe("callGateway error details", () => { }); expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5); - expect(lastClientOptions).not.toBeNull(); + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(startCalls).toBe(0); }); @@ -1064,7 +1069,7 @@ describe("callGateway error details", () => { it("waits for gateway client teardown before resolving", async () => { setLocalLoopbackGatewayConfig(); - let releaseStop!: () => void; + let releaseStop: (() => void) | undefined; let stopStarted = false; let stopFinished = false; let callResolved = false; @@ -1116,6 +1121,9 @@ describe("callGateway error details", () => { }); expect(callResolved).toBe(false); + if (!releaseStop) { + throw new Error("Expected gateway stop release callback to be initialized"); + } releaseStop(); await promise; @@ -1127,7 +1135,7 @@ describe("callGateway error details", () => { setLocalLoopbackGatewayConfig(); vi.useFakeTimers(); - let releaseStop!: () => void; + let releaseStop: (() => void) | undefined; let stopStarted = false; __testing.setDepsForTests({ @@ -1173,6 +1181,9 @@ describe("callGateway error details", () => { await vi.advanceTimersByTimeAsync(5); + if (!releaseStop) { + throw new Error("Expected gateway stop release callback to be initialized"); + } releaseStop(); await expect(promise).resolves.toEqual({ ok: true }); diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index e1a20047bdd..3e376f9c366 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -101,7 +101,7 @@ describe("abortChatRunById", () => { content: [{ type: "text", text: " Partial reply " }], }), ); - expect((payload.message as { timestamp?: unknown }).timestamp).toEqual(expect.any(Number)); + expect((payload.message as { timestamp?: unknown }).timestamp).toBeGreaterThan(0); expect(ops.nodeSendToSession).toHaveBeenCalledWith(sessionKey, "chat", payload); }); diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index e34792a0f79..02dd7d799c3 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -477,7 +477,7 @@ describe("parseMessageWithAttachments validation errors", () => { expect(parsed.message).toContain( "[image attachment omitted: text-only attachment limit reached]", ); - expect(logs.some((line) => /offload limit 10/i.test(line))).toBe(true); + expect(logs).toContainEqual(expect.stringMatching(/offload limit 10/i)); } finally { await cleanupOffloadedRefs(parsed.offloadedRefs); } diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 1571ee5c7bd..9b6762d2cc0 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -234,8 +234,7 @@ describe("GatewayClient security checks", () => { onConnectError, }); - // Should not throw - expect(() => client.start()).not.toThrow(); + expect(client.start()).toBeUndefined(); expectSecurityConnectError(onConnectError); expect(wsInstances.length).toBe(0); // No WebSocket created @@ -535,9 +534,7 @@ describe("GatewayClient close handling", () => { const client = createClientWithIdentity("dev-2", onClose); client.start(); - expect(() => { - getLatestWs().emitClose(1008, "unauthorized: device token mismatch"); - }).not.toThrow(); + expect(getLatestWs().emitClose(1008, "unauthorized: device token mismatch")).toBeUndefined(); expect(logDebugMock).toHaveBeenCalledWith( expect.stringContaining("failed clearing stale device-auth token"), diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 52547aa6a57..3c3bf5b7b1d 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -210,7 +210,7 @@ describe("GatewayClient", () => { }); try { - expect(() => client.start()).not.toThrow(); + expect(client.start()).toBeUndefined(); await connected; expect(onConnectError).not.toHaveBeenCalled(); } finally { diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index a64026f5bd0..b387ee4e9b6 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -488,7 +488,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.auth.token"); }); - it("does not throw for unresolved remote token ref when password is available", () => { + it("uses remote password when remote token ref is unresolved", () => { const resolved = resolveGatewayCredentialsFromConfig({ cfg: { gateway: { diff --git a/src/gateway/exec-approval-ios-push.test.ts b/src/gateway/exec-approval-ios-push.test.ts index f291850426b..93b2afa2539 100644 --- a/src/gateway/exec-approval-ios-push.test.ts +++ b/src/gateway/exec-approval-ios-push.test.ts @@ -15,12 +15,15 @@ type Deferred = { }; function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 7faf0e5a85b..3b54cbdd91b 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -88,7 +88,10 @@ describe("gateway cli backend live helpers", () => { token: "gateway-token", }); - expect(client).toEqual(expect.any(Object)); + expect(client).toMatchObject({ + start: expect.any(Function), + stopAndWait: expect.any(Function), + }); expect(gatewayClientState.lastOptions).toMatchObject({ url: "ws://127.0.0.1:18789", token: "gateway-token", diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index fa5a78ffa47..79436798fe2 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -364,7 +364,7 @@ async function verifyCodexImageProbe(params: { } const { extractPayloadText } = await import("./test-helpers.agent-results.js"); expect(extractPayloadText(payload.result)).toContain(expectedToken); - expect(events.some((event) => event.stream === "codex_app_server.lifecycle")).toBe(true); + expect(events.map((event) => event.stream)).toContain("codex_app_server.lifecycle"); } function findGuardianReviewStatus(events: CapturedAgentEvent[]): "approved" | "denied" | undefined { diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 20410d0af4c..285eb01df7d 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -631,7 +631,9 @@ describe("gateway broadcaster", () => { reason: "ws_send_buffer_drop", }), ); - expect(events.filter((event) => event.type === "payload.large")).toHaveLength(1); + expect( + events.reduce((count, event) => count + (event.type === "payload.large" ? 1 : 0), 0), + ).toBe(1); } finally { stop(); resetDiagnosticEventsForTest(); diff --git a/src/gateway/gateway-stability.test.ts b/src/gateway/gateway-stability.test.ts index e53dc3a3d00..e5fad0c7784 100644 --- a/src/gateway/gateway-stability.test.ts +++ b/src/gateway/gateway-stability.test.ts @@ -138,10 +138,14 @@ describe("gateway stability lane", () => { expect(event).not.toHaveProperty("sessionId"); expect(event).not.toHaveProperty("sessionKey"); } - expect(sessionEvents.some((event) => event.outcome === "idle" && event.queueDepth === 0)).toBe( - true, + const idleDrainedEvents = sessionEvents.filter( + (event) => event.outcome === "idle" && event.queueDepth === 0, ); - expect(sessionEvents.every((event) => event.reason === STABILITY_REASON)).toBe(true); + expect(idleDrainedEvents.length).toBeGreaterThan(0); + const unexpectedReasons = sessionEvents + .map((event) => event.reason) + .filter((reason) => reason !== STABILITY_REASON); + expect(unexpectedReasons).toEqual([]); stopDiagnosticStabilityRecorder(); emitDiagnosticEvent({ diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index eea103203b0..3031b068ed8 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -41,10 +41,10 @@ const createIMessageAliasPlugin = () => ({ describe("gateway hooks helpers", () => { const resolveHooksConfigOrThrow = (cfg: OpenClawConfig) => { const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); if (!resolved) { throw new Error("hooks config missing"); } + expect(resolved.token).toBe(cfg.hooks?.token); return resolved; }; @@ -188,11 +188,7 @@ describe("gateway hooks helpers", () => { list: [{ id: "main", default: true }, { id: "hooks" }], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); expect(resolveHookTargetAgentId(resolved, "hooks")).toBe("hooks"); expect(resolveHookTargetAgentId(resolved, "missing-agent")).toBe("main"); expect(resolveHookTargetAgentId(resolved, undefined)).toBeUndefined(); @@ -223,11 +219,7 @@ describe("gateway hooks helpers", () => { const cfg = { hooks: { enabled: true, token: "secret" }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const denied = resolveHookSessionKey({ hooksConfig: resolved, source: "request", @@ -240,11 +232,7 @@ describe("gateway hooks helpers", () => { const cfg = { hooks: { enabled: true, token: "secret", allowRequestSessionKey: true }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const allowed = resolveHookSessionKey({ hooksConfig: resolved, source: "request", @@ -262,11 +250,7 @@ describe("gateway hooks helpers", () => { allowedSessionKeyPrefixes: ["hook:"], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const blocked = resolveHookSessionKey({ hooksConfig: resolved, @@ -291,11 +275,7 @@ describe("gateway hooks helpers", () => { allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const denied = resolveHookSessionKey({ hooksConfig: resolved, @@ -313,11 +293,7 @@ describe("gateway hooks helpers", () => { allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const allowed = resolveHookSessionKey({ hooksConfig: resolved, @@ -335,11 +311,7 @@ describe("gateway hooks helpers", () => { defaultSessionKey: "hook:ingress", }, } as OpenClawConfig; - const resolved = resolveHooksConfig(cfg); - expect(resolved).not.toBeNull(); - if (!resolved) { - return; - } + const resolved = resolveHooksConfigOrThrow(cfg); const resolvedKey = resolveHookSessionKey({ hooksConfig: resolved, diff --git a/src/gateway/http-common.test.ts b/src/gateway/http-common.test.ts index ced62124a26..d55e3de072d 100644 --- a/src/gateway/http-common.test.ts +++ b/src/gateway/http-common.test.ts @@ -292,7 +292,7 @@ describe("setSseHeaders", () => { const { res, setHeader } = makeMockHttpResponse(); // Ensure flushHeaders is not defined on the mock response. expect((res as unknown as { flushHeaders?: () => void }).flushHeaders).toBeUndefined(); - expect(() => setSseHeaders(res)).not.toThrow(); + setSseHeaders(res); expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); }); }); @@ -312,7 +312,7 @@ describe("watchClientDisconnect", () => { const { req, res } = buildReqRes(null, null); const controller = new AbortController(); const cleanup = watchClientDisconnect(req, res, controller); - expect(() => cleanup()).not.toThrow(); + cleanup(); expect(controller.signal.aborted).toBe(false); }); diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index ff26bb0659f..2b308a539d9 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -19,12 +19,12 @@ describe("live-agent-probes", () => { }); it("accepts only cat for the shared image probe reply", () => { - expect(() => assertLiveImageProbeReply("cat")).not.toThrow(); - expect(() => + expect(assertLiveImageProbeReply("cat")).toBeUndefined(); + expect( assertLiveImageProbeReply( "model metadata for `gpt-5.5` not found. defaulting to fallback metadata; this can degrade performance and cause issues.cat", ), - ).not.toThrow(); + ).toBeUndefined(); expect(() => assertLiveImageProbeReply("horse")).toThrow("image probe expected 'cat'"); expect(() => assertLiveImageProbeReply("caterpillar")).toThrow("image probe expected 'cat'"); }); @@ -77,7 +77,7 @@ describe("live-agent-probes", () => { }); it("validates cron cli job shape for the shared live probe", () => { - expect(() => + expect( assertCronJobMatches({ job: { name: "live-mcp-abc", @@ -90,6 +90,6 @@ describe("live-agent-probes", () => { expectedMessage: "probe-abc", expectedSessionKey: "agent:dev:test", }), - ).not.toThrow(); + ).toBeUndefined(); }); }); diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index c5967b6fa21..1f2f83071e9 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -73,7 +73,7 @@ async function createNoisyPngBuffer(width: number, height: number): Promise { await vi.waitFor(() => expect(abortedUrls).toHaveLength(2)); await vi.dynamicImportSettled(); - expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 24 * 60 * 60_000)).toBe(false); + const scheduledDelays = setTimeoutSpy.mock.calls.map(([, delay]) => delay); + expect(scheduledDelays).not.toContain(24 * 60 * 60_000); expect( getCachedGatewayModelPricing({ provider: "anthropic", model: "claude-opus-4-6" }), ).toBeUndefined(); diff --git a/src/gateway/models-http.test.ts b/src/gateway/models-http.test.ts index c79ba3a425b..d30a6b6d9ed 100644 --- a/src/gateway/models-http.test.ts +++ b/src/gateway/models-http.test.ts @@ -46,6 +46,17 @@ async function getModels(pathname: string, headers?: Record) { }); } +async function expectFirstModelId(): Promise { + const list = (await (await getModels("/v1/models")).json()) as { + data?: Array<{ id?: string }>; + }; + const firstId = list.data?.[0]?.id; + if (typeof firstId !== "string") { + throw new Error("Expected /v1/models to return at least one string model id"); + } + return firstId; +} + describe("OpenAI-compatible models HTTP API (e2e)", () => { it("serves /v1/models when compatibility endpoints are enabled", async () => { const res = await getModels("/v1/models"); @@ -62,12 +73,8 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { }); it("serves /v1/models/{id}", async () => { - const list = (await (await getModels("/v1/models")).json()) as { - data?: Array<{ id?: string }>; - }; - const firstId = list.data?.[0]?.id; - expect(typeof firstId).toBe("string"); - const res = await getModels(`/v1/models/${encodeURIComponent(firstId!)}`); + const firstId = await expectFirstModelId(); + const res = await getModels(`/v1/models/${encodeURIComponent(firstId)}`); expect(res.status).toBe(200); const json = (await res.json()) as { id?: string; object?: string }; expect(json.object).toBe("model"); @@ -99,12 +106,8 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { }); it("rejects /v1/models/{id} without read access", async () => { - const list = (await (await getModels("/v1/models")).json()) as { - data?: Array<{ id?: string }>; - }; - const firstId = list.data?.[0]?.id; - expect(typeof firstId).toBe("string"); - const res = await getModels(`/v1/models/${encodeURIComponent(firstId!)}`, { + const firstId = await expectFirstModelId(); + const res = await getModels(`/v1/models/${encodeURIComponent(firstId)}`, { "x-openclaw-scopes": "operator.approvals", }); expect(res.status).toBe(403); diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 1d56fc261e2..ddbaa911880 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -942,7 +942,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { const jsonChunks = data .filter((d) => d !== "[DONE]") .map((d) => JSON.parse(d) as Record); - expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true); + expect(jsonChunks.map((chunk) => chunk.object)).toContain("chat.completion.chunk"); const allContent = jsonChunks .flatMap((c) => (c.choices as Array> | undefined) ?? []) .map((choice) => (choice.delta as Record | undefined)?.content) diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index e2b17ed2971..506321ed010 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -132,6 +132,16 @@ function parseSseEvents(text: string): SseEvent[] { return events; } +function collectSseEventTypes(events: readonly SseEvent[]): string[] { + const eventTypes: string[] = []; + for (const event of events) { + if (event.event) { + eventTypes.push(event.event); + } + } + return eventTypes; +} + function findSseEvent(events: SseEvent[], eventName: string): SseEvent { const event = events.find((candidate) => candidate.event === eventName); if (!event) { @@ -141,7 +151,7 @@ function findSseEvent(events: SseEvent[], eventName: string): SseEvent { } function parseSseData(event: SseEvent): unknown { - return JSON.parse(event.data); + return JSON.parse(event.data) as unknown; } function requireSessionKey(value: string | undefined, label: string): string { @@ -710,7 +720,7 @@ describe("OpenResponses HTTP API (e2e)", () => { const deltaText = await resDelta.text(); const deltaEvents = parseSseEvents(deltaText); - const eventTypes = deltaEvents.map((e) => e.event).filter(Boolean); + const eventTypes = collectSseEventTypes(deltaEvents); expect(eventTypes).toContain("response.created"); expect(eventTypes).toContain("response.output_item.added"); expect(eventTypes).toContain("response.in_progress"); @@ -719,7 +729,7 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(eventTypes).toContain("response.output_text.done"); expect(eventTypes).toContain("response.content_part.done"); expect(eventTypes).toContain("response.completed"); - expect(deltaEvents.some((e) => e.data === "[DONE]")).toBe(true); + expect(deltaEvents.map((event) => event.data)).toContain("[DONE]"); const deltas = deltaEvents .filter((e) => e.event === "response.output_text.delta") @@ -831,7 +841,7 @@ describe("OpenResponses HTTP API (e2e)", () => { | undefined; expect(streamingOpts?.senderIsOwner).toBe(true); const streamingEvents = parseSseEvents(await streamingResponse.text()); - expect(streamingEvents.some((event) => event.event === "response.completed")).toBe(true); + expect(streamingEvents.map((event) => event.event)).toContain("response.completed"); }); it("treats shared-secret bearer callers as owner operators", async () => { @@ -950,7 +960,7 @@ describe("OpenResponses HTTP API (e2e)", () => { ?.text as string | undefined) ?? "", ).toBe("Let me check that."); expect(response?.output?.[1]?.name).toBe("get_weather"); - expect(events.some((event) => event.data === "[DONE]")).toBe(true); + expect(events.map((event) => event.data)).toContain("[DONE]"); }); it("returns every client tool call when an agent invokes multiple tools in one turn (#52288)", async () => { @@ -1095,7 +1105,7 @@ describe("OpenResponses HTTP API (e2e)", () => { "activate_graph", "get_status", ]); - expect(events.some((event) => event.data === "[DONE]")).toBe(true); + expect(events.map((event) => event.data)).toContain("[DONE]"); }); it("reuses the prior session when previous_response_id is provided", async () => { diff --git a/src/gateway/plugin-node-capability.test.ts b/src/gateway/plugin-node-capability.test.ts index 5520a9e27bf..76ddbc25698 100644 --- a/src/gateway/plugin-node-capability.test.ts +++ b/src/gateway/plugin-node-capability.test.ts @@ -141,7 +141,8 @@ describe("plugin node capability helpers", () => { }); expect(refreshed?.surface).toBe("canvas"); expect(refreshed?.expiresAtMs).toBe(1_100); - expect(refreshed?.capability).toEqual(expect.any(String)); + expect(refreshed?.capability).toBeTypeOf("string"); + expect(refreshed?.capability).not.toBe(""); expect(refreshed?.scopedUrl).toContain("/__openclaw__/cap/"); expect(refreshed?.scopedUrl).not.toContain("old-token/__openclaw__/cap/"); expect(client.pluginSurfaceUrls?.canvas).toBe(refreshed?.scopedUrl); diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts index ba9c4237717..0981a35ed4f 100644 --- a/src/gateway/probe.auth.integration.test.ts +++ b/src/gateway/probe.auth.integration.test.ts @@ -118,9 +118,9 @@ describe("probeGateway auth integration", () => { expect(result.ok).toBe(true); expect(result.error).toBeNull(); - expect(result.health).not.toBeNull(); - expect(result.status).not.toBeNull(); - expect(result.configSnapshot).not.toBeNull(); + expectRecord(result.health, "probe health"); + expectRecord(result.status, "probe status"); + expectRecord(result.configSnapshot, "probe config snapshot"); }); }); }); diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index a451358562e..2eb1ddd74c7 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -212,7 +212,7 @@ describe("probeGateway", () => { expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(1_000); - expect(gatewayClientState.options).not.toBeNull(); + expect(gatewayClientState.options?.url).toBe("ws://127.0.0.1:18789"); expect(gatewayClientState.startCalls).toBe(1); }); @@ -243,7 +243,7 @@ describe("probeGateway", () => { }); expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(250); - expect(gatewayClientState.options).not.toBeNull(); + expect(gatewayClientState.options?.url).toBe("ws://127.0.0.1:18789"); expect(gatewayClientState.startCalls).toBe(0); }); diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 6cae9b71477..32659259263 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -1538,7 +1538,7 @@ describe("agent event handler", () => { emitLifecycleEnd(handler, "run-hidden", 2); expect(chatBroadcastCalls(broadcast)).toHaveLength(0); - expect(broadcast.mock.calls.filter(([event]) => event === "agent")).toHaveLength(0); + expect(broadcast.mock.calls.some(([event]) => event === "agent")).toBe(false); expect(nodeSendToSession).not.toHaveBeenCalled(); expect(persistGatewaySessionLifecycleEventMock).toHaveBeenCalledWith({ sessionKey: "session-hidden", diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index 60fa0aec4b2..eaf2f107f83 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -96,7 +96,7 @@ describe("createHooksRequestHandler timeout status mapping", () => { }); test.each(["0.0.0.0", "::"])( - "does not throw when bindHost=%s while parsing non-hook request URL", + "returns unhandled when bindHost=%s sees a non-hook request URL", async (bindHost) => { const handler = createHooksHandler({ bindHost }); const req = createHookRequest({ url: "/" }); diff --git a/src/gateway/server-lanes.test.ts b/src/gateway/server-lanes.test.ts index bd2ea60aa0b..d51335acb73 100644 --- a/src/gateway/server-lanes.test.ts +++ b/src/gateway/server-lanes.test.ts @@ -5,12 +5,15 @@ import { CommandLane } from "../process/lanes.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-methods/chat-webchat-media.test.ts b/src/gateway/server-methods/chat-webchat-media.test.ts index af62b1a27db..c83cd97bcbd 100644 --- a/src/gateway/server-methods/chat-webchat-media.test.ts +++ b/src/gateway/server-methods/chat-webchat-media.test.ts @@ -52,7 +52,7 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( [ { - text: "Reasoning:\n_step_", + text: "step", mediaUrl: audioPath, trustedLocalMedia: true, isReasoning: true, diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 7238abe6225..f8c8d2219d2 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -50,16 +50,43 @@ async function writeTranscriptHeader(transcriptPath: string, sessionId: string) async function readTranscriptLines(transcriptPath: string): Promise { const raw = await fs.readFile(transcriptPath, "utf-8"); - return raw - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => { - try { - return JSON.parse(line) as TranscriptLine; - } catch { - return {}; - } - }); + const lines: TranscriptLine[] = []; + for (const line of raw.split(/\r?\n/)) { + if (line.trim().length === 0) { + continue; + } + try { + lines.push(JSON.parse(line) as TranscriptLine); + } catch { + lines.push({}); + } + } + return lines; +} + +function collectMessagesWithIdempotencyKey( + lines: TranscriptLine[], + idempotencyKey: string, +): Record[] { + const messages: Record[] = []; + for (const line of lines) { + if (line.message?.idempotencyKey === idempotencyKey) { + messages.push(line.message); + } + } + return messages; +} + +function findMessageWithIdempotencyKey( + lines: TranscriptLine[], + idempotencyKey: string, +): Record | undefined { + for (const line of lines) { + if (line.message?.idempotencyKey === idempotencyKey) { + return line.message; + } + } + return undefined; } function setMockSessionEntry(transcriptPath: string, sessionId: string) { @@ -124,12 +151,7 @@ describe("chat abort transcript persistence", () => { }); const lines = await readTranscriptLines(transcriptPath); - const persisted = lines - .map((line) => line.message) - .filter( - (message): message is Record => - Boolean(message) && message?.idempotencyKey === `${runId}:assistant`, - ); + const persisted = collectMessagesWithIdempotencyKey(lines, `${runId}:assistant`); expect(persisted).toHaveLength(1); expect(persisted[0]).toMatchObject({ @@ -176,12 +198,8 @@ describe("chat abort transcript persistence", () => { expect(payload.runIds).toEqual(expect.arrayContaining(["run-a", "run-b"])); const lines = await readTranscriptLines(transcriptPath); - const runAPersisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === "run-a:assistant"); - const runBPersisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === "run-b:assistant"); + const runAPersisted = findMessageWithIdempotencyKey(lines, "run-a:assistant"); + const runBPersisted = findMessageWithIdempotencyKey(lines, "run-b:assistant"); expect(runAPersisted).toMatchObject({ idempotencyKey: "run-a:assistant", @@ -226,9 +244,7 @@ describe("chat abort transcript persistence", () => { expect(payload).toMatchObject({ aborted: true, runIds: ["run-stop-1"] }); const lines = await readTranscriptLines(transcriptPath); - const persisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === "run-stop-1:assistant"); + const persisted = findMessageWithIdempotencyKey(lines, "run-stop-1:assistant"); expect(persisted).toMatchObject({ idempotencyKey: "run-stop-1:assistant", @@ -264,9 +280,7 @@ describe("chat abort transcript persistence", () => { expect(payload).toMatchObject({ aborted: true, runIds: [runId] }); const lines = await readTranscriptLines(transcriptPath); - const persisted = lines - .map((line) => line.message) - .find((message) => message?.idempotencyKey === `${runId}:assistant`); + const persisted = findMessageWithIdempotencyKey(lines, `${runId}:assistant`); expect(persisted).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 7b6847ef019..22e1c23064a 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -927,7 +927,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.dispatchedReplies = [ { kind: "final", - payload: { text: "Reasoning:\n_step_", isReasoning: true }, + payload: { text: "step", isReasoning: true }, }, { kind: "final", @@ -3433,3 +3433,33 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); }); }); + +describe("chat.send operator UI client sender context", () => { + it("does not inject sender identity fields for Control UI clients", async () => { + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-control-ui-sender", + message: "hello from control ui", + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + version: "dev", + platform: "web", + }, + scopes: ["operator.write"], + }, + }, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx?.SenderId).toBeUndefined(); + expect(mockState.lastDispatchCtx?.SenderName).toBeUndefined(); + expect(mockState.lastDispatchCtx?.SenderUsername).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index b901304087e..31c41f23a94 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -18,7 +18,7 @@ describe("gateway chat.inject transcript writes", () => { message: "hello", }); expect(appended.ok).toBe(true); - expect(appended.messageId).toEqual(expect.any(String)); + expect(appended.messageId).toBeTypeOf("string"); const messageId = appended.messageId; if (!messageId) { throw new Error("expected appended message id"); @@ -65,7 +65,7 @@ describe("gateway chat.inject transcript writes", () => { message: "hello", }); expect(appended.ok).toBe(true); - expect(appended.messageId).toEqual(expect.any(String)); + expect(appended.messageId).toBeTypeOf("string"); const messageId = appended.messageId; if (!messageId) { throw new Error("expected appended message id"); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index f38e7e8da33..8ff12ec5452 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -51,6 +51,7 @@ import { import { INTERNAL_MESSAGE_CHANNEL, isGatewayCliClient, + isOperatorUiClient, isWebchatClient, normalizeMessageChannel, } from "../../utils/message-channel.js"; @@ -2266,9 +2267,13 @@ export const chatHandlers: GatewayRequestHandlers = { ...(commandSource ? { CommandSource: commandSource } : {}), CommandAuthorized: true, MessageSid: clientRunId, - SenderId: clientInfo?.id, - SenderName: clientInfo?.displayName, - SenderUsername: clientInfo?.displayName, + ...(!isOperatorUiClient(clientInfo) + ? { + SenderId: clientInfo?.id, + SenderName: clientInfo?.displayName, + SenderUsername: clientInfo?.displayName, + } + : {}), GatewayClientScopes: client?.connect?.scopes ?? [], ...pluginBoundMediaFields, }; diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 92074f5b3d5..b861ae8b2d9 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -190,6 +190,16 @@ function requireCommand(commands: T[], name: string) return command; } +function collectBuiltinNames(commands: readonly { name: string; source: string }[]): string[] { + const names: string[] = []; + for (const command of commands) { + if (command.source !== "plugin") { + names.push(command.name); + } + } + return names; +} + describe("commands.list handler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -278,14 +288,15 @@ describe("commands.list handler", () => { for (const scope of ["native", "text", "both"] as const) { const { payload } = callHandler({ scope }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - expect(commands.some((c) => c.source === "plugin")).toBe(true); + const sources = commands.map((command) => command.source); + expect(sources).toContain("plugin"); } }); it("filters built-in commands by scope=native (excludes text-only)", () => { const { payload } = callHandler({ scope: "native" }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - const builtinNames = commands.filter((c) => c.source !== "plugin").map((c) => c.name); + const builtinNames = collectBuiltinNames(commands); expect(builtinNames).not.toContain("commands"); expect(builtinNames).toContain("model"); expect(builtinNames).toContain("debug_prompt"); @@ -294,7 +305,7 @@ describe("commands.list handler", () => { it("filters built-in commands by scope=text (excludes native-only)", () => { const { payload } = callHandler({ scope: "text" }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - const builtinNames = commands.filter((c) => c.source !== "plugin").map((c) => c.name); + const builtinNames = collectBuiltinNames(commands); expect(builtinNames).toContain("commands"); expect(builtinNames).not.toContain("debug_prompt"); }); @@ -324,7 +335,7 @@ describe("commands.list handler", () => { it("omits plugin commands when provider lacks nativeCommandsAutoEnabled", () => { const { payload } = callHandler({ provider: "whatsapp" }); const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - expect(commands.filter((c) => c.source === "plugin")).toEqual([]); + expect(commands.some((c) => c.source === "plugin")).toBe(false); }); it("uses text-surface names when scope=text even with provider-native aliases", () => { @@ -470,6 +481,9 @@ describe("buildCommandsListResult", () => { it("is callable independently from handler", () => { const result = buildCommandsListResult({ cfg: {} as never, agentId: "main" }); expect(result.commands.length).toBeGreaterThan(0); - expect(result.commands.every((c) => typeof c.scope === "string")).toBe(true); + const invalidScopes = result.commands + .map((command) => command.scope) + .filter((scope) => typeof scope !== "string"); + expect(invalidScopes).toEqual([]); }); }); diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index b47b027c5a2..fc918fe473f 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -10,12 +10,15 @@ type Deferred = { }; function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 59201b6b75c..9fa3b49f2db 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -31,6 +31,16 @@ vi.mock("../../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("waitForAgentJob", () => { async function runLifecycleScenario(params: { runIdPrefix: string; @@ -74,10 +84,11 @@ describe("waitForAgentJob", () => { await vi.advanceTimersByTimeAsync(15_000); const snapshot = await snapshotPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("timeout"); - expect(snapshot?.startedAt).toBe(100); - expect(snapshot?.endedAt).toBe(200); + expect(snapshot).toMatchObject({ + status: "timeout", + startedAt: 100, + endedAt: 200, + }); } finally { vi.useRealTimers(); } @@ -89,10 +100,11 @@ describe("waitForAgentJob", () => { startedAt: 300, endedAt: 400, }); - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("ok"); - expect(snapshot?.startedAt).toBe(300); - expect(snapshot?.endedAt).toBe(400); + expect(snapshot).toMatchObject({ + status: "ok", + startedAt: 300, + endedAt: 400, + }); }); it("ignores transient aborted end events when the same run later succeeds", async () => { @@ -119,10 +131,11 @@ describe("waitForAgentJob", () => { }); const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("ok"); - expect(snapshot?.startedAt).toBe(500); - expect(snapshot?.endedAt).toBe(700); + expect(snapshot).toMatchObject({ + status: "ok", + startedAt: 500, + endedAt: 700, + }); }); it("lets a later aborted timeout replace a pending lifecycle error", async () => { @@ -149,10 +162,11 @@ describe("waitForAgentJob", () => { await vi.advanceTimersByTimeAsync(15_000); const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("timeout"); - expect(snapshot?.startedAt).toBe(800); - expect(snapshot?.endedAt).toBe(1_000); + expect(snapshot).toMatchObject({ + status: "timeout", + startedAt: 800, + endedAt: 1_000, + }); expect(snapshot?.error).toBeUndefined(); } finally { vi.useRealTimers(); @@ -183,11 +197,12 @@ describe("waitForAgentJob", () => { await vi.advanceTimersByTimeAsync(15_000); const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("error"); - expect(snapshot?.startedAt).toBe(1_100); - expect(snapshot?.endedAt).toBe(1_300); - expect(snapshot?.error).toBe("final error"); + expect(snapshot).toMatchObject({ + status: "error", + startedAt: 1_100, + endedAt: 1_300, + error: "final error", + }); } finally { vi.useRealTimers(); } @@ -1161,7 +1176,7 @@ describe("exec approval handlers", () => { expect.objectContaining({ id, decision: "allow-once" }), undefined, ); - expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); + expect(broadcasts.map((entry) => entry.event)).toContain("exec.approval.resolved"); }); it("treats duplicate same-decision exec resolves as idempotent during grace", async () => { @@ -1207,7 +1222,7 @@ describe("exec approval handlers", () => { expect(firstResolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(repeatResolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); - expect(broadcasts.filter((entry) => entry.event === "exec.approval.resolved")).toHaveLength( + expect(countMatching(broadcasts, (entry) => entry.event === "exec.approval.resolved")).toBe( resolvedBroadcastCount, ); expect(conflictingResolveRespond).toHaveBeenCalledWith( @@ -1483,7 +1498,7 @@ describe("exec approval handlers", () => { }, }); const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); - expect(requested).toBeTruthy(); + expect(requested).toEqual(expect.objectContaining({ event: "exec.approval.requested" })); const request = (requested?.payload as { request?: Record })?.request ?? {}; expect(request["commandAnalysis"]).toEqual( expect.objectContaining({ commandCount: 1, nestedCommandCount: 0 }), diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index dd3de12dff0..0b29745097c 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -26,6 +26,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createInternalHookEvent, @@ -1023,6 +1024,46 @@ export const sessionsHandlers: GatewayRequestHandlers = { } canonicalParentSessionKey = parent.canonicalKey; } + if ( + canonicalParentSessionKey && + p.emitCommandHooks === true && + !requestedKey && + !resolveOptionalInitialSessionMessage(p) && + cfg.session?.dmScope === "main" + ) { + const parentAgentId = normalizeAgentId( + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + ); + const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }); + if (canonicalParentSessionKey === parentMainKey) { + const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); + const resetResult = await performGatewaySessionReset({ + key: canonicalParentSessionKey, + reason: "new", + commandSource: "webchat", + }); + if (!resetResult.ok) { + respond(false, undefined, resetResult.error); + return; + } + respond( + true, + { + ok: true, + key: resetResult.key, + sessionId: resetResult.entry.sessionId, + entry: resetResult.entry, + runStarted: false, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: resetResult.key, + reason: "new", + }); + return; + } + } if (canonicalParentSessionKey && p.emitCommandHooks === true) { const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); const parentAgentId = normalizeAgentId( diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index c4310c13e80..2b34ae265ee 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -95,7 +95,7 @@ describe("tools.catalog handler", () => { | undefined; expect(payload?.agentId).toBe("main"); const groups = payload?.groups ?? []; - expect(groups.filter((group) => group.source === "plugin")).toEqual([]); + expect(groups.some((group) => group.source === "plugin")).toBe(false); const media = groups.find((group) => group.id === "media"); expect(media?.tools.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain("core:tts"); }); diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index d25695d1a39..eb5f16497ac 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -17,12 +17,15 @@ type LoadModelCatalogForTest = NonNullable< >; function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-restart-deferral.test.ts b/src/gateway/server-restart-deferral.test.ts index 709cd2c504d..042afbee347 100644 --- a/src/gateway/server-restart-deferral.test.ts +++ b/src/gateway/server-restart-deferral.test.ts @@ -13,12 +13,15 @@ async function flushMicrotasks(count = 10): Promise { } function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 72dca20d540..52fceb8d9df 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -268,7 +268,9 @@ describe("server-runtime-services", () => { it("clears delayed maintenance handles when close starts during maintenance startup", async () => { vi.useFakeTimers(); let closing = false; - let resolveMaintenance!: (maintenance: ReturnType) => void; + let resolveMaintenance: + | ((maintenance: ReturnType) => void) + | undefined; const startMaintenance = vi.fn( () => new Promise>((resolve) => { @@ -294,6 +296,9 @@ describe("server-runtime-services", () => { expect(startMaintenance).toHaveBeenCalledTimes(1); closing = true; + if (!resolveMaintenance) { + throw new Error("Expected gateway maintenance resolver to be initialized"); + } resolveMaintenance(createMaintenanceHandles()); await Promise.resolve(); await Promise.resolve(); diff --git a/src/gateway/server-startup-early.test.ts b/src/gateway/server-startup-early.test.ts index 547851db9ba..44dff5751ff 100644 --- a/src/gateway/server-startup-early.test.ts +++ b/src/gateway/server-startup-early.test.ts @@ -50,7 +50,7 @@ describe("startGatewayEarlyRuntime", () => { chatRunBuffers: new Map(), chatDeltaSentAt: new Map(), chatDeltaLastBroadcastLen: new Map(), - removeChatRun: () => {}, + removeChatRun: () => undefined, agentRunSeq: new Map(), nodeSendToSession: () => {}, skillsRefreshDelayMs: 30_000, diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 15e1412f527..9da795dd486 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -396,7 +396,7 @@ describe("startGatewayPostAttachRuntime", () => { it("waits for deferred startup plugin attachment before channel sidecars", async () => { const events: string[] = []; - let finishAttachment!: () => void; + let finishAttachment: (() => void) | undefined; const attachmentFinished = new Promise((resolve) => { finishAttachment = () => { events.push("startup-loaded-end"); @@ -439,6 +439,9 @@ describe("startGatewayPostAttachRuntime", () => { }); expect(startGatewaySidecars).not.toHaveBeenCalled(); + if (!finishAttachment) { + throw new Error("Expected startup plugin attachment release callback to be initialized"); + } finishAttachment(); await runtimePromise; @@ -517,7 +520,7 @@ describe("startGatewayPostAttachRuntime", () => { }); it("waits for sidecars by default before returning", async () => { - let resumeSidecars!: () => void; + let resumeSidecars: (() => void) | undefined; const sidecarsReady = new Promise<{ pluginServices: null }>((resolve) => { resumeSidecars = () => resolve({ pluginServices: null }); }); @@ -539,6 +542,9 @@ describe("startGatewayPostAttachRuntime", () => { await Promise.resolve(); expect(returned).toBe(false); + if (!resumeSidecars) { + throw new Error("Expected gateway sidecar resume callback to be initialized"); + } resumeSidecars(); await runtimePromise; expect(returned).toBe(true); @@ -604,7 +610,7 @@ describe("startGatewayPostAttachRuntime", () => { await withEnvAsync( { OPENCLAW_SKIP_CHANNELS: undefined, OPENCLAW_SKIP_PROVIDERS: undefined }, async () => { - let resolvePrewarm!: () => void; + let resolvePrewarm: (() => void) | undefined; const prewarmPrimaryModel = vi.fn( async () => await new Promise((resolve) => { @@ -649,6 +655,9 @@ describe("startGatewayPostAttachRuntime", () => { ); await sidecarsPromise; + if (!resolvePrewarm) { + throw new Error("Expected primary model prewarm resolver to be initialized"); + } resolvePrewarm(); await Promise.resolve(); }, @@ -656,7 +665,7 @@ describe("startGatewayPostAttachRuntime", () => { }); it("keeps startup-gated methods unavailable while sidecars are still resuming", async () => { - let resumeSidecars!: () => void; + let resumeSidecars: (() => void) | undefined; const sidecarsReady = new Promise<{ pluginServices: null }>((resolve) => { resumeSidecars = () => resolve({ pluginServices: null }); }); @@ -684,6 +693,9 @@ describe("startGatewayPostAttachRuntime", () => { expect([...unavailableGatewayMethods]).toEqual([...STARTUP_UNAVAILABLE_GATEWAY_METHODS]); expect(hoisted.startPluginServices).not.toHaveBeenCalled(); + if (!resumeSidecars) { + throw new Error("Expected gateway sidecar resume callback to be initialized"); + } resumeSidecars(); await vi.waitFor(() => { expect([...unavailableGatewayMethods]).toEqual([]); @@ -916,6 +928,7 @@ function createPostAttachParams(overrides: Partial = {}): Post broadcast: vi.fn(), tailscaleMode: "off", resetOnExit: false, + preserveFunnel: false, controlUiBasePath: "/", logTailscale: { info: vi.fn(), diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 1a14ba681f7..db28e5ced4b 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -673,6 +673,7 @@ export async function startGatewayPostAttachRuntime( broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; tailscaleMode: GatewayTailscaleMode; resetOnExit: boolean; + preserveFunnel: boolean; controlUiBasePath: string; logTailscale: { info: (msg: string) => void; @@ -757,6 +758,7 @@ export async function startGatewayPostAttachRuntime( runtimeDeps.startGatewayTailscaleExposure({ tailscaleMode: params.tailscaleMode, resetOnExit: params.resetOnExit, + preserveFunnel: params.preserveFunnel, port: params.port, controlUiBasePath: params.controlUiBasePath, logTailscale: params.logTailscale, diff --git a/src/gateway/server-tailscale.test.ts b/src/gateway/server-tailscale.test.ts new file mode 100644 index 00000000000..2bb340602f4 --- /dev/null +++ b/src/gateway/server-tailscale.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + enableTailscaleServe: vi.fn(async (_port: number) => undefined), + disableTailscaleServe: vi.fn(async () => undefined), + enableTailscaleFunnel: vi.fn(async (_port: number) => undefined), + disableTailscaleFunnel: vi.fn(async () => undefined), + getTailnetHostname: vi.fn(async () => null), + hasTailscaleFunnelRouteForPort: vi.fn(async (_port: number) => false), +})); + +vi.mock("../infra/tailscale.js", () => ({ + enableTailscaleServe: mocks.enableTailscaleServe, + disableTailscaleServe: mocks.disableTailscaleServe, + enableTailscaleFunnel: mocks.enableTailscaleFunnel, + disableTailscaleFunnel: mocks.disableTailscaleFunnel, + getTailnetHostname: mocks.getTailnetHostname, + hasTailscaleFunnelRouteForPort: mocks.hasTailscaleFunnelRouteForPort, +})); + +import { startGatewayTailscaleExposure } from "./server-tailscale.js"; + +function createLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +afterEach(() => { + for (const fn of Object.values(mocks)) { + fn.mockReset(); + } + mocks.enableTailscaleServe.mockResolvedValue(undefined); + mocks.disableTailscaleServe.mockResolvedValue(undefined); + mocks.enableTailscaleFunnel.mockResolvedValue(undefined); + mocks.disableTailscaleFunnel.mockResolvedValue(undefined); + mocks.getTailnetHostname.mockResolvedValue(null); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false); +}); + +describe("startGatewayTailscaleExposure preserveFunnel", () => { + it("calls enableTailscaleServe in serve mode when preserveFunnel is unset", async () => { + const logTailscale = createLogger(); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + logTailscale, + }); + + expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789); + expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled(); + }); + + it("skips enableTailscaleServe when preserveFunnel is true and a Funnel route covers the port", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).toHaveBeenCalledWith(18789); + expect(mocks.enableTailscaleServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith(expect.stringMatching(/preserv/i)); + }); + + it("notes resetOnExit is a no-op when preserveFunnel skips Serve", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + resetOnExit: true, + logTailscale, + }); + + expect(mocks.enableTailscaleServe).not.toHaveBeenCalled(); + expect(logTailscale.info).toHaveBeenCalledWith( + expect.stringMatching(/resetOnExit is a no-op/i), + ); + }); + + it("falls back to enableTailscaleServe when preserveFunnel is true but no Funnel route exists for the port", async () => { + const logTailscale = createLogger(); + mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false); + + await startGatewayTailscaleExposure({ + tailscaleMode: "serve", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).toHaveBeenCalledWith(18789); + expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789); + }); + + it("never consults the Funnel route helper when running in funnel mode", async () => { + const logTailscale = createLogger(); + + await startGatewayTailscaleExposure({ + tailscaleMode: "funnel", + port: 18789, + preserveFunnel: true, + logTailscale, + }); + + expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled(); + expect(mocks.enableTailscaleFunnel).toHaveBeenCalledWith(18789); + }); +}); diff --git a/src/gateway/server-tailscale.ts b/src/gateway/server-tailscale.ts index 9d09f12c4f9..ebf0a6383c9 100644 --- a/src/gateway/server-tailscale.ts +++ b/src/gateway/server-tailscale.ts @@ -5,12 +5,14 @@ import { enableTailscaleFunnel, enableTailscaleServe, getTailnetHostname, + hasTailscaleFunnelRouteForPort, } from "../infra/tailscale.js"; export async function startGatewayTailscaleExposure(params: { tailscaleMode: "off" | "serve" | "funnel"; resetOnExit?: boolean; port: number; + preserveFunnel?: boolean; controlUiBasePath?: string; logTailscale: { info: (msg: string) => void; warn: (msg: string) => void }; }): Promise<(() => Promise) | null> { @@ -20,6 +22,21 @@ export async function startGatewayTailscaleExposure(params: { try { if (params.tailscaleMode === "serve") { + if (params.preserveFunnel === true) { + const funnelCovers = await hasTailscaleFunnelRouteForPort(params.port); + if (funnelCovers) { + const resetSuffix = params.resetOnExit + ? "; resetOnExit is a no-op because no Serve route was applied this run" + : ""; + params.logTailscale.info( + `serve skipped: preserving externally configured Tailscale Funnel for port ${params.port}${resetSuffix}`, + ); + // Skip the resetOnExit teardown deliberately: the Funnel route is + // owned by an external operator, so we must not run + // disableTailscaleServe on shutdown either. + return null; + } + } await enableTailscaleServe(params.port); } else { await enableTailscaleFunnel(params.port); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 3ae47e22277..e73067a0e46 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -529,7 +529,8 @@ describe("gateway server agent", () => { if (!ackPayload || !finalPayload) { throw new Error("missing websocket payload"); } - expect(ackPayload.runId).toEqual(expect.any(String)); + expect(ackPayload.runId).toBeTypeOf("string"); + expect(ackPayload.runId).not.toBe(""); expect(finalPayload.runId).toBe(ackPayload.runId); expect(finalPayload.status).toBe("ok"); }); diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 699f567eead..2155545a668 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -217,7 +217,7 @@ describe("gateway auth compatibility baseline", () => { }); expect(rotated.ok).toBe(true); const rotatedToken = rotated.ok ? rotated.entry.token : ""; - expect(rotatedToken).toEqual(expect.any(String)); + expect(rotatedToken).toBeTypeOf("string"); expect(rotatedToken.length).toBeGreaterThan(0); const ws = await openWs(port); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 1f8118e7b7c..b494dbacfc0 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -51,12 +51,15 @@ const sendReq = ( }; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 0747a071e10..d7b92d46d3f 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -447,7 +447,7 @@ describe("gateway server cron", () => { const runRes = await cronState.cron.run(routeJobId, "force"); expect(runRes).toEqual({ ok: true, ran: true }); const events = await waitForSystemEvent(); - expect(events.some((event) => event.includes("cron route check"))).toBe(true); + expect(events).toEqual(expect.arrayContaining([expect.stringContaining("cron route check")])); const wrappedAtMs = Date.now() + 1000; const wrappedRes = await directCronReq(cronState, "cron.add", { diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts index a4560310917..48ecc2bd5f8 100644 --- a/src/gateway/server.device-pair-approve-authz.test.ts +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -79,7 +79,6 @@ describe("gateway device.pair.approve caller scope guard", () => { expect(approve.error?.message).toBe("missing scope: operator.admin"); const paired = await getPairedDevice(approverIdentity.identity.deviceId); - expect(paired).not.toBeNull(); expect(paired?.approvedScopes).toEqual(["operator.admin"]); } finally { pairingWs?.close(); @@ -138,7 +137,7 @@ describe("gateway device.pair.approve caller scope guard", () => { expect(reject.error?.message).toBe("device pairing rejection denied"); const stillPending = await getPendingDevicePairing(request.request.requestId); - expect(stillPending).not.toBeNull(); + expect(stillPending?.requestId).toBe(request.request.requestId); } finally { pairingWs?.close(); started.ws.close(); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index ff721d0671f..da0e2fd2353 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -167,7 +167,7 @@ describe("gateway server health/presence", () => { await localHarness.close(); const evt = await shutdownP; const evtPayload = evt.payload as { reason?: unknown } | undefined; - expect(evtPayload?.reason).toEqual(expect.any(String)); + expect(evtPayload?.reason).toBe("gateway stopping"); }); test( @@ -279,7 +279,7 @@ describe("gateway server health/presence", () => { const presenceRes = await presenceP; const entries = (presenceRes.payload ?? []) as Array>; - expect(entries.some((e) => e.instanceId === cliId)).toBe(false); + expect(entries.map((entry) => entry.instanceId)).not.toContain(cliId); ws.close(); }); diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 4860d92f946..f22fb11214a 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -172,14 +172,16 @@ describe("gateway server hooks", () => { const resWake = await postHook(port, "/hooks/wake", { text: "Ping", mode: "next-heartbeat" }); expect(resWake.status).toBe(200); const wakeEvents = await waitForSystemEvent(); - expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true); + expect(wakeEvents).toEqual(expect.arrayContaining([expect.stringContaining("Ping")])); drainSystemEvents(resolveMainKey()); mockIsolatedRunOkOnce(); const resAgent = await postHook(port, "/hooks/agent", { message: "Do it", name: "Email" }); expect(resAgent.status).toBe(200); const agentEvents = await waitForSystemEvent(); - expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true); + expect(agentEvents).toEqual( + expect.arrayContaining([expect.stringContaining("Hook Email: done")]), + ); const firstCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as { job?: { payload?: { externalContentSource?: string } }; }; @@ -251,7 +253,9 @@ describe("gateway server hooks", () => { ); expect(resHeader.status).toBe(200); const headerEvents = await waitForSystemEvent(); - expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true); + expect(headerEvents).toEqual( + expect.arrayContaining([expect.stringContaining("Header auth")]), + ); drainSystemEvents(resolveMainKey()); const resGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { @@ -321,7 +325,9 @@ describe("gateway server hooks", () => { expect(resAgent.status).toBe(200); const targetEvents = await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY); - expect(targetEvents.some((event) => event.includes("Hook Email: done"))).toBe(true); + expect(targetEvents).toEqual( + expect.arrayContaining([expect.stringContaining("Hook Email: done")]), + ); expect(peekSystemEventEntries(resolveMainKey())).toEqual([]); drainSystemEvents(HOOKS_MAIN_SESSION_KEY); }); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8b3cc5214af..2d5f71d5463 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1393,6 +1393,7 @@ export async function startGatewayServer( broadcast, tailscaleMode, resetOnExit: tailscaleConfig.resetOnExit ?? false, + preserveFunnel: tailscaleConfig.preserveFunnel ?? false, controlUiBasePath, logTailscale, gatewayPluginConfigAtStart, diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index cb68da708e5..69c0e5c43ef 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -84,7 +84,13 @@ async function getConnectedNodeIds(ws: WebSocket): Promise { {}, ); expect(nodes.ok).toBe(true); - return (nodes.payload?.nodes ?? []).filter((n) => n.connected).map((n) => n.nodeId); + const nodeIds: string[] = []; + for (const node of nodes.payload?.nodes ?? []) { + if (node.connected) { + nodeIds.push(node.nodeId); + } + } + return nodeIds; } async function requestAllowOnceApproval( diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index bd12322df92..5ee9b91841b 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -430,8 +430,8 @@ describe("gateway hot reload", () => { }) { await expect(params.applyReload()).rejects.toThrow(params.expectedError); const degradedEvents = drainSystemEvents(params.sessionKey); - expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe( - true, + expect(degradedEvents).toEqual( + expect.arrayContaining([expect.stringContaining("[SECRETS_RELOADER_DEGRADED]")]), ); await expect(params.applyReload()).rejects.toThrow(params.expectedError); @@ -444,8 +444,8 @@ describe("gateway hot reload", () => { }) { await expect(params.applyReload()).resolves.toBeUndefined(); const recoveredEvents = drainSystemEvents(params.sessionKey); - expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe( - true, + expect(recoveredEvents).toEqual( + expect.arrayContaining([expect.stringContaining("[SECRETS_RELOADER_RECOVERED]")]), ); } diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index dd95371637a..44828551fd4 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -40,6 +40,16 @@ const FAST_WAIT_OPTS = { timeout: 1_000, interval: 2 } as const; let ws: WebSocket; let port: number; +function countConnectedNodes(nodes: readonly { connected?: boolean }[] | undefined): number { + let count = 0; + for (const node of nodes ?? []) { + if (node.connected) { + count++; + } + } + return count; +} + function installCanvasNodePolicyForTest() { const registry = getActiveRuntimePluginRegistry(); if (!registry) { @@ -326,8 +336,7 @@ describe("gateway node command allowlist", () => { const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }>; }>(ws, "node.list", {}); - const nodes = listRes.payload?.nodes ?? []; - return nodes.filter((node) => node.connected).length; + return countConnectedNodes(listRes.payload?.nodes); }, FAST_WAIT_OPTS) .toBe(count); }; @@ -554,7 +563,7 @@ describe("gateway node command allowlist", () => { "node.list", {}, ); - return (listRes.payload?.nodes ?? []).filter((node) => node.connected).length; + return countConnectedNodes(listRes.payload?.nodes); }, FAST_WAIT_OPTS) .toBe(0); diff --git a/src/gateway/server.sessions.delete-lifecycle.test.ts b/src/gateway/server.sessions.delete-lifecycle.test.ts index adfd12572eb..def75074411 100644 --- a/src/gateway/server.sessions.delete-lifecycle.test.ts +++ b/src/gateway/server.sessions.delete-lifecycle.test.ts @@ -345,8 +345,8 @@ test("sessions.delete returns unavailable when active run does not stop", async >; expect(store["agent:main:discord:group:dev"]?.sessionId).toBe("sess-active"); const filesAfterDeleteAttempt = await fs.readdir(dir); - expect(filesAfterDeleteAttempt.some((f) => f.startsWith("sess-active.jsonl.deleted."))).toBe( - false, + expect(filesAfterDeleteAttempt).not.toContainEqual( + expect.stringMatching(/^sess-active\.jsonl\.deleted\./), ); ws.close(); diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 5f9c00cdef7..1557718667e 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; -import { embeddedRunMock, writeSessionStore } from "./test-helpers.js"; +import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, bootstrapCacheMocks, @@ -211,7 +211,9 @@ test("sessions.reset returns unavailable when active run does not stop", async ( >; expect(store["agent:main:main"]?.sessionId).toBe("sess-main"); const filesAfterResetAttempt = await fs.readdir(dir); - expect(filesAfterResetAttempt.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(false); + expect(filesAfterResetAttempt).not.toContainEqual( + expect.stringMatching(/^sess-main\.jsonl\.reset\./), + ); }); test("sessions.reset emits before_reset for the entry actually reset in the writer slot", async () => { @@ -401,12 +403,75 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga expect(startEvent).toMatchObject({ resumedFrom: "sess-parent-hooks", }); - expect((startEvent as { sessionId?: string } | undefined)?.sessionId).toEqual(expect.any(String)); + expect((startEvent as { sessionId?: string } | undefined)?.sessionId).toBeTypeOf("string"); + expect((startEvent as { sessionId?: string } | undefined)?.sessionId).not.toBe(""); expect((startEvent as { sessionKey?: string } | undefined)?.sessionKey).toMatch( /^agent:main:dashboard:/, ); }); +test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => { + const { dir } = await createSessionStoreDir(); + const transcriptPath = path.join(dir, "sess-parent-dms.jsonl"); + await fs.writeFile( + transcriptPath, + `${JSON.stringify({ + type: "message", + id: "m1", + message: { role: "user", content: "hello before /new" }, + })}\n`, + "utf-8", + ); + + testState.sessionConfig = { dmScope: "main" }; + try { + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent-dms", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + }); + + const result = await directSessionReq<{ + ok: boolean; + key: string; + sessionId: string; + runStarted: boolean; + }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + }); + expect(result.ok).toBe(true); + // Reset-in-place: response key matches the parent main key, NOT a dashboard child. + expect(result.payload?.key).toBe("agent:main:main"); + expect(result.payload?.runStarted).toBe(false); + expect(result.payload?.sessionId).not.toBe("sess-parent-dms"); + + expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1); + expect(sessionLifecycleHookMocks.runSessionStart).toHaveBeenCalledTimes(1); + const [endEvent] = ( + sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + const [startEvent] = ( + sessionLifecycleHookMocks.runSessionStart.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + expect(endEvent).toMatchObject({ + sessionId: "sess-parent-dms", + sessionKey: "agent:main:main", + reason: "new", + }); + expect(startEvent).toMatchObject({ + sessionKey: "agent:main:main", + resumedFrom: "sess-parent-dms", + }); + } finally { + testState.sessionConfig = undefined; + } +}); + test("sessions.create without emitCommandHooks does not fire command:new hook (#76957)", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2"); diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index b5a7e5e8a69..1a9097d7534 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -11,6 +11,16 @@ import { const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); +function collectNonEmptyLines(text: string): string[] { + const lines: string[] = []; + for (const line of text.split(/\r?\n/)) { + if (line.trim().length > 0) { + lines.push(line); + } + } + return lines; +} + test("lists and patches session store via sessions.* RPC", async () => { const { dir, storePath } = await createSessionStoreDir(); const now = Date.now(); @@ -150,7 +160,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(list1.ok).toBe(true); expect(list1.payload?.path).toBe(storePath); - expect(list1.payload?.sessions.some((s) => s.key === "global")).toBe(false); + expect(list1.payload?.sessions.map((session) => session.key)).not.toContain("global"); expect(list1.payload?.defaults?.modelProvider).toBe("anthropic"); const main = list1.payload?.sessions.find((s) => s.key === "agent:main:main"); expect(main?.totalTokens).toBeUndefined(); @@ -321,8 +331,8 @@ test("lists and patches session store via sessions.* RPC", async () => { const listAfterCleanup = await directSessionReq<{ sessions: Array<{ key: string }>; }>("sessions.list", {}); - expect(listAfterCleanup.payload?.sessions.some((s) => s.key === "agent:main:subagent:one")).toBe( - false, + expect(listAfterCleanup.payload?.sessions.map((session) => session.key)).not.toContain( + "agent:main:subagent:one", ); piSdkMock.enabled = true; @@ -378,12 +388,12 @@ test("lists and patches session store via sessions.* RPC", async () => { }); expect(compacted.ok).toBe(true); expect(compacted.payload?.compacted).toBe(true); - const compactedLines = (await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8")) - .split(/\r?\n/) - .filter((l) => l.trim().length > 0); + const compactedLines = collectNonEmptyLines( + await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8"), + ); expect(compactedLines).toHaveLength(3); const filesAfterCompact = await fs.readdir(dir); - expect(filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak."))).toBe(true); + expect(filesAfterCompact).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.bak\./)); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:discord:group:dev", @@ -394,11 +404,11 @@ test("lists and patches session store via sessions.* RPC", async () => { sessions: Array<{ key: string }>; }>("sessions.list", {}); expect(listAfterDelete.ok).toBe(true); - expect( - listAfterDelete.payload?.sessions.some((s) => s.key === "agent:main:discord:group:dev"), - ).toBe(false); + expect(listAfterDelete.payload?.sessions.map((session) => session.key)).not.toContain( + "agent:main:discord:group:dev", + ); const filesAfterDelete = await fs.readdir(dir); - expect(filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted."))).toBe(true); + expect(filesAfterDelete).toContainEqual(expect.stringMatching(/^sess-group\.jsonl\.deleted\./)); const reset = await directSessionReq<{ ok: true; @@ -425,7 +435,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(storeAfterReset["agent:main:main"]?.lastAccountId).toBe("work"); expect(storeAfterReset["agent:main:main"]?.lastThreadId).toBe("1737500000.123456"); const filesAfterReset = await fs.readdir(dir); - expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); + expect(filesAfterReset).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.reset\./)); const badThinking = await directSessionReq("sessions.patch", { key: "agent:main:main", diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index f2c68bd32cd..d4b5ce1d262 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -443,7 +443,7 @@ describe("gateway silent scope-upgrade reconnect", () => { expect(res.ok).toBe(false); expect(res.error?.message).toBe("pairing required: device is not approved yet"); - expect(replacementRequestId).toEqual(expect.any(String)); + expect(replacementRequestId).toBeTypeOf("string"); expect(replacementRequestId.length).toBeGreaterThan(0); expect( (res.error?.details as { requestId?: unknown; code?: string } | undefined)?.requestId, diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 7cc480f7613..dcae34484ec 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -85,7 +85,7 @@ async function createFreshOperatorDevice(scopes: string[], nonce: string) { async function connectOperator(ws: GatewaySocket, scopes: string[]) { const nonce = await readConnectChallengeNonce(ws); - expect(nonce).toEqual(expect.any(String)); + expect(nonce).toBeTypeOf("string"); expect(String(nonce).length).toBeGreaterThan(0); await connectOk(ws, { token: "secret", @@ -325,7 +325,7 @@ describe("gateway talk.config", () => { }); }); - it("does not throw when SecretRef apiKey flows through a strict provider resolver", async () => { + it("redacts SecretRef apiKey after strict provider resolver accepts it", async () => { // Regression for #72496: ElevenLabs/OpenAI speech providers call the strict // normalizeResolvedSecretInputString helper inside resolveTalkConfig. The // discovery path used to hand them the raw source config (with the SecretRef @@ -383,8 +383,11 @@ describe("gateway talk.config", () => { // the UI keeps the SecretRef context, but every field becomes the // sentinel so no credential material leaks to read-scope callers. const redactedApiKey = talk?.providers?.[GENERIC_TALK_PROVIDER_ID]?.apiKey; - expect(redactedApiKey).toBeTypeOf("object"); - expect((redactedApiKey as SecretRef).id).toBe("__OPENCLAW_REDACTED__"); + expect(redactedApiKey).toEqual({ + id: "__OPENCLAW_REDACTED__", + provider: "__OPENCLAW_REDACTED__", + source: "__OPENCLAW_REDACTED__", + }); expect(talk?.resolved?.config?.apiKey).toEqual(redactedApiKey); }); diff --git a/src/gateway/server.tools-catalog.test.ts b/src/gateway/server.tools-catalog.test.ts index edce17ae455..bf711e1509a 100644 --- a/src/gateway/server.tools-catalog.test.ts +++ b/src/gateway/server.tools-catalog.test.ts @@ -18,11 +18,11 @@ describe("gateway tools.catalog", () => { }>(ws, "tools.catalog", {}); expect(res.ok).toBe(true); - expect(res.payload?.agentId).toEqual(expect.any(String)); + expect(res.payload?.agentId).toBeTypeOf("string"); expect(res.payload?.agentId).not.toBe(""); const mediaGroup = res.payload?.groups?.find((group) => group.id === "media"); - expect(mediaGroup?.tools?.some((tool) => tool.id === "tts" && tool.source === "core")).toBe( - true, + expect(mediaGroup?.tools?.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain( + "core:tts", ); }); }); @@ -35,9 +35,9 @@ describe("gateway tools.catalog", () => { groups?: Array<{ source?: "core" | "plugin" }>; }>(ws, "tools.catalog", { includePlugins: false }); expect(noPlugins.ok).toBe(true); - expect((noPlugins.payload?.groups ?? []).every((group) => group.source !== "plugin")).toBe( - true, - ); + expect( + (noPlugins.payload?.groups ?? []).filter((group) => group.source === "plugin"), + ).toEqual([]); const unknownAgent = await rpcReq(ws, "tools.catalog", { agentId: "does-not-exist" }); expect(unknownAgent.ok).toBe(false); diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index ff418a1dd03..39ca826b051 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -62,21 +62,23 @@ describe("session-compaction-checkpoints", () => { expect(copyFileSyncSpy).not.toHaveBeenCalled(); expect(sessionManagerOpenSpy).not.toHaveBeenCalled(); - expect(snapshot).not.toBeNull(); - expect(snapshot?.leafId).toBe(leafId); - expect(snapshot?.sessionFile).not.toBe(sessionFile); - expect(snapshot?.sessionFile).toContain(".checkpoint."); - expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(true); - expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore); + expect(snapshot).toMatchObject({ leafId }); + if (!snapshot) { + throw new Error("expected checkpoint snapshot"); + } + expect(snapshot.sessionFile).not.toBe(sessionFile); + expect(snapshot.sessionFile).toContain(".checkpoint."); + expect(fsSync.existsSync(snapshot.sessionFile)).toBe(true); + expect(await fs.readFile(snapshot.sessionFile, "utf-8")).toBe(originalBefore); session.appendCompaction("checkpoint summary", leafId, 123, { ok: true }); - expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore); + expect(await fs.readFile(snapshot.sessionFile, "utf-8")).toBe(originalBefore); expect(await fs.readFile(sessionFile, "utf-8")).not.toBe(originalBefore); await cleanupCompactionCheckpointSnapshot(snapshot); - expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(false); + expect(fsSync.existsSync(snapshot.sessionFile)).toBe(false); expect(fsSync.existsSync(sessionFile)).toBe(true); } finally { copyFileSyncSpy.mockRestore(); @@ -119,11 +121,12 @@ describe("session-compaction-checkpoints", () => { expect(copyFileSyncSpy).not.toHaveBeenCalled(); expect(sessionManagerOpenSpy).not.toHaveBeenCalled(); - expect(snapshot).not.toBeNull(); - expect(snapshot?.sessionId).toBe(sessionId); - expect(snapshot?.leafId).toBe(leafId); - expect(snapshot?.sessionFile).not.toBe(sessionFile); - expect(snapshot?.sessionFile).toContain(".checkpoint."); + expect(snapshot).toMatchObject({ sessionId, leafId }); + if (!snapshot) { + throw new Error("expected checkpoint snapshot"); + } + expect(snapshot.sessionFile).not.toBe(sessionFile); + expect(snapshot.sessionFile).toContain(".checkpoint."); } finally { await cleanupCompactionCheckpointSnapshot(snapshot); copyFileSyncSpy.mockRestore(); @@ -155,7 +158,7 @@ describe("session-compaction-checkpoints", () => { expect(snapshot).toBeNull(); expect(copyFileSyncSpy).not.toHaveBeenCalled(); expect(MAX_COMPACTION_CHECKPOINT_SNAPSHOT_BYTES).toBeGreaterThan(64); - expect(fsSync.readdirSync(dir).filter((file) => file.includes(".checkpoint."))).toEqual([]); + expect(fsSync.readdirSync(dir).some((file) => file.includes(".checkpoint."))).toBe(false); } finally { copyFileSyncSpy.mockRestore(); } @@ -194,15 +197,19 @@ describe("session-compaction-checkpoints", () => { expect(openSpy).not.toHaveBeenCalled(); expect(forkSpy).not.toHaveBeenCalled(); - expect(forked).not.toBeNull(); - expect(forked?.sessionFile).not.toBe(sessionFile); - expect(forked?.sessionId).toEqual(expect.any(String)); + expect(forked).toMatchObject({ sessionFile: expect.any(String) }); + if (!forked) { + throw new Error("expected forked checkpoint transcript"); + } + expect(forked.sessionFile).not.toBe(sessionFile); + expect(forked.sessionId).toBeTypeOf("string"); + expect(forked.sessionId).not.toBe(""); } finally { openSpy.mockRestore(); forkSpy.mockRestore(); } - const forkedLines = (await fs.readFile(forked!.sessionFile, "utf-8")).trim().split(/\r?\n/); + const forkedLines = (await fs.readFile(forked.sessionFile, "utf-8")).trim().split(/\r?\n/); const forkedEntries = forkedLines.map((line) => JSON.parse(line) as Record); const sourceEntries = (await fs.readFile(sessionFile, "utf-8")) .trim() @@ -217,7 +224,7 @@ describe("session-compaction-checkpoints", () => { expect(forkedEntries[0]).toMatchObject({ type: "session", - id: forked!.sessionId, + id: forked.sessionId, cwd: dir, parentSession: sessionFile, }); @@ -273,15 +280,18 @@ describe("session-compaction-checkpoints", () => { sessionDir: dir, }); - expect(forked).not.toBeNull(); - const forkedEntries = (await fs.readFile(forked!.sessionFile, "utf-8")) + expect(forked).toMatchObject({ sessionFile: expect.any(String) }); + if (!forked) { + throw new Error("expected forked checkpoint transcript"); + } + const forkedEntries = (await fs.readFile(forked.sessionFile, "utf-8")) .trim() .split(/\r?\n/) .map((line) => JSON.parse(line) as Record); expect(forkedEntries[0]).toMatchObject({ type: "session", version: CURRENT_SESSION_VERSION, - id: forked!.sessionId, + id: forked.sessionId, parentSession: legacySessionFile, }); expect(forkedEntries[1]).toMatchObject({ @@ -289,15 +299,17 @@ describe("session-compaction-checkpoints", () => { parentId: null, message: expect.objectContaining({ content: "legacy first" }), }); - expect(forkedEntries[1]?.id).toEqual(expect.any(String)); + expect(forkedEntries[1]?.id).toBeTypeOf("string"); + expect(forkedEntries[1]?.id).not.toBe(""); expect(forkedEntries[2]).toMatchObject({ type: "message", parentId: forkedEntries[1]?.id, message: expect.objectContaining({ content: "legacy second" }), }); - expect(forkedEntries[2]?.id).toEqual(expect.any(String)); + expect(forkedEntries[2]?.id).toBeTypeOf("string"); + expect(forkedEntries[2]?.id).not.toBe(""); - const messages = SessionManager.open(forked!.sessionFile, dir).buildSessionContext().messages; + const messages = SessionManager.open(forked.sessionFile, dir).buildSessionContext().messages; expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([ "legacy first", "legacy second", @@ -368,7 +380,11 @@ describe("session-compaction-checkpoints", () => { createdAt: now + 100, }); - expect(stored).not.toBeNull(); + expect(stored?.preCompaction).toMatchObject({ + sessionId, + sessionFile: currentSnapshotFile, + leafId: "current-leaf", + }); expect(fsSync.existsSync(existingCheckpoints[0].preCompaction.sessionFile)).toBe(false); expect(fsSync.existsSync(existingCheckpoints[1].preCompaction.sessionFile)).toBe(false); expect(fsSync.existsSync(existingCheckpoints[2].preCompaction.sessionFile)).toBe(true); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 2299261f633..792a78cd0e2 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -1302,7 +1302,7 @@ describe("readSessionMessages", () => { pluginId: "hitl-test-hooks", }); - expect(messageId).toEqual(expect.any(String)); + expect(messageId).toBeTypeOf("string"); expect(messageId.length).toBeGreaterThan(0); const out = readSessionMessages(sessionId, storePath, sessionFile); expect( @@ -1806,7 +1806,7 @@ describe("resolveSessionTranscriptCandidates safety", () => { const normalizedCandidates = candidates.map((value) => path.resolve(value)); const expectedFallback = path.resolve(path.dirname(storePath), "sess-safe.jsonl"); - expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false); + expect(candidates).not.toEqual(expect.arrayContaining([expect.stringContaining("etc/passwd")])); expect(normalizedCandidates).toContain(expectedFallback); }); @@ -2056,9 +2056,7 @@ describe("oversized transcript line guards", () => { 512 * 1024, ); - expect(usage).not.toBeNull(); - expect(usage?.modelProvider).not.toBe("oversized-provider"); - expect(usage?.modelProvider).toBe("test-provider"); + expect(usage).toMatchObject({ modelProvider: "test-provider" }); }); test("readSessionTitleFieldsFromTranscriptAsync delegates to bounded sync reader", async () => { diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 2b40af7fe41..0470f00d922 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -5,6 +5,7 @@ import { assertGatewayAuthNotKnownWeak, assertHooksTokenSeparateFromGatewayAuth, ensureGatewayStartupAuth, + mergeGatewayTailscaleConfig, } from "./startup-auth.js"; const mocks = vi.hoisted(() => ({ @@ -23,6 +24,17 @@ vi.mock("../config/mutate.js", async () => { }; }); +describe("mergeGatewayTailscaleConfig", () => { + it("preserves explicit preserveFunnel overrides", () => { + expect( + mergeGatewayTailscaleConfig( + { mode: "serve", resetOnExit: false, preserveFunnel: false }, + { preserveFunnel: true }, + ), + ).toEqual({ mode: "serve", resetOnExit: false, preserveFunnel: true }); + }); +}); + describe("ensureGatewayStartupAuth", () => { async function expectEphemeralGeneratedTokenWhenOverridden(cfg: OpenClawConfig) { const result = await ensureGatewayStartupAuth({ @@ -515,36 +527,36 @@ describe("assertGatewayAuthNotKnownWeak", () => { }, ); - it("does not throw on an empty token (falls through to generation path)", () => { - expect(() => + it("allows an empty token to fall through to generation path", () => { + expect( assertGatewayAuthNotKnownWeak({ mode: "token", modeSource: "config", token: "", allowTailscale: false, }), - ).not.toThrow(); + ).toBeUndefined(); }); - it("does not throw on a real token", () => { - expect(() => + it("allows a real token", () => { + expect( assertGatewayAuthNotKnownWeak({ mode: "token", modeSource: "config", token: "a-legit-random-token-0123456789abcdef", allowTailscale: false, }), - ).not.toThrow(); + ).toBeUndefined(); }); - it("does not throw on the none mode", () => { - expect(() => + it("allows the none mode", () => { + expect( assertGatewayAuthNotKnownWeak({ mode: "none", modeSource: "default", allowTailscale: false, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); @@ -569,7 +581,7 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { }); it("allows hooks token when gateway auth is not token mode", () => { - expect(() => + expect( assertHooksTokenSeparateFromGatewayAuth({ cfg: { hooks: { @@ -584,11 +596,11 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { allowTailscale: false, }, }), - ).not.toThrow(); + ).toBeUndefined(); }); it("allows matching values when hooks are disabled", () => { - expect(() => + expect( assertHooksTokenSeparateFromGatewayAuth({ cfg: { hooks: { @@ -603,6 +615,6 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { allowTailscale: false, }, }), - ).not.toThrow(); + ).toBeUndefined(); }); }); diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index 46ee8556f92..b885b653108 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -61,6 +61,9 @@ export function mergeGatewayTailscaleConfig( if (override.resetOnExit !== undefined) { merged.resetOnExit = override.resetOnExit; } + if (override.preserveFunnel !== undefined) { + merged.preserveFunnel = override.preserveFunnel; + } return merged; } diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index c78535672c9..458d8ce8451 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -552,6 +552,13 @@ describe("talk realtime gateway relay", () => { expect(() => createSession("conn-1")).toThrow( "Too many active realtime relay sessions for this connection", ); - expect(() => createSession("conn-2")).not.toThrow(); + expect(createSession("conn-2")).toMatchObject({ + provider: "relay-test", + transport: "gateway-relay", + audio: { + inputEncoding: "pcm16", + outputEncoding: "pcm16", + }, + }); }); }); diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 6c9c6d24c2f..09b6a261dd4 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -127,6 +127,16 @@ vi.mock("../../auto-reply/reply/queue.js", async () => { }; }); +vi.mock("../../auto-reply/reply/queue/cleanup.js", async () => { + const actual = await vi.importActual( + "../../auto-reply/reply/queue/cleanup.js", + ); + return { + ...actual, + clearSessionQueues: sessionCleanupMocks.clearSessionQueues, + }; +}); + vi.mock("../../auto-reply/reply/abort.js", async () => { const actual = await vi.importActual( "../../auto-reply/reply/abort.js", diff --git a/src/hooks/fire-and-forget.test.ts b/src/hooks/fire-and-forget.test.ts index 2545b2b302e..66fe24836ac 100644 --- a/src/hooks/fire-and-forget.test.ts +++ b/src/hooks/fire-and-forget.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { fireAndForgetBoundedHook, fireAndForgetHook } from "./fire-and-forget.js"; +function requireFirstLog(logger: ReturnType): string { + const message = logger.mock.calls[0]?.[0]; + if (typeof message !== "string") { + throw new Error("expected string log message"); + } + return message; +} + describe("fireAndForgetHook", () => { it("logs rejection errors as sanitized single-line messages", async () => { const logger = vi.fn(); @@ -11,8 +19,9 @@ describe("fireAndForgetHook", () => { ); await Promise.resolve(); expect(logger).toHaveBeenCalledWith(expect.stringMatching(/^hook failed: boom forged secret/)); - expect(logger.mock.calls[0]?.[0]).not.toContain("\n"); - expect(logger.mock.calls[0]?.[0]).not.toContain("sk-test1234567890"); + const message = requireFirstLog(logger); + expect(message).not.toContain("\n"); + expect(message).not.toContain("sk-test1234567890"); }); it("does not log for resolved tasks", async () => { diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index 66b2be9a202..def7068c829 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -22,6 +22,14 @@ vi.mock("../agents/pi-embedded.js", () => ({ import { generateSlugViaLLM } from "./llm-slug-generator.js"; +function requireFirstRunOptions(): unknown { + const options = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + if (!options) { + throw new Error("expected embedded Pi agent run options"); + } + return options; +} + describe("generateSlugViaLLM", () => { beforeEach(() => { runEmbeddedPiAgentMock.mockReset(); @@ -37,7 +45,7 @@ describe("generateSlugViaLLM", () => { }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect(requireFirstRunOptions()).toEqual( expect.objectContaining({ timeoutMs: 15_000, cleanupBundleMcpOnRunEnd: true, @@ -58,7 +66,7 @@ describe("generateSlugViaLLM", () => { }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect(requireFirstRunOptions()).toEqual( expect.objectContaining({ timeoutMs: 500_000, }), @@ -96,7 +104,7 @@ describe("generateSlugViaLLM", () => { }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect(requireFirstRunOptions()).toEqual( expect.objectContaining({ provider: "openai-codex", model: "gpt-5.5", diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts index eed0b323d8e..476cb78afbd 100644 --- a/src/hooks/loader.test.ts +++ b/src/hooks/loader.test.ts @@ -292,7 +292,12 @@ describe("loader", () => { const event = createInternalHookEvent("command", "new", "test-session"); await triggerInternalHook(event); - expect(event.messages.filter((message) => message === "reloadable-hook")).toHaveLength(1); + expect( + event.messages.reduce( + (count, message) => count + (message === "reloadable-hook" ? 1 : 0), + 0, + ), + ).toBe(1); }); it("should support named exports", async () => { diff --git a/src/hooks/message-hooks.test.ts b/src/hooks/message-hooks.test.ts index 29a7d7da6a4..6a1e35f1edd 100644 --- a/src/hooks/message-hooks.test.ts +++ b/src/hooks/message-hooks.test.ts @@ -198,11 +198,9 @@ describe("message hooks", () => { }); registerInternalHook("message:received", badHandler); - await expect( - triggerInternalHook( - createInternalHookEvent("message", "received", "s1", { content: "test" }), - ), - ).resolves.not.toThrow(); + await triggerInternalHook( + createInternalHookEvent("message", "received", "s1", { content: "test" }), + ); expect(badHandler).toHaveBeenCalledOnce(); }); @@ -228,9 +226,9 @@ describe("message hooks", () => { }); registerInternalHook("message:sent", asyncFailHandler); - await expect( - triggerInternalHook(createInternalHookEvent("message", "sent", "s1", { content: "reply" })), - ).resolves.not.toThrow(); + await triggerInternalHook( + createInternalHookEvent("message", "sent", "s1", { content: "reply" }), + ); expect(asyncFailHandler).toHaveBeenCalledOnce(); }); }); diff --git a/src/hooks/plugin-hooks.test.ts b/src/hooks/plugin-hooks.test.ts index 89157df925b..58656aaf6ea 100644 --- a/src/hooks/plugin-hooks.test.ts +++ b/src/hooks/plugin-hooks.test.ts @@ -98,6 +98,15 @@ describe("bundle plugin hooks", () => { }; } + function requireOnlyHookEntry(entries: ReturnType) { + expect(entries).toHaveLength(1); + const [entry] = entries; + if (!entry) { + throw new Error("Expected bundled hook entry"); + } + return entry; + } + it("exposes enabled bundle hook dirs as plugin-managed hook entries", async () => { const bundleRoot = await writeBundleHookFixture(); @@ -105,14 +114,14 @@ describe("bundle plugin hooks", () => { config: createConfig(true), }); - expect(entries).toHaveLength(1); - expect(entries[0]?.hook.name).toBe("bundle-hook"); - expect(entries[0]?.hook.source).toBe("openclaw-plugin"); - expect(entries[0]?.hook.pluginId).toBe("sample-bundle"); - expect(entries[0]?.hook.baseDir).toBe( + const entry = requireOnlyHookEntry(entries); + expect(entry.hook.name).toBe("bundle-hook"); + expect(entry.hook.source).toBe("openclaw-plugin"); + expect(entry.hook.pluginId).toBe("sample-bundle"); + expect(entry.hook.baseDir).toBe( fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")), ); - expect(entries[0]?.metadata?.events).toEqual(["command:new"]); + expect(entry.metadata?.events).toEqual(["command:new"]); }); it("loads and executes enabled bundle hooks through the internal hook loader", async () => { diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 90d2a80ba3e..91360253d8e 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -60,6 +60,19 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, })); +function requireFirstRequestHeaders(mock: ReturnType): Headers { + const [request] = (mock.mock.calls[0] ?? []) as [{ headers?: Headers }?]; + if (!request) { + throw new Error("expected request call"); + } + const headers = request.headers; + expect(headers).toBeInstanceOf(Headers); + if (!headers) { + throw new Error("expected request headers"); + } + return headers; +} + function createProvider(overrides: Partial = {}) { return createOpenAiCompatibleImageGenerationProvider({ id: "sample", @@ -185,7 +198,7 @@ describe("OpenAI-compatible image provider helper", () => { }, }), ); - const headers = postJsonRequestMock.mock.calls[0]?.[0].headers as Headers; + const headers = requireFirstRequestHeaders(postJsonRequestMock); expect(headers.get("Content-Type")).toBe("application/json"); expect(result).toMatchObject({ model: "custom-image", @@ -212,7 +225,7 @@ describe("OpenAI-compatible image provider helper", () => { body: expect.any(FormData), }), ); - const headers = postMultipartRequestMock.mock.calls[0]?.[0].headers as Headers; + const headers = requireFirstRequestHeaders(postMultipartRequestMock); expect(headers.has("Content-Type")).toBe(false); }); diff --git a/src/image-generation/provider-registry.test.ts b/src/image-generation/provider-registry.test.ts index 6c4ed231fc1..5ececbdb82a 100644 --- a/src/image-generation/provider-registry.test.ts +++ b/src/image-generation/provider-registry.test.ts @@ -34,6 +34,14 @@ async function loadProviderRegistry() { return await import("./provider-registry.js"); } +function requireImageProvider(id: string): ImageGenerationProviderPlugin { + const provider = getImageGenerationProvider(id); + if (!provider) { + throw new Error(`expected image generation provider ${id}`); + } + return provider; +} + describe("image-generation provider registry", () => { beforeEach(async () => { resolvePluginCapabilityProvidersMock.mockReset(); @@ -56,7 +64,7 @@ describe("image-generation provider registry", () => { const provider = getImageGenerationProvider("custom-image"); - expect(provider?.id).toBe("custom-image"); + expect(provider).toMatchObject({ id: "custom-image" }); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "imageGenerationProviders", cfg: undefined, @@ -72,6 +80,6 @@ describe("image-generation provider registry", () => { expect(listImageGenerationProviders().map((provider) => provider.id)).toEqual(["safe-image"]); expect(getImageGenerationProvider("__proto__")).toBeUndefined(); expect(getImageGenerationProvider("constructor")).toBeUndefined(); - expect(getImageGenerationProvider("safe-alias")?.id).toBe("safe-image"); + expect(requireImageProvider("safe-alias")).toMatchObject({ id: "safe-image" }); }); }); diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index fc98135dcd7..5875e9d7170 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -189,13 +189,13 @@ describe("agent-events sequencing", () => { seen.push(evt.runId); }); - expect(() => + expect( emitAgentEvent({ runId: "run-safe", stream: "assistant", data: { text: "hi" }, }), - ).not.toThrow(); + ).toBeUndefined(); stopGood(); stopBad(); diff --git a/src/infra/approval-handler-runtime.test.ts b/src/infra/approval-handler-runtime.test.ts index a9e0e22128b..f38b5f1682e 100644 --- a/src/infra/approval-handler-runtime.test.ts +++ b/src/infra/approval-handler-runtime.test.ts @@ -84,6 +84,18 @@ function createTestApprovalHandler(capability: ApprovalCapability) { }); } +type ApprovalHandlerRuntime = NonNullable>>; + +function expectApprovalRuntime( + runtime: Awaited>, +): ApprovalHandlerRuntime { + expect(runtime).toEqual(expect.objectContaining({ handleRequested: expect.any(Function) })); + if (runtime === null) { + throw new Error("Expected approval handler runtime"); + } + return runtime; +} + describe("createChannelApprovalHandlerFromCapability", () => { it("returns null when the capability does not expose a native runtime", async () => { await expect( @@ -116,7 +128,7 @@ describe("createChannelApprovalHandlerFromCapability", () => { ...TEST_HANDLER_PARAMS, }); - expect(runtime).not.toBeNull(); + expectApprovalRuntime(runtime); }); it("preserves the original request and resolved approval kind when stop-time cleanup unbinds", async () => { @@ -128,7 +140,7 @@ describe("createChannelApprovalHandlerFromCapability", () => { }), ); - expect(runtime).not.toBeNull(); + const approvalRuntime = expectApprovalRuntime(runtime); const request = { id: "custom:1", expiresAtMs: Date.now() + 60_000, @@ -138,8 +150,8 @@ describe("createChannelApprovalHandlerFromCapability", () => { }, } as never; - await runtime?.handleRequested(request); - await runtime?.stop(); + await approvalRuntime.handleRequested(request); + await approvalRuntime.stop(); expect(unbindPending).toHaveBeenCalledWith( expect.objectContaining({ @@ -161,12 +173,12 @@ describe("createChannelApprovalHandlerFromCapability", () => { }), ); - expect(runtime).not.toBeNull(); + const approvalRuntime = expectApprovalRuntime(runtime); const request = makeExecApprovalRequest("exec:1"); - await runtime?.handleRequested(request); - await runtime?.handleRequested(request); - await runtime?.handleResolved({ + await approvalRuntime.handleRequested(request); + await approvalRuntime.handleRequested(request); + await approvalRuntime.handleResolved({ id: "exec:1", decision: "approved", resolvedBy: "operator", @@ -207,9 +219,10 @@ describe("createChannelApprovalHandlerFromCapability", () => { const request = makeExecApprovalRequest("exec:2"); - await runtime?.handleRequested(request); + const approvalRuntime = expectApprovalRuntime(runtime); + await approvalRuntime.handleRequested(request); await expect( - runtime?.handleResolved({ + approvalRuntime.handleResolved({ id: "exec:2", decision: "approved", resolvedBy: "operator", @@ -240,15 +253,16 @@ describe("createChannelApprovalHandlerFromCapability", () => { const request = makeExecApprovalRequest("exec:stop-1"); - await runtime?.handleRequested(request); - await runtime?.handleRequested({ + const approvalRuntime = expectApprovalRuntime(runtime); + await approvalRuntime.handleRequested(request); + await approvalRuntime.handleRequested({ ...request, id: "exec:stop-2", }); - await expect(runtime?.stop()).resolves.toBeUndefined(); + await expect(approvalRuntime.stop()).resolves.toBeUndefined(); expect(unbindPending).toHaveBeenCalledTimes(2); - await expect(runtime?.stop()).resolves.toBeUndefined(); + await expect(approvalRuntime.stop()).resolves.toBeUndefined(); expect(unbindPending).toHaveBeenCalledTimes(2); }); }); diff --git a/src/infra/archive-helpers.test.ts b/src/infra/archive-helpers.test.ts index 440444f262a..95351df001b 100644 --- a/src/infra/archive-helpers.test.ts +++ b/src/infra/archive-helpers.test.ts @@ -142,7 +142,7 @@ describe("archive helpers", () => { }, }); - expect(() => checker({ path: "package", type: "Directory", size: 0 })).not.toThrow(); + checker({ path: "package", type: "Directory", size: 0 }); checker({ path: "package/a.txt", type: "File", size: 6 }); expectTarPreflightError( checker, diff --git a/src/infra/archive-path.test.ts b/src/infra/archive-path.test.ts index 97a83a30e50..36453bd7b6c 100644 --- a/src/infra/archive-path.test.ts +++ b/src/infra/archive-path.test.ts @@ -30,7 +30,7 @@ describe("archive path helpers", () => { }); it.each(["", ".", "./"])("accepts empty-like entry paths: %j", (entryPath) => { - expect(() => validateArchiveEntryPath(entryPath)).not.toThrow(); + expect(validateArchiveEntryPath(entryPath)).toBeUndefined(); }); it.each([ diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 8a8dfd6ccda..05186565a74 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -175,18 +175,18 @@ describe("createBackupArchive", () => { }); const entries = await listArchiveEntries(result.archivePath); - expect( - entries.some((entry) => entry.endsWith("/state/extensions/demo/openclaw.plugin.json")), - ).toBe(true); - expect(entries.some((entry) => entry.endsWith("/state/extensions/demo/src/index.js"))).toBe( - true, + const entrySuffixes = entries.map((entry) => entry.replace(/^.*\/state\//, "/state/")); + expect(entrySuffixes).toEqual( + expect.arrayContaining([ + "/state/extensions/demo/openclaw.plugin.json", + "/state/extensions/demo/src/index.js", + "/state/node_modules/root-dep/index.js", + ]), ); - expect( - entries.some((entry) => entry.endsWith("/state/node_modules/root-dep/index.js")), - ).toBe(true); - expect( - entries.some((entry) => entry.includes("/state/extensions/demo/node_modules/")), - ).toBe(false); + const pluginNodeModuleEntries = entries.filter((entry) => + entry.includes("/state/extensions/demo/node_modules/"), + ); + expect(pluginNodeModuleEntries).toEqual([]); const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; await expect( diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index bf866b491e7..9690ddbbb28 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -4,6 +4,20 @@ import { discoverGatewayBeacons } from "./bonjour-discovery.js"; const WIDE_AREA_DOMAIN = "openclaw.internal."; +function collectMatching( + items: readonly T[], + predicate: (item: T) => boolean, + map: (item: T) => U, +): U[] { + const matches: U[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(map(item)); + } + } + return matches; +} + describe("bonjour-discovery", () => { it("discovers beacons on darwin across local + wide-area domains", async () => { const calls: Array<{ argv: string[]; timeoutMs: number }> = []; @@ -293,9 +307,13 @@ describe("bonjour-discovery", () => { run: run as unknown as typeof runCommandWithTimeout, }); - expect(calls.filter((c) => c[1] === "-B").map((c) => c[3])).toEqual( - expect.arrayContaining(["local.", "openclaw.internal."]), - ); + expect( + collectMatching( + calls, + (c) => c[1] === "-B", + (c) => c[3], + ), + ).toEqual(expect.arrayContaining(["local.", "openclaw.internal."])); calls.length = 0; await discoverGatewayBeacons({ @@ -305,7 +323,7 @@ describe("bonjour-discovery", () => { run: run as unknown as typeof runCommandWithTimeout, }); - expect(calls.filter((c) => c[1] === "-B")).toHaveLength(1); + expect(calls.reduce((count, c) => count + (c[1] === "-B" ? 1 : 0), 0)).toBe(1); expect(calls.find((c) => c[1] === "-B")?.[3]).toBe("local."); }); }); diff --git a/src/infra/channel-runtime-context.test.ts b/src/infra/channel-runtime-context.test.ts index f458515d11d..19a784afde4 100644 --- a/src/infra/channel-runtime-context.test.ts +++ b/src/infra/channel-runtime-context.test.ts @@ -35,7 +35,7 @@ describe("channel runtime context helpers", () => { const scoped = createTaskScopedChannelRuntime({}); expect(scoped.channelRuntime).toBeUndefined(); - expect(() => scoped.dispose()).not.toThrow(); + expect(scoped.dispose()).toBeUndefined(); }); it("disposes only task-scoped registrations", () => { diff --git a/src/infra/command-analysis/inline-eval.test.ts b/src/infra/command-analysis/inline-eval.test.ts index 8c684da53c6..2bdae3e039c 100644 --- a/src/infra/command-analysis/inline-eval.test.ts +++ b/src/infra/command-analysis/inline-eval.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from "vitest"; +import type { InterpreterInlineEvalHit } from "./inline-eval.js"; import { describeInterpreterInlineEval, detectInterpreterInlineEvalArgv, isInterpreterLikeAllowlistPattern, } from "./inline-eval.js"; +function expectInlineEvalDescription(hit: InterpreterInlineEvalHit | null, expected: string) { + expect(hit).toEqual(expect.any(Object)); + if (hit === null) { + throw new Error(`Expected inline eval hit for ${expected}`); + } + expect(describeInterpreterInlineEval(hit)).toBe(expected); +} + describe("exec inline eval detection", () => { it.each([ { argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" }, @@ -15,8 +24,7 @@ describe("exec inline eval detection", () => { { argv: ["gawk", "-F", ",", "{print $1}", "data.csv"], expected: "gawk inline program" }, ] as const)("detects interpreter eval flags for %j", ({ argv, expected }) => { const hit = detectInterpreterInlineEvalArgv([...argv]); - expect(hit).not.toBeNull(); - expect(describeInterpreterInlineEval(hit!)).toBe(expected); + expectInlineEvalDescription(hit, expected); }); it.each([ @@ -46,8 +54,7 @@ describe("exec inline eval detection", () => { { argv: ["sed", "-es/.*/id/e", "/dev/null"], expected: "sed -e" }, ] as const)("detects command carriers for %j", ({ argv, expected }) => { const hit = detectInterpreterInlineEvalArgv([...argv]); - expect(hit).not.toBeNull(); - expect(describeInterpreterInlineEval(hit!)).toBe(expected); + expectInlineEvalDescription(hit, expected); }); it("ignores normal script execution", () => { diff --git a/src/infra/diagnostics-timeline.test.ts b/src/infra/diagnostics-timeline.test.ts index 97dbf47b594..64737028c47 100644 --- a/src/infra/diagnostics-timeline.test.ts +++ b/src/infra/diagnostics-timeline.test.ts @@ -134,8 +134,8 @@ describe("diagnostics timeline", () => { count: 2, }, }); - expect(event?.timestamp).toEqual(expect.any(String)); - expect(event?.pid).toEqual(expect.any(Number)); + expect(event?.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u); + expect(event?.pid).toBe(process.pid); expect((event?.attributes as Record).ignored).toBeUndefined(); }); @@ -168,7 +168,7 @@ describe("diagnostics timeline", () => { attributes: { pluginCount: 3 }, }); expect(events[1]?.spanId).toBe(events[0]?.spanId); - expect(events[1]?.durationMs).toEqual(expect.any(Number)); + expect(events[1]?.durationMs).toBeGreaterThanOrEqual(0); }); it("records span error events and rethrows failures", async () => { diff --git a/src/infra/exec-approval-channel-runtime.test.ts b/src/infra/exec-approval-channel-runtime.test.ts index 181eea169cb..ae7fab6af0c 100644 --- a/src/infra/exec-approval-channel-runtime.test.ts +++ b/src/infra/exec-approval-channel-runtime.test.ts @@ -33,12 +33,15 @@ let createExecApprovalChannelRuntime: typeof import("./exec-approval-channel-run let ExecApprovalChannelRuntimeTerminalStartError: typeof import("./exec-approval-channel-runtime.js").ExecApprovalChannelRuntimeTerminalStartError; function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/infra/exec-safe-bin-policy.test.ts b/src/infra/exec-safe-bin-policy.test.ts index 44d7f2def15..a5d21f9d9d1 100644 --- a/src/infra/exec-safe-bin-policy.test.ts +++ b/src/infra/exec-safe-bin-policy.test.ts @@ -26,14 +26,21 @@ function normalizeGeneratedDocBlock(block: string): string { while (lines.at(-1)?.trim() === "") { lines.pop(); } - const indents = lines - .filter((line) => line.trim().length > 0) - .map((line) => line.match(/^ */)?.[0].length ?? 0); - const commonIndent = Math.min(...indents); + let commonIndent = Infinity; + for (const line of lines) { + if (line.trim().length === 0) { + continue; + } + commonIndent = Math.min(commonIndent, line.match(/^ */)?.[0].length ?? 0); + } if (commonIndent <= 0) { return lines.join("\n"); } - return lines.map((line) => line.slice(Math.min(line.length, commonIndent))).join("\n"); + const normalizedLines: string[] = []; + for (const line of lines) { + normalizedLines.push(line.slice(Math.min(line.length, commonIndent))); + } + return normalizedLines.join("\n"); } function buildDeniedFlagArgvVariants(flag: string): string[][] { diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index d7699cf3bd4..136aa3bba32 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -1,13 +1,17 @@ import "./fs-safe-defaults.js"; +import fs from "node:fs/promises"; import path from "node:path"; -import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced"; +import { + ensureDirectoryWithinRoot, + findExistingAncestor, + writeViaSiblingTempPath, +} from "@openclaw/fs-safe/advanced"; import { root as fsSafeRoot, type ReadResult } from "@openclaw/fs-safe/root"; export { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe/errors"; export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, - ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, @@ -63,6 +67,39 @@ export type ExternalFileWriteResult = { path: string; }; +export async function ensureAbsoluteDirectory( + dirPath: string, + options?: { scopeLabel?: string; mode?: number }, +): Promise<{ ok: true; path: string } | { ok: false; error: Error }> { + const absolutePath = path.resolve(dirPath); + const scopeLabel = options?.scopeLabel ?? "directory"; + const existingAncestor = await findExistingAncestor(absolutePath); + if (!existingAncestor) { + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + if (existingAncestor === absolutePath) { + try { + const stat = await fs.lstat(absolutePath); + if (!stat.isSymbolicLink() && stat.isDirectory()) { + return { ok: true, path: absolutePath }; + } + } catch { + // Fall through to the uniform invalid-path result below. + } + return { ok: false, error: new Error(`Invalid path: must stay within ${scopeLabel}`) }; + } + const result = await ensureDirectoryWithinRoot({ + rootDir: existingAncestor, + requestedPath: path.relative(existingAncestor, absolutePath), + scopeLabel, + mode: options?.mode, + }); + if (result.ok) { + return result; + } + return { ok: false, error: new Error(result.error) }; +} + export async function writeExternalFileWithinRoot( options: ExternalFileWriteOptions, ): Promise { diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index 8a1c3cd54b6..2ac7c660815 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -10,6 +10,8 @@ import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { acquireGatewayLock, GatewayLockError, type GatewayLockOptions } from "./gateway-lock.js"; +type GatewayLock = NonNullable>>; + const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-lock-" }); let fixtureRoot = ""; const realNow = Date.now.bind(Date); @@ -47,6 +49,14 @@ async function acquireForTest( }); } +function expectGatewayLock(lock: Awaited>): GatewayLock { + expect(lock).toEqual(expect.objectContaining({ release: expect.any(Function) })); + if (lock === null) { + throw new Error("Expected gateway lock"); + } + return lock; +} + function resolveLockPath(env: NodeJS.ProcessEnv) { const stateDir = resolveStateDir(env); const configPath = resolveConfigPath(env, stateDir); @@ -175,7 +185,7 @@ describe("gateway lock", () => { vi.useRealTimers(); const env = await makeEnv(); const lock = await acquireForTest(env, { timeoutMs: 50 }); - expect(lock).not.toBeNull(); + const acquiredLock = expectGatewayLock(lock); const pending = acquireForTest(env, { timeoutMs: 15, @@ -183,9 +193,9 @@ describe("gateway lock", () => { }); await expect(pending).rejects.toBeInstanceOf(GatewayLockError); - await lock?.release(); + await acquiredLock.release(); const lock2 = await acquireForTest(env); - await lock2?.release(); + await expectGatewayLock(lock2).release(); }); it("treats recycled linux pid as stale when start time mismatches", async () => { @@ -204,9 +214,9 @@ describe("gateway lock", () => { pollIntervalMs: 5, platform: "linux", }); - expect(lock).not.toBeNull(); + const acquiredLock = expectGatewayLock(lock); - await lock?.release(); + await acquiredLock.release(); spy.mockRestore(); }); @@ -259,8 +269,7 @@ describe("gateway lock", () => { platform: "darwin", port: 18789, }); - expect(lock).not.toBeNull(); - await lock?.release(); + await expectGatewayLock(lock).release(); connectSpy.mockRestore(); }); @@ -329,8 +338,7 @@ describe("gateway lock", () => { port: 18789, readProcessCmdline: () => ["chrome.exe", "--no-sandbox"], }); - expect(lock).not.toBeNull(); - await lock?.release(); + await expectGatewayLock(lock).release(); connectSpy.mockRestore(); }); @@ -394,8 +402,7 @@ describe("gateway lock", () => { port: 18789, readProcessCmdline: () => ["/Applications/Safari.app/Contents/MacOS/Safari"], }); - expect(lock).not.toBeNull(); - await lock?.release(); + await expectGatewayLock(lock).release(); connectSpy.mockRestore(); }); diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 6d28fa597fd..e5876447a79 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -183,9 +183,6 @@ describe("git commit resolution", () => { .trim() .slice(0, 7); - expect(() => - resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} }), - ).not.toThrow(); expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} })).toBe( repoHead, ); diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 7351a2b7703..1f1e38019ad 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -103,12 +103,18 @@ describe("Ghost reminder bug (issue #13317)", () => { } | null, reminderText: string, ) => { - expect(calledCtx).not.toBeNull(); - expect(calledCtx?.Provider).toBe("cron-event"); - expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); - expect(calledCtx?.Body).toContain(reminderText); - expect(calledCtx?.Body).not.toContain("HEARTBEAT_OK"); - expect(calledCtx?.Body).not.toContain("heartbeat poll"); + expect(calledCtx).toEqual( + expect.objectContaining({ + Provider: "cron-event", + Body: expect.stringContaining("scheduled reminder has been triggered"), + }), + ); + if (calledCtx === null || typeof calledCtx.Body !== "string") { + throw new Error("Expected cron event prompt body"); + } + expect(calledCtx.Body).toContain(reminderText); + expect(calledCtx.Body).not.toContain("HEARTBEAT_OK"); + expect(calledCtx.Body).not.toContain("heartbeat poll"); }; const runCronReminderCase = async ( diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 5a790370f53..c60762a9ff7 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1134,19 +1134,25 @@ describe("runHeartbeatOnce", () => { typedCases<{ name: string; caseDir: string; - replies: Array<{ text: string }>; + replies: Array<{ text: string; isReasoning?: boolean }>; expectedTexts: string[]; }>([ { - name: "reasoning + final payload", + name: "legacy-prefixed reasoning + final payload", caseDir: "hb-reasoning", replies: [{ text: "Reasoning:\n_Because it helps_" }, { text: "Final alert" }], expectedTexts: ["Reasoning:\n_Because it helps_", "Final alert"], }, { - name: "reasoning + HEARTBEAT_OK", + name: "raw flagged reasoning + final payload", + caseDir: "hb-reasoning-raw", + replies: [{ text: "Because it helps", isReasoning: true }, { text: "Final alert" }], + expectedTexts: ["Reasoning:\n_Because it helps_", "Final alert"], + }, + { + name: "raw flagged reasoning + HEARTBEAT_OK", caseDir: "hb-reasoning-heartbeat-ok", - replies: [{ text: "Reasoning:\n_Because it helps_" }, { text: "HEARTBEAT_OK" }], + replies: [{ text: "Because it helps", isReasoning: true }, { text: "HEARTBEAT_OK" }], expectedTexts: ["Reasoning:\n_Because it helps_"], }, ]), diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index 113382b2d37..e3728bb1f1b 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -160,16 +160,16 @@ describe("startHeartbeatRunner", () => { expect(runSpy.mock.calls.slice(1).map((call) => call[0]?.agentId)).toEqual( expect.arrayContaining(["main", "ops"]), ); - expect( - runSpy.mock.calls.some( - (call) => call[0]?.agentId === "main" && call[0]?.heartbeat?.every === "10m", - ), - ).toBe(true); - expect( - runSpy.mock.calls.some( - (call) => call[0]?.agentId === "ops" && call[0]?.heartbeat?.every === "15m", - ), - ).toBe(true); + const reloadedHeartbeatCalls = runSpy.mock.calls.map((call) => ({ + agentId: call[0]?.agentId, + every: call[0]?.heartbeat?.every, + })); + expect(reloadedHeartbeatCalls).toEqual( + expect.arrayContaining([ + { agentId: "main", every: "10m" }, + { agentId: "ops", every: "15m" }, + ]), + ); runner.stop(); }); @@ -353,12 +353,18 @@ describe("startHeartbeatRunner", () => { requestHeartbeat(wake("retry", { coalesceMs: 0 })); await vi.advanceTimersByTimeAsync(1_000); } - expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(false); + const scheduledSlotCallsBeforeInterval = callTimes.filter( + (time) => time >= firstDueMs + intervalMs, + ); + expect(scheduledSlotCallsBeforeInterval).toEqual([]); // The next interval tick at the next scheduled slot should still fire — // the retries must not push the phase out by multiple intervals. await vi.advanceTimersByTimeAsync(firstDueMs + intervalMs - Date.now() + 1); - expect(callTimes.some((time) => time >= firstDueMs + intervalMs)).toBe(true); + const scheduledSlotCallsAfterInterval = callTimes.filter( + (time) => time >= firstDueMs + intervalMs, + ); + expect(scheduledSlotCallsAfterInterval.length).toBeGreaterThan(0); runner.stop(); }); @@ -501,7 +507,8 @@ describe("startHeartbeatRunner", () => { sessionKey: "agent:main:main", }, }); - expect(runSpy.mock.calls.some((call) => call[0]?.agentId === "finance")).toBe(false); + const financeCalls = runSpy.mock.calls.filter((call) => call[0]?.agentId === "finance"); + expect(financeCalls).toEqual([]); runner.stop(); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 1e0dd20c75e..a82138c1f50 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -16,6 +16,7 @@ import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; import { isNestedAgentLane } from "../agents/lanes.js"; import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js"; import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js"; +import { formatReasoningMessage } from "../agents/pi-embedded-utils.js"; import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; import { @@ -639,10 +640,26 @@ function resolveHeartbeatReasoningPayloads( replyResult: ReplyPayload | ReplyPayload[] | undefined, ): ReplyPayload[] { const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : []; - return payloads.filter((payload) => { + const reasoningPayloads: ReplyPayload[] = []; + for (const payload of payloads) { const text = typeof payload.text === "string" ? payload.text : ""; - return text.trimStart().startsWith("Reasoning:"); - }); + const hasLegacyReasoningPrefix = text.trimStart().startsWith("Reasoning:"); + if (payload.isReasoning !== true && !hasLegacyReasoningPrefix) { + continue; + } + + const formattedText = hasLegacyReasoningPrefix ? text : formatReasoningMessage(text); + if (!formattedText.trim()) { + continue; + } + + const deliverablePayload: ReplyPayload = { ...payload, text: formattedText }; + delete deliverablePayload.isReasoning; + delete deliverablePayload.mediaUrl; + delete deliverablePayload.mediaUrls; + reasoningPayloads.push(deliverablePayload); + } + return reasoningPayloads; } async function restoreHeartbeatUpdatedAt(params: { diff --git a/src/infra/heartbeat-wake.test.ts b/src/infra/heartbeat-wake.test.ts index b07f2735dac..bd0eaaeca36 100644 --- a/src/infra/heartbeat-wake.test.ts +++ b/src/infra/heartbeat-wake.test.ts @@ -43,6 +43,11 @@ describe("heartbeat-wake", () => { return handler; } + function expectWakeCall(handler: ReturnType, index: number, request: WakeRequest) { + const [actualRequest] = handler.mock.calls[index] ?? []; + expect(actualRequest).toEqual(request); + } + async function expectRetryAfterDefaultDelay(params: { handler: ReturnType; initialReason: string; @@ -61,7 +66,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(500); expect(params.handler).toHaveBeenCalledTimes(2); - expect(params.handler.mock.calls[1]?.[0]).toEqual(wake(params.expectedRetryReason)); + expectWakeCall(params.handler, 1, wake(params.expectedRetryReason)); } beforeEach(() => { @@ -138,7 +143,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(1); expect(handler).toHaveBeenCalledTimes(2); - expect(handler.mock.calls[1]?.[0]).toEqual(wake("hook:wake")); + expectWakeCall(handler, 1, wake("hook:wake")); }); it("retries thrown handler errors after the default retry delay", async () => { @@ -315,7 +320,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(1); expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0]?.[0]).toEqual({ + expectWakeCall(handler, 0, { source: "cron", intent: "immediate", reason: "cron:job-1", @@ -326,7 +331,7 @@ describe("heartbeat-wake", () => { await vi.advanceTimersByTimeAsync(1000); expect(handler).toHaveBeenCalledTimes(2); - expect(handler.mock.calls[1]?.[0]).toEqual({ + expectWakeCall(handler, 1, { source: "cron", intent: "immediate", reason: "cron:job-1", diff --git a/src/infra/host-env-security.reported-baseline.test.ts b/src/infra/host-env-security.reported-baseline.test.ts index 99a818e265f..c9d8869cc6e 100644 --- a/src/infra/host-env-security.reported-baseline.test.ts +++ b/src/infra/host-env-security.reported-baseline.test.ts @@ -158,7 +158,9 @@ describe("host env reported baseline coverage", () => { for (const key of expectedAllowlistKeys) { expect(INHERITED_ALLOWLIST_RATIONALE[key].trim().length).toBeGreaterThan(0); expect(isDangerousHostInheritedEnvVarName(key)).toBe(false); - expect(isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)).toBe(true); + expect([isDangerousHostEnvVarName(key), isDangerousHostEnvOverrideVarName(key)]).toContain( + true, + ); const inheritedSanitized = sanitizeHostExecEnv({ baseEnv: { diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 2a758236a48..78a778b79a9 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -58,6 +58,16 @@ function withoutSigusr1Listeners(fn: () => void): void { } } +function countSigusr1Emits(calls: readonly unknown[][]): number { + let count = 0; + for (const args of calls) { + if (args[0] === "SIGUSR1") { + count += 1; + } + } + return count; +} + function withRestartSupervisorEnabled(fn: () => void): void { const originalVitest = process.env.VITEST; const originalNodeEnv = process.env.NODE_ENV; @@ -417,10 +427,10 @@ describe("infra runtime", () => { expect(second.cooldownMsApplied).toBe(30_000); await vi.advanceTimersByTimeAsync(29_999); - expect(emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1").length).toBe(1); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(1); await vi.advanceTimersByTimeAsync(1); - expect(emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1").length).toBe(2); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(2); } finally { process.removeListener("SIGUSR1", handler); } @@ -447,7 +457,7 @@ describe("infra runtime", () => { expect(forced.cooldownMsApplied).toBe(0); await vi.advanceTimersByTimeAsync(0); - expect(emitSpy.mock.calls.filter((args) => args[0] === "SIGUSR1").length).toBe(2); + expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(2); expect(peekGatewaySigusr1RestartReason()).toBe("update.run"); } finally { process.removeListener("SIGUSR1", handler); diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index 82bdc89f5bd..05b7328cd91 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -16,14 +16,24 @@ vi.mock("../process/exec.js", async () => { async function listMatchingDirs(root: string, prefix: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) - .map((entry) => entry.name); + const names: string[] = []; + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith(prefix)) { + names.push(entry.name); + } + } + return names; } async function listMatchingEntries(root: string, prefix: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }); - return entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name); + const names: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith(prefix)) { + names.push(entry.name); + } + } + return names; } function normalizeDarwinTmpPath(filePath: string): string { diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 352354dc62c..684d0ab43fe 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -74,7 +74,7 @@ function getDispatcherClassName(value: unknown): string | null { } function expectDispatcherAttached(value: unknown): void { - expect(value).toEqual(expect.any(Object)); + expect(getDispatcherClassName(value)).toMatch(/^(Agent|Mock)$/u); } function getSecondRequestHeaders(fetchImpl: ReturnType): Headers { diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index 91d46a45cce..337b94e697b 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -25,6 +25,7 @@ import { registerManagedProxyGatewayLoopbackNoProxy, startProxy, stopProxy, + type ProxyHandle, } from "./proxy-lifecycle.js"; const mockForceResetGlobalDispatcher = vi.mocked(forceResetGlobalDispatcher); @@ -32,6 +33,24 @@ const mockBootstrapGlobalAgent = vi.mocked(bootstrapGlobalAgent); const mockLogInfo = vi.mocked(logInfo); const mockLogWarn = vi.mocked(logWarn); +function expectProxyHandle(handle: Awaited>): ProxyHandle { + expect(handle).toEqual(expect.objectContaining({ proxyUrl: expect.any(String) })); + if (handle === null) { + throw new Error("Expected managed proxy handle"); + } + return handle; +} + +function expectNoProxyUnregister( + unregister: ReturnType, +): () => void { + expect(unregister).toBeTypeOf("function"); + if (typeof unregister !== "function") { + throw new Error("Expected Gateway NO_PROXY unregister callback"); + } + return unregister; +} + describe("startProxy", () => { const savedEnv: Record = {}; const envKeysToClean = [ @@ -135,9 +154,14 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(getActiveManagedProxyUrl()?.href).toBe("http://127.0.0.1:3128/"); + const activeProxyUrl = getActiveManagedProxyUrl(); + expect(activeProxyUrl).toEqual(expect.any(URL)); + if (activeProxyUrl === undefined) { + throw new Error("Expected active managed proxy URL"); + } + expect(activeProxyUrl.href).toBe("http://127.0.0.1:3128/"); - await stopProxy(handle); + await stopProxy(expectProxyHandle(handle)); expect(getActiveManagedProxyUrl()).toBeUndefined(); }); @@ -147,7 +171,7 @@ describe("startProxy", () => { const handle = await startProxy({ enabled: true }); - expect(handle?.proxyUrl).toBe("http://127.0.0.1:3128"); + expect(expectProxyHandle(handle).proxyUrl).toBe("http://127.0.0.1:3128"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); }); @@ -159,7 +183,7 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3129", }); - expect(handle?.proxyUrl).toBe("http://127.0.0.1:3129"); + expect(expectProxyHandle(handle).proxyUrl).toBe("http://127.0.0.1:3129"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); }); @@ -178,7 +202,7 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(handle).not.toBeNull(); + expectProxyHandle(handle); expect(process.env["http_proxy"]).toBe("http://127.0.0.1:3128"); expect(process.env["https_proxy"]).toBe("http://127.0.0.1:3128"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); @@ -261,12 +285,12 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(handle).not.toBeNull(); + const proxyHandle = expectProxyHandle(handle); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); expect(process.env["NO_PROXY"]).toBe(""); mockForceResetGlobalDispatcher.mockClear(); - await stopProxy(handle); + await stopProxy(proxyHandle); expect(process.env["HTTP_PROXY"]).toBe("http://previous.example.com:8080"); expect(process.env["NO_PROXY"]).toBe("corp.example.com"); @@ -448,12 +472,12 @@ describe("startProxy", () => { }); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); - - expect(unregister).toBeTypeOf("function"); + const unregister = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"), + ); expect(agent["NO_PROXY"]).toBe("127.0.0.1:18789"); - unregister?.(); + unregister(); expect(agent["NO_PROXY"]).toBeNull(); await stopProxy(handle); }); @@ -465,15 +489,17 @@ describe("startProxy", () => { }); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - const unregisterIpv6 = registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789"); - expect(unregisterIpv6).toBeTypeOf("function"); + const unregisterIpv6 = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789"), + ); expect(agent["NO_PROXY"]).toBe("[::1]:18789"); - unregisterIpv6?.(); + unregisterIpv6(); - const unregisterLocalhost = registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789"); - expect(unregisterLocalhost).toBeTypeOf("function"); + const unregisterLocalhost = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789"), + ); expect(agent["NO_PROXY"]).toBe("localhost.:18789"); - unregisterLocalhost?.(); + unregisterLocalhost(); await stopProxy(handle); }); @@ -489,12 +515,12 @@ describe("startProxy", () => { }); const agent = (global as Record)["GLOBAL_AGENT"] as Record; - const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000"); - - expect(unregister).toBeTypeOf("function"); + const unregister = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000"), + ); expect(agent["NO_PROXY"]).toBe("127.0.0.1:3000"); - unregister?.(); + unregister(); await stopProxy(handle); }); @@ -539,12 +565,12 @@ describe("startProxy", () => { const agent = (global as Record)["GLOBAL_AGENT"] as Record; agent["NO_PROXY"] = "corp.example.com"; - const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"); - - expect(unregister).toBeTypeOf("function"); + const unregister = expectNoProxyUnregister( + registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789"), + ); expect(agent["NO_PROXY"]).toBe("corp.example.com,127.0.0.1:18789"); - unregister?.(); + unregister(); expect(agent["NO_PROXY"]).toBe("corp.example.com"); await stopProxy(handle); }); @@ -556,8 +582,7 @@ describe("startProxy", () => { proxyUrl: "http://127.0.0.1:3128", }); - expect(handle).not.toBeNull(); - handle?.kill("SIGTERM"); + expectProxyHandle(handle).kill("SIGTERM"); expect(process.env["HTTP_PROXY"]).toBeUndefined(); expect(process.env["NO_PROXY"]).toBe("corp.example.com"); diff --git a/src/infra/net/runtime-fetch.test.ts b/src/infra/net/runtime-fetch.test.ts index 562ddb9f16b..8790c5e2ccc 100644 --- a/src/infra/net/runtime-fetch.test.ts +++ b/src/infra/net/runtime-fetch.test.ts @@ -40,6 +40,14 @@ class MockProxyAgent { readonly __testStub = true; } +function requireFetchInit(mock: ReturnType): RequestInit { + const init = mock.mock.calls[0]?.[1] as RequestInit | undefined; + if (!init) { + throw new Error("expected runtime fetch init"); + } + return init; +} + afterEach(() => { Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY); }); @@ -74,7 +82,7 @@ describe("fetchWithRuntimeDispatcher", () => { }); expect(response.status).toBe(200); - const sentHeaders = runtimeFetch.mock.calls[0]?.[1]?.headers; + const sentHeaders = requireFetchInit(runtimeFetch).headers; expect(sentHeaders).not.toBe(headers); expect(Object.getOwnPropertySymbols(sentHeaders as object)).toEqual([]); expect(Object.getOwnPropertySymbols(headers)).toHaveLength(1); @@ -124,7 +132,7 @@ describe("fetchWithRuntimeDispatcher", () => { expect(response.status).toBe(200); expect(runtimeFetch).toHaveBeenCalledTimes(1); - const sentInit = runtimeFetch.mock.calls[0]?.[1] as RequestInit; + const sentInit = requireFetchInit(runtimeFetch); const sentHeaders = new Headers(sentInit.headers); expect(sentHeaders.has("content-length")).toBe(false); expect(sentHeaders.has("content-type")).toBe(false); diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index eb074a85297..91301e42cb9 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -27,11 +27,8 @@ async function setupPairedNode(baseDir: string): Promise { baseDir, ); const paired = await getPairedNode("node-1", baseDir); - expect(paired).not.toBeNull(); - if (!paired) { - throw new Error("expected node to be paired"); - } - return paired.token; + expect(paired?.token).toEqual(expect.any(String)); + return paired!.token; } const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" }); diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index 3b0a3ca6ef1..ff75dea03ba 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -12,8 +12,11 @@ import { function parseSpecOrThrow(spec: string) { const parsed = parseRegistryNpmSpec(spec); - expect(parsed).not.toBeNull(); - return parsed!; + expect(parsed).toEqual(expect.any(Object)); + if (parsed === null) { + throw new Error(`Expected ${spec} to parse`); + } + return parsed; } describe("npm registry spec validation", () => { diff --git a/src/infra/outbound/abort.test.ts b/src/infra/outbound/abort.test.ts index 794615b2a28..034909f2d4e 100644 --- a/src/infra/outbound/abort.test.ts +++ b/src/infra/outbound/abort.test.ts @@ -3,8 +3,8 @@ import { throwIfAborted } from "./abort.js"; describe("throwIfAborted", () => { it("does nothing when the signal is missing or not aborted", () => { - expect(() => throwIfAborted()).not.toThrow(); - expect(() => throwIfAborted(new AbortController().signal)).not.toThrow(); + expect(throwIfAborted()).toBeUndefined(); + expect(throwIfAborted(new AbortController().signal)).toBeUndefined(); }); it("throws a standard AbortError when the signal is aborted", () => { diff --git a/src/infra/outbound/current-conversation-bindings.test.ts b/src/infra/outbound/current-conversation-bindings.test.ts index 7a4f2d5e230..50c396b5859 100644 --- a/src/infra/outbound/current-conversation-bindings.test.ts +++ b/src/infra/outbound/current-conversation-bindings.test.ts @@ -13,6 +13,14 @@ import { touchGenericCurrentConversationBinding, unbindGenericCurrentConversationBindings, } from "./current-conversation-bindings.js"; +import type { SessionBindingRecord } from "./session-binding.types.js"; + +function expectSessionBinding(bound: SessionBindingRecord | null): SessionBindingRecord { + if (bound === null) { + throw new Error("Expected current-conversation binding"); + } + return bound; +} function setMinimalCurrentConversationRegistry(): void { setActivePluginRegistry( @@ -315,7 +323,7 @@ describe("generic current-conversation bindings", () => { }, }); - expect(bound).not.toBeNull(); + expectSessionBinding(bound); touchGenericCurrentConversationBinding( "generic:workspace\u241fdefault\u241f\u241fuser:U123", diff --git a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts index 105dc9b7e83..a4f9fa5e717 100644 --- a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts +++ b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts @@ -25,6 +25,16 @@ function normalizeReconnectAccountIdForTest(accountId?: string | null): string { return (accountId ?? "").trim() || "default"; } +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + async function drainDirectChatReconnectPending(opts: { accountId: string; deliver: DeliverFn; @@ -148,13 +158,12 @@ describe("drainPendingDeliveries for reconnect", () => { expect(after.lastError).toBe("transient failure"); }); - it("does not throw if delivery fails during drain", async () => { + it("records retry state if delivery fails during drain", async () => { const log = createRecoveryLog(); const deliver = createTransientFailureDeliver(); await enqueueFailedDirectChatDelivery({ accountId: "acct1", stateDir: tmpDir }); - // Should not throw await expect( drainAcct1DirectChatReconnect({ deliver, log, stateDir: tmpDir }), ).resolves.toBeUndefined(); @@ -348,7 +357,7 @@ describe("drainPendingDeliveries for reconnect", () => { await startupRecovery; expect(deliver).toHaveBeenCalledTimes(2); - expect(deliveredTargets.filter((target) => target === "+1555")).toHaveLength(1); + expect(countMatching(deliveredTargets, (target) => target === "+1555")).toBe(1); expect(startupLog.info).toHaveBeenCalledWith( expect.stringContaining("Recovery skipped for delivery"), ); diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index 24955b7ea99..fe1c4b178d9 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -655,7 +655,8 @@ describe("delivery-queue recovery", () => { const remaining = await loadPendingDeliveries(tmpDir()); expect(remaining).toHaveLength(3); - expect(remaining.every((entry) => entry.retryCount === 1)).toBe(true); + const entriesWithUnexpectedRetryCount = remaining.filter((entry) => entry.retryCount !== 1); + expect(entriesWithUnexpectedRetryCount).toEqual([]); expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next startup")); }); diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 6eebc676362..687217ad70c 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -2,12 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { vi } from "vitest"; import type { ChannelMessageActionName } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { CrossContextDecoration } from "./outbound-policy.js"; let applyCrossContextDecoration: typeof import("./outbound-policy.js").applyCrossContextDecoration; let buildCrossContextDecoration: typeof import("./outbound-policy.js").buildCrossContextDecoration; let enforceCrossContextPolicy: typeof import("./outbound-policy.js").enforceCrossContextPolicy; let shouldApplyCrossContextMarker: typeof import("./outbound-policy.js").shouldApplyCrossContextMarker; +function expectCrossContextDecoration( + decoration: CrossContextDecoration | null, +): CrossContextDecoration { + if (decoration === null) { + throw new Error("Expected cross-context decoration"); + } + return decoration; +} + const mocks = vi.hoisted(() => ({ getChannelPlugin: vi.fn((channel: string) => channel === "richchat" @@ -99,7 +109,7 @@ function expectCrossContextPolicyResult(params: { }, }); if (params.expected === "allow") { - expect(run).not.toThrow(); + expect(run()).toBeUndefined(); return; } expect(run).toThrow(params.expected); @@ -183,10 +193,10 @@ describe("outbound policy helpers", () => { toolContext: { currentChannelId: "C12345678", currentChannelProvider: "richchat" }, }); - expect(decoration).not.toBeNull(); + const requiredDecoration = expectCrossContextDecoration(decoration); const applied = applyCrossContextDecoration({ message: "hello", - decoration: decoration!, + decoration: requiredDecoration, preferPresentation: true, }); diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts index c7a280efb16..665553619ee 100644 --- a/src/infra/ports-format.test.ts +++ b/src/infra/ports-format.test.ts @@ -76,6 +76,7 @@ describe("ports-format", () => { }); expect(lines[0]).toContain("Port 18789 is already in use"); expect(lines).toContain("- pid 123 alice: ssh -N -L 18789:127.0.0.1:18789"); - expect(lines.some((line) => line.includes("SSH tunnel"))).toBe(true); + const sshTunnelHints = lines.filter((line) => line.includes("SSH tunnel")); + expect(sshTunnelHints.length).toBeGreaterThan(0); }); }); diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 7ace7670e56..17849ed749e 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -130,7 +130,8 @@ describeUnix("inspectPortUsage", () => { try { const result = await inspectPortUsage(port); expect(result.status).toBe("busy"); - expect(result.errors?.some((err) => err.includes("ENOENT"))).toBe(true); + const enoentErrors = (result.errors ?? []).filter((err) => err.includes("ENOENT")); + expect(enoentErrors.length).toBeGreaterThan(0); } finally { await new Promise((resolve) => server.close(() => resolve())); } @@ -230,9 +231,10 @@ describe("inspectPortUsage on Windows", () => { expect(result.listeners).toHaveLength(1); expect(result.listeners[0]?.command).toBe("node.exe"); expect(result.listeners[0]?.commandLine).toContain("openclaw"); - expect(result.hints.some((hint) => hint.includes("Gateway already running locally"))).toBe( - true, + const gatewayRunningHints = result.hints.filter((hint) => + hint.includes("Gateway already running locally"), ); + expect(gatewayRunningHints.length).toBeGreaterThan(0); }); it("falls back to wmic when PowerShell cannot read the command line", async () => { @@ -265,6 +267,7 @@ describe("inspectPortUsage on Windows", () => { const result = await inspectPortUsage(18789); expect(result.listeners[0]?.commandLine).toContain("openclaw"); - expect(runCommandWithTimeoutMock.mock.calls.some(([argv]) => argv[0] === "wmic")).toBe(true); + const commandNames = runCommandWithTimeoutMock.mock.calls.map(([argv]) => argv[0]); + expect(commandNames).toContain("wmic"); }); }); diff --git a/src/infra/provider-usage.fetch.claude.test.ts b/src/infra/provider-usage.fetch.claude.test.ts index 3bde3c4fa90..e9b82c9ad4f 100644 --- a/src/infra/provider-usage.fetch.claude.test.ts +++ b/src/infra/provider-usage.fetch.claude.test.ts @@ -41,7 +41,7 @@ async function expectMissingScopeWithoutFallback(mockFetch: ScopeFallbackFetch) expectMissingScopeError(result); const calledUrls = mockFetch.mock.calls.map(([input]) => toRequestUrl(input)); expect(calledUrls.length).toBeGreaterThan(0); - expect(calledUrls.filter((url) => !url.includes("/api/oauth/usage"))).toEqual([]); + expect(calledUrls.every((url) => url.includes("/api/oauth/usage"))).toBe(true); } function makeOrgAResponse() { diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 12e90ff4bbe..95a3d02acb3 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -589,7 +589,8 @@ describe("push APNs send semantics", () => { }, }, }); - expect(sent?.signature).toEqual(expect.any(String)); + expect(sent?.signature).toBeTypeOf("string"); + expect(sent?.signature).not.toBe(""); expect(result).toMatchObject({ ok: true, status: 202, diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index 68056bf6b9b..3aaffe139ae 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -14,6 +14,8 @@ import { sendWebPushNotification, } from "./push-web.js"; +type WebPushSubscription = NonNullable>>; + // Stub resolveStateDir so tests use a temp directory. let tmpDir: string; vi.mock("../config/paths.js", () => ({ @@ -32,6 +34,16 @@ vi.mock("web-push", () => ({ }, })); +function expectLoadedSubscription( + loaded: Awaited>, +): WebPushSubscription { + expect(loaded).toEqual(expect.objectContaining({ endpoint: expect.any(String) })); + if (loaded === null) { + throw new Error("Expected loaded web push subscription"); + } + return loaded; +} + beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "push-web-test-")); vi.clearAllMocks(); @@ -118,8 +130,7 @@ describe("subscription CRUD", () => { baseDir: tmpDir, }); const loaded = await loadWebPushSubscription(sub.subscriptionId, tmpDir); - expect(loaded).not.toBeNull(); - expect(loaded!.endpoint).toBe(endpoint); + expect(expectLoadedSubscription(loaded).endpoint).toBe(endpoint); }); it("returns null for unknown subscription ID", async () => { @@ -222,7 +233,7 @@ describe("sending", () => { const results = await broadcastWebPush({ title: "Broadcast" }, tmpDir); expect(results).toHaveLength(2); - expect(results.filter((result) => !result.ok)).toEqual([]); + expect(results.every((result) => result.ok)).toBe(true); expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1); expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(2); }); diff --git a/src/infra/restart-handoff.test.ts b/src/infra/restart-handoff.test.ts index ad138720ef9..854176bac4d 100644 --- a/src/infra/restart-handoff.test.ts +++ b/src/infra/restart-handoff.test.ts @@ -10,6 +10,7 @@ import { readGatewayRestartHandoffSync, writeGatewayRestartHandoffSync, } from "./restart-handoff.js"; +import type { GatewayRestartHandoff } from "./restart-handoff.js"; const tempDirs: string[] = []; @@ -26,6 +27,16 @@ function handoffPath(env: NodeJS.ProcessEnv): string { return path.join(env.OPENCLAW_STATE_DIR ?? "", GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME); } +function expectWrittenHandoff( + opts: Parameters[0], +): GatewayRestartHandoff { + const handoff = writeGatewayRestartHandoffSync(opts); + if (handoff === null) { + throw new Error("Expected gateway restart handoff to be written"); + } + return handoff; +} + describe("gateway restart handoff", () => { afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -68,16 +79,14 @@ describe("gateway restart handoff", () => { it("consumes a fresh handoff by exited pid instead of current process pid", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: process.pid + 1, - reason: "update.run", - restartKind: "update-process", - supervisorMode: "systemd", - createdAt: 2_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: process.pid + 1, + reason: "update.run", + restartKind: "update-process", + supervisorMode: "systemd", + createdAt: 2_000, + }); expect( consumeGatewayRestartHandoffForExitedProcessSync({ @@ -97,15 +106,13 @@ describe("gateway restart handoff", () => { it("rejects handoffs for a different exited pid and clears them", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 111, - restartKind: "full-process", - supervisorMode: "external", - createdAt: 1_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 111, + restartKind: "full-process", + supervisorMode: "external", + createdAt: 1_000, + }); expect( consumeGatewayRestartHandoffForExitedProcessSync({ @@ -120,16 +127,14 @@ describe("gateway restart handoff", () => { it("rejects a handoff when the supplied process instance does not match", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 111, - processInstanceId: "gateway-instance-1", - restartKind: "full-process", - supervisorMode: "external", - createdAt: 1_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 111, + processInstanceId: "gateway-instance-1", + restartKind: "full-process", + supervisorMode: "external", + createdAt: 1_000, + }); expect( consumeGatewayRestartHandoffForExitedProcessSync({ @@ -168,16 +173,14 @@ describe("gateway restart handoff", () => { it("rejects expired and oversized handoff files", () => { const env = createHandoffEnv(); - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 111, - restartKind: "full-process", - supervisorMode: "external", - createdAt: 1_000, - ttlMs: 1_000, - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 111, + restartKind: "full-process", + supervisorMode: "external", + createdAt: 1_000, + ttlMs: 1_000, + }); expect(readGatewayRestartHandoffSync(env, 2_001)).toBeNull(); fs.writeFileSync(handoffPath(env), "x".repeat(8192), { encoding: "utf8", mode: 0o600 }); @@ -231,14 +234,12 @@ describe("gateway restart handoff", () => { return; } - expect( - writeGatewayRestartHandoffSync({ - env, - pid: 12_345, - restartKind: "full-process", - supervisorMode: "external", - }), - ).not.toBeNull(); + expectWrittenHandoff({ + env, + pid: 12_345, + restartKind: "full-process", + supervisorMode: "external", + }); expect(fs.readFileSync(targetPath, "utf8")).toBe("keep"); expect(fs.lstatSync(handoffPath(env)).isSymbolicLink()).toBe(false); diff --git a/src/infra/restart-stale-pids.test.ts b/src/infra/restart-stale-pids.test.ts index 48d0bc564c0..5b938a1e0be 100644 --- a/src/infra/restart-stale-pids.test.ts +++ b/src/infra/restart-stale-pids.test.ts @@ -672,8 +672,8 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { mockSpawnSync.mockImplementation(() => ({ error: null, status: 1, stdout: "", stderr: "" })); vi.spyOn(process, "kill").mockReturnValue(true); - // Must not throw — the catch path returns transient inconclusive, loop continues - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + // The catch path returns transient inconclusive, then the loop continues. + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); }); }); @@ -759,7 +759,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { cleanStaleGatewayProcessesSync(); expect(events).toContain("port-free"); - expect(events.filter((e) => e.startsWith("busy-poll")).length).toBeGreaterThan(0); + expect(events.some((e) => e.startsWith("busy-poll"))).toBe(true); }); it("bails immediately when lsof is permanently unavailable (ENOENT) — Greptile edge case", () => { @@ -777,7 +777,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { }); vi.spyOn(process, "kill").mockReturnValue(true); - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Must bail after first ENOENT poll — no point retrying a missing binary const enoentPolls = events.filter((e) => e.startsWith("enoent-poll")); @@ -792,7 +792,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { createErrnoResult("EPERM", "lsof eperm"), ); vi.spyOn(process, "kill").mockReturnValue(true); - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Must bail after exactly 1 EPERM poll — same as ENOENT/EACCES expect(getCallCount()).toBe(2); // 1 initial find + 1 EPERM poll }); @@ -805,7 +805,7 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { createErrnoResult("EACCES", "lsof permission denied"), ); vi.spyOn(process, "kill").mockReturnValue(true); - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Should have bailed after exactly 1 poll call (the EACCES one) expect(getCallCount()).toBe(2); // 1 initial find + 1 EACCES poll }); @@ -826,8 +826,8 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { }); vi.spyOn(process, "kill").mockReturnValue(true); - // Must return without throwing (proceeds with warning after budget expires) - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + // Proceeds with warning after budget expires. + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); }); it("still polls for port-free when all stale pids were already dead at SIGTERM time", () => { @@ -1199,8 +1199,8 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { }); }); vi.spyOn(process, "kill").mockReturnValue(true); - // Should complete cleanly — no openclaw pids in status-1 output → free - expect(() => cleanStaleGatewayProcessesSync()).not.toThrow(); + // No openclaw pids in status-1 output means the port is free for this cleanup. + expect(cleanStaleGatewayProcessesSync()).toContain(stalePid); // Completed with one argv verification after the status-1 poll output: // initial lsof + poll lsof + ps argv check. expect(getCallCount()).toBe(3); diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index ff9dbc95ff0..53c28b3f624 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -1656,8 +1656,8 @@ describe("run-node script", () => { fakeProcess.emit("SIGINT"); expect(fsSync.existsSync(lockDir)).toBe(false); - // Normal release after signal must be a no-op, not throw. - expect(() => release()).not.toThrow(); + // Normal release after signal must be a no-op. + expect(release()).toBeUndefined(); expect(fakeProcess.listenerCount("SIGINT")).toBe(0); expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); expect(fakeProcess.listenerCount("exit")).toBe(0); @@ -1674,7 +1674,7 @@ describe("run-node script", () => { fakeProcess.emit("SIGTERM"); expect(fsSync.existsSync(lockDir)).toBe(false); - expect(() => release()).not.toThrow(); + expect(release()).toBeUndefined(); }); }); @@ -1688,7 +1688,7 @@ describe("run-node script", () => { fakeProcess.emit("exit"); expect(fsSync.existsSync(lockDir)).toBe(false); - expect(() => release()).not.toThrow(); + expect(release()).toBeUndefined(); }); }); diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index 99d4e938e81..c2c03afcd69 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -102,7 +102,7 @@ describe("runtime-guard", () => { version: "22.16.0", execPath: "/usr/bin/node", }; - expect(() => assertSupportedRuntime(runtime, details)).not.toThrow(); + expect(assertSupportedRuntime(runtime, details)).toBeUndefined(); expect(runtime.exit).not.toHaveBeenCalled(); }); diff --git a/src/infra/skills-remote.test.ts b/src/infra/skills-remote.test.ts index af26a7ed6d7..25dc56b63f6 100644 --- a/src/infra/skills-remote.test.ts +++ b/src/infra/skills-remote.test.ts @@ -40,10 +40,8 @@ describe("skills-remote", () => { it("supports idempotent remote node removal", () => { const nodeId = `node-${randomUUID()}`; - expect(() => { - removeRemoteNodeInfo(nodeId); - removeRemoteNodeInfo(nodeId); - }).not.toThrow(); + expect(removeRemoteNodeInfo(nodeId)).toBeUndefined(); + expect(removeRemoteNodeInfo(nodeId)).toBeUndefined(); }); it("bumps the skills snapshot version when an eligible remote node disconnects", async () => { @@ -227,7 +225,7 @@ describe("skills-remote", () => { const nodeId = `node-${randomUUID()}`; const bin = `bin-${randomUUID()}`; let invokeCount = 0; - let releaseProbe!: () => void; + let releaseProbe: (() => void) | undefined; const probeStarted = new Promise((resolve) => { setSkillsRemoteRegistry({ listConnected: () => [], @@ -288,6 +286,9 @@ describe("skills-remote", () => { cfg, timeoutMs: 10, }); + if (!releaseProbe) { + throw new Error("Expected remote skill probe release callback to be initialized"); + } releaseProbe(); await Promise.all([first, second]); diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index de4a3cdf15f..80c83af330f 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -46,6 +46,14 @@ vi.mock("node:child_process", async () => { const spawnMock = vi.mocked(spawn); +function requireSpawnArgs(index: number): string[] { + const args = spawnMock.mock.calls[index]?.[1] as string[] | undefined; + if (!args) { + throw new Error("expected ssh spawn args"); + } + return args; +} + let parseSshConfigOutput: typeof import("./ssh-config.js").parseSshConfigOutput; let resolveSshConfig: typeof import("./ssh-config.js").resolveSshConfig; @@ -81,8 +89,7 @@ describe("ssh-config", () => { expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net"); expect(config?.port).toBe(2222); expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]); - const args = spawnMock.mock.calls[0]?.[1] as string[] | undefined; - expect(args?.slice(-2)).toEqual(["--", "me@alias"]); + expect(requireSpawnArgs(0).slice(-2)).toEqual(["--", "me@alias"]); }); it("adds non-default port and trimmed identity arguments", async () => { diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index f00df6d50d8..9be68d72ef0 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -149,7 +149,9 @@ describe("migrateOrphanedSessionKeys", () => { expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session"); expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session"); // The key must NOT have been merged into ops namespace - expect(Object.keys(store).filter((k) => k.startsWith("agent:ops:")).length).toBe(1); + expect( + Object.keys(store).reduce((count, k) => count + (k.startsWith("agent:ops:") ? 1 : 0), 0), + ).toBe(1); }); }); diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 09839a6e881..b7480af1410 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -10,6 +10,7 @@ const { enableTailscaleServe, disableTailscaleServe, ensureFunnel, + tailscaleFunnelStatusCoversPort, } = tailscale; const tailscaleBin = expect.stringMatching(/tailscale$/i); @@ -236,3 +237,92 @@ describe("tailscale helpers", () => { expect(exec).toHaveBeenCalledTimes(2); }); }); + +describe("tailscaleFunnelStatusCoversPort", () => { + function buildFunnelStatus(handlers: Record) { + const host = "device.tailnet.ts.net:443"; + return { + AllowFunnel: { [host]: true }, + Web: { + [host]: { Handlers: handlers }, + }, + } as Record; + } + + it("matches a Funnel route whose Proxy is a full http URL", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches a Proxy URL with a trailing slash", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:18789/" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches a Proxy URL with a longer path", () => { + const status = buildFunnelStatus({ "/api": { Proxy: "http://127.0.0.1:18789/api" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches the localhost loopback alias", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://localhost:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches an IPv6 loopback Proxy", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://[::1]:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches the documented https+insecure target scheme", () => { + const status = buildFunnelStatus({ + "/": { Proxy: "https+insecure://localhost:18789" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("matches https+insecure with a trailing path", () => { + const status = buildFunnelStatus({ + "/api": { Proxy: "https+insecure://127.0.0.1:18789/api" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("does not match https+insecure on a non-loopback host", () => { + const status = buildFunnelStatus({ + "/": { Proxy: "https+insecure://10.0.0.5:18789" }, + }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("matches a bare port form", () => { + const status = buildFunnelStatus({ "/": { Proxy: "18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(true); + }); + + it("does not match a Proxy on a different port", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://127.0.0.1:9000" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("does not match a non-loopback host on the right port", () => { + const status = buildFunnelStatus({ "/": { Proxy: "http://10.0.0.5:18789" } }); + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("ignores Web entries whose host is not in AllowFunnel", () => { + const status = { + AllowFunnel: { "device.tailnet.ts.net:443": false }, + Web: { + "device.tailnet.ts.net:443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, + }, + }, + } as Record; + expect(tailscaleFunnelStatusCoversPort(status, 18789)).toBe(false); + }); + + it("returns false on an empty status payload", () => { + expect(tailscaleFunnelStatusCoversPort({}, 18789)).toBe(false); + }); +}); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 8857ece2f88..60c894c1dd6 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -402,6 +402,97 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec = }); } +export async function hasTailscaleFunnelRouteForPort( + port: number, + exec: typeof runExec = runExec, +): Promise { + try { + const tailscaleBin = await getTailscaleBinary(); + const { stdout } = await exec(tailscaleBin, ["funnel", "status", "--json"], { + maxBuffer: 200_000, + timeoutMs: 5_000, + }); + const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; + return tailscaleFunnelStatusCoversPort(parsed, port); + } catch { + return false; + } +} + +const TAILSCALE_LOOPBACK_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +export function tailscaleFunnelStatusCoversPort( + status: Record, + port: number, +): boolean { + for (const proxy of funnelStatusBackendsForPort(status)) { + if (tailscaleProxyMatchesLoopbackPort(proxy, port)) { + return true; + } + } + return false; +} + +function tailscaleProxyMatchesLoopbackPort(proxy: string, port: number): boolean { + // Tailscale stores the Proxy field as a full URL string (e.g. + // "http://127.0.0.1:18789", "http://127.0.0.1:18789/", + // "https+insecure://localhost:18789/api"), or as the bare forms accepted + // by `tailscale funnel/serve` ("localhost:18789", "18789"). Strip any + // RFC 3986 scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) "://") and + // any trailing path before host/port match — covers documented Tailscale + // target schemes such as `http`, `https`, and `https+insecure`. + const stripped = proxy.replace(/^[a-z][a-z0-9+\-.]*:\/\//i, "").replace(/\/.*$/, ""); + if (stripped === String(port)) { + return true; + } + const sep = stripped.lastIndexOf(":"); + if (sep < 0) { + return false; + } + const host = stripped.slice(0, sep); + const portStr = stripped.slice(sep + 1); + if (portStr !== String(port)) { + return false; + } + return TAILSCALE_LOOPBACK_PROXY_HOSTS.has(host); +} + +function funnelStatusBackendsForPort(status: Record): Set { + const backends = new Set(); + const allowFunnel = (status as { AllowFunnel?: Record }).AllowFunnel ?? {}; + const enabledHosts = new Set( + Object.entries(allowFunnel) + .filter(([, value]) => value === true) + .map(([host]) => host), + ); + if (enabledHosts.size === 0) { + return backends; + } + const web = (status as { Web?: Record }).Web; + if (!web || typeof web !== "object") { + return backends; + } + for (const [host, handlers] of Object.entries(web)) { + if (!enabledHosts.has(host)) { + continue; + } + if (!handlers || typeof handlers !== "object") { + continue; + } + const handlerEntries = (handlers as { Handlers?: Record }).Handlers; + if (!handlerEntries || typeof handlerEntries !== "object") { + continue; + } + for (const handler of Object.values(handlerEntries)) { + const proxy = (handler as { Proxy?: unknown })?.Proxy; + if (typeof proxy === "string" && proxy.length > 0) { + backends.add(proxy); + } + } + } + return backends; +} + export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], { diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 1f2a7514b9a..0f3811828ab 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -323,7 +323,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("skipped"); expect(result.reason).toBe("dirty"); - expect(calls.some((call) => call.includes("rebase"))).toBe(false); + expect(calls).not.toEqual(expect.arrayContaining([expect.stringContaining("rebase")])); }); it.each([ @@ -366,7 +366,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("rebase-failed"); - expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true); + expect(calls).toEqual(expect.arrayContaining([expect.stringContaining("rebase --abort")])); }); it("returns error and stops early when deps install fails", async () => { @@ -381,8 +381,8 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("deps-install-failed"); - expect(calls.some((call) => call === "pnpm build")).toBe(false); - expect(calls.some((call) => call === "pnpm ui:build")).toBe(false); + expect(calls).not.toContain("pnpm build"); + expect(calls).not.toContain("pnpm ui:build"); }); it("returns error and stops early when build fails", async () => { @@ -398,8 +398,8 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("build-failed"); - expect(calls.some((call) => call === "pnpm install")).toBe(true); - expect(calls.some((call) => call === "pnpm ui:build")).toBe(false); + expect(calls).toContain("pnpm install"); + expect(calls).not.toContain("pnpm ui:build"); }); it("uses stable tag when beta tag is older than release", async () => { @@ -459,7 +459,8 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("ok"); expect(calls).toContain("pnpm --version"); - expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); + const npmPrefixInstallCalls = calls.filter((call) => call.startsWith("npm install --prefix ")); + expect(npmPrefixInstallCalls.length).toBeGreaterThan(0); expect(calls).toContain("npm --version"); expect(calls).toContain("pnpm install"); expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps"); @@ -611,12 +612,16 @@ describe("runGatewayUpdate", () => { const result = await runWithCommand(runCommand, { channel: "dev" }); expect(result.status).toBe("ok"); - expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); + expect(calls).toEqual( + expect.arrayContaining([expect.stringMatching(/^npm install --prefix /)]), + ); expect(calls).toContain("pnpm install"); expect(calls).toContain("pnpm build"); expect(calls).not.toContain("pnpm lint"); expect(calls).toContain("pnpm ui:build"); - expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true); + expect(pnpmEnvPaths).toEqual( + expect.arrayContaining([expect.stringContaining("openclaw-update-pnpm-")]), + ); }); it("runs dev preflight lint in constrained mode when explicitly enabled", async () => { @@ -1423,9 +1428,10 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("pnpm-npm-bootstrap-failed"); - expect(calls.some((call) => call === "npm run build")).toBe(false); - expect(calls.some((call) => call === "npm run lint")).toBe(false); - expect(calls.some((call) => preflightPrefixPattern.test(call))).toBe(false); + expect(calls).not.toContain("npm run build"); + expect(calls).not.toContain("npm run lint"); + const preflightCalls = calls.filter((call) => preflightPrefixPattern.test(call)); + expect(preflightCalls).toEqual([]); }); it("skips update when no git root", async () => { @@ -1445,8 +1451,10 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("skipped"); expect(result.reason).toBe("not-git-install"); - expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); - expect(calls.some((call) => call.startsWith("npm i -g"))).toBe(false); + const pnpmGlobalInstallCalls = calls.filter((call) => call.startsWith("pnpm add -g")); + const npmGlobalInstallCalls = calls.filter((call) => call.startsWith("npm i -g")); + expect(pnpmGlobalInstallCalls).toEqual([]); + expect(npmGlobalInstallCalls).toEqual([]); }); async function runNpmGlobalUpdateCase(params: { @@ -1569,7 +1577,7 @@ describe("runGatewayUpdate", () => { expect(result.mode).toBe("npm"); expect(result.before?.version).toBe("1.0.0"); expect(result.after?.version).toBe("2.0.0"); - expect(calls.some((call) => call === expectedInstallCommand)).toBe(true); + expect(calls).toContain(expectedInstallCommand); }); it("updates global npm installs from the GitHub main package spec", async () => { @@ -1895,8 +1903,12 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("ok"); expect(result.mode).toBe("pnpm"); expect(result.after?.version).toBe("2.0.0"); - expect(calls.some((call) => call.startsWith("npm i -g --prefix "))).toBe(true); - expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); + const npmPrefixedGlobalInstallCalls = calls.filter((call) => + call.startsWith("npm i -g --prefix "), + ); + const pnpmAddGlobalCalls = calls.filter((call) => call.startsWith("pnpm add -g")); + expect(npmPrefixedGlobalInstallCalls.length).toBeGreaterThan(0); + expect(pnpmAddGlobalCalls).toEqual([]); expect(result.steps.map((step) => step.name)).toEqual(["global update", "global install swap"]); await expect(fs.access(staleInstallChunk)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -1944,7 +1956,7 @@ describe("runGatewayUpdate", () => { expect(result.mode).toBe("bun"); expect(result.before?.version).toBe("1.0.0"); expect(result.after?.version).toBe("2.0.0"); - expect(calls.some((call) => call === "bun add -g openclaw@latest")).toBe(true); + expect(calls).toContain("bun add -g openclaw@latest"); }); }); @@ -1961,7 +1973,9 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("not-openclaw-root"); - expect(calls.some((call) => call.includes("status --porcelain"))).toBe(false); + expect(calls).not.toEqual( + expect.arrayContaining([expect.stringContaining("status --porcelain")]), + ); }); it("fails with a clear reason when openclaw.mjs is missing", async () => { diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index 6bb8f098bbe..a97c8d00e89 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -177,7 +177,7 @@ describe("enableConsoleCapture", () => { enableConsoleCapture(); const epipe = new Error("write EPIPE") as NodeJS.ErrnoException; epipe.code = "EPIPE"; - expect(() => stream.emit("error", epipe)).not.toThrow(); + expect(stream.emit("error", epipe)).toBe(true); }); it("rethrows non-EPIPE errors on stdout", () => { diff --git a/src/logging/diagnostic-memory.test.ts b/src/logging/diagnostic-memory.test.ts index 2a1b224b500..bc0af7485f3 100644 --- a/src/logging/diagnostic-memory.test.ts +++ b/src/logging/diagnostic-memory.test.ts @@ -149,6 +149,11 @@ describe("diagnostic memory", () => { } stop(); - expect(events.filter((event) => event.type === "diagnostic.memory.pressure")).toHaveLength(1); + expect( + events.reduce( + (count, event) => count + (event.type === "diagnostic.memory.pressure" ? 1 : 0), + 0, + ), + ).toBe(1); }); }); diff --git a/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts b/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts index bd719460a46..c3d5a241681 100644 --- a/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts +++ b/src/logging/diagnostic-stuck-session-recovery.runtime.test.ts @@ -324,7 +324,7 @@ describe("stuck session recovery", () => { }); it("coalesces duplicate recovery attempts for the same session", async () => { - let resolveWait!: (value: boolean) => void; + let resolveWait: ((value: boolean) => void) | undefined; const waitPromise = new Promise((resolve) => { resolveWait = resolve; }); @@ -346,6 +346,9 @@ describe("stuck session recovery", () => { }); expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledTimes(1); + if (!resolveWait) { + throw new Error("Expected diagnostic recovery wait resolver to be initialized"); + } resolveWait(true); await first; }); diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 79b371316d4..a3f29555935 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -52,6 +52,16 @@ function flushDiagnosticEvents() { return new Promise((resolve) => setImmediate(resolve)); } +function countMatching(items: readonly T[], predicate: (item: T) => boolean) { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + describe("diagnostic session state pruning", () => { beforeEach(() => { vi.useFakeTimers(); @@ -261,7 +271,7 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(0); + expect(events.some((event) => event.type === "session.long_running")).toBe(false); const stuckEvents = events.filter((event) => event.type === "session.stuck"); expect(stuckEvents).toHaveLength(1); expect(stuckEvents[0]).toMatchObject({ @@ -298,9 +308,9 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.stalled")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); + expect(events.some((event) => event.type === "session.stalled")).toBe(false); + expect(events.some((event) => event.type === "session.long_running")).toBe(false); }); it("backs off repeated stuck warnings while a session remains unchanged", () => { @@ -359,7 +369,7 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); const stalledEvents = events.filter((event) => event.type === "session.stalled"); expect(stalledEvents).toHaveLength(1); expect(stalledEvents[0]).toMatchObject({ @@ -657,8 +667,8 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.stalled")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); + expect(events.some((event) => event.type === "session.stalled")).toBe(false); const longRunningEvents = events.filter((event) => event.type === "session.long_running"); expect(longRunningEvents).toHaveLength(1); expect(longRunningEvents[0]).toMatchObject({ @@ -691,7 +701,7 @@ describe("stuck session diagnostics threshold", () => { markDiagnosticEmbeddedRunStarted({ sessionId: "s1", sessionKey: "main" }); vi.advanceTimersByTime(16_000); - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "session.long_running")).toBe(1); vi.advanceTimersByTime(28_000); emitDiagnosticEvent({ @@ -702,7 +712,7 @@ describe("stuck session diagnostics threshold", () => { }); vi.advanceTimersByTime(2_000); - expect(events.filter((event) => event.type === "session.long_running")).toHaveLength(1); + expect(countMatching(events, (event) => event.type === "session.long_running")).toBe(1); } finally { unsubscribe(); } @@ -737,8 +747,8 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); - expect(events.filter((event) => event.type === "session.stalled")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); + expect(events.some((event) => event.type === "session.stalled")).toBe(false); const longRunningEvents = events.filter((event) => event.type === "session.long_running"); expect(longRunningEvents).toHaveLength(1); expect(longRunningEvents[0]).toMatchObject({ @@ -903,7 +913,7 @@ describe("stuck session diagnostics threshold", () => { const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); const events: DiagnosticEventPayload[] = []; const unsubscribe = onDiagnosticEvent((event) => events.push(event)); - let finishPhase!: () => void; + let finishPhase: (() => void) | undefined; const phase = withDiagnosticPhase( "startup.plugins.load", () => @@ -911,6 +921,10 @@ describe("stuck session diagnostics threshold", () => { finishPhase = resolve; }), ); + if (!finishPhase) { + throw new Error("Expected diagnostic phase finish callback to be initialized"); + } + const completePhase = finishPhase; try { startDiagnosticHeartbeat( @@ -933,7 +947,7 @@ describe("stuck session diagnostics threshold", () => { logMessageQueued({ sessionId: "s1", sessionKey: "main", source: "telegram" }); vi.advanceTimersByTime(30_000); } finally { - finishPhase(); + completePhase(); await phase; unsubscribe(); } @@ -1034,14 +1048,14 @@ describe("stuck session diagnostics threshold", () => { vi.advanceTimersByTime(30_000); vi.advanceTimersByTime(90_000); - expect(events.filter((event) => event === "diagnostic.liveness.warning")).toHaveLength(1); + expect(countMatching(events, (event) => event === "diagnostic.liveness.warning")).toBe(1); vi.advanceTimersByTime(30_000); } finally { unsubscribe(); } - expect(events.filter((event) => event === "diagnostic.liveness.warning")).toHaveLength(2); + expect(countMatching(events, (event) => event === "diagnostic.liveness.warning")).toBe(2); }); it("does not start the heartbeat when diagnostics are disabled by config", () => { @@ -1073,7 +1087,7 @@ describe("stuck session diagnostics threshold", () => { unsubscribe(); } - expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); + expect(events.some((event) => event.type === "session.stuck")).toBe(false); }); it("uses default threshold for invalid values", () => { diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts index 274bb3a63ea..8acaa74d7d3 100644 --- a/src/logging/logger-redaction-behavior.test.ts +++ b/src/logging/logger-redaction-behavior.test.ts @@ -149,7 +149,7 @@ describe("file log redaction", () => { const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n"); const record = JSON.parse(line ?? "{}") as Record; - expect(record.hostname).toEqual(expect.any(String)); + expect(record.hostname).toBeTypeOf("string"); expect(record.hostname).not.toBe(""); expect(record.message).toBe("request completed"); }); diff --git a/src/logging/parse-log-line.test.ts b/src/logging/parse-log-line.test.ts index 46465e11f4a..4db1b4c31b7 100644 --- a/src/logging/parse-log-line.test.ts +++ b/src/logging/parse-log-line.test.ts @@ -15,12 +15,13 @@ describe("parseLogLine", () => { const parsed = parseLogLine(line); - expect(parsed).not.toBeNull(); - expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z"); - expect(parsed?.level).toBe("info"); - expect(parsed?.subsystem).toBe("gateway/channels/demo-channel"); - expect(parsed?.message).toBe('{"subsystem":"gateway/channels/demo-channel"} connected'); - expect(parsed?.raw).toBe(line); + expect(parsed).toMatchObject({ + time: "2026-01-09T01:38:41.523Z", + level: "info", + subsystem: "gateway/channels/demo-channel", + message: '{"subsystem":"gateway/channels/demo-channel"} connected', + raw: line, + }); }); it("falls back to meta timestamp when top-level time is missing", () => { diff --git a/src/markdown/render-aware-chunking.test.ts b/src/markdown/render-aware-chunking.test.ts index 5481f839554..92b66ca89bc 100644 --- a/src/markdown/render-aware-chunking.test.ts +++ b/src/markdown/render-aware-chunking.test.ts @@ -31,7 +31,7 @@ describe("renderMarkdownIRChunksWithinLimit", () => { expect(chunks.map((chunk) => chunk.source.text)).toEqual(["alpha ", "<<"]); expect(chunks.map((chunk) => chunk.source.text).join("")).toBe("alpha <<"); - expect(chunks.filter((chunk) => chunk.rendered.length > 8)).toEqual([]); + expect(chunks.every((chunk) => chunk.rendered.length <= 8)).toBe(true); }); it("preserves formatting when a rendered chunk is re-split", () => { @@ -46,8 +46,8 @@ describe("renderMarkdownIRChunksWithinLimit", () => { }); expect(chunks.map((chunk) => chunk.source.text)).toEqual(["Which of ", "these"]); - expect(chunks.filter((chunk) => !chunk.rendered.startsWith(""))).toEqual([]); - expect(chunks.filter((chunk) => !chunk.rendered.endsWith(""))).toEqual([]); + expect(chunks.every((chunk) => chunk.rendered.startsWith(""))).toBe(true); + expect(chunks.every((chunk) => chunk.rendered.endsWith(""))).toBe(true); }); it("checks exact candidates instead of assuming rendered length is monotonic", () => { diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 94602bd0437..bf1305b3831 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -104,9 +104,20 @@ function createAudioConfigWithEcho(opts?: { return { cfg, providers }; } +function disableImageUnderstanding(cfg: OpenClawConfig): void { + if (!cfg.tools?.media) { + throw new Error("Expected media tool config"); + } + cfg.tools.media.image = { enabled: false }; +} + function expectSingleEchoDeliveryCall() { expect(mockDeliverOutboundPayloads).toHaveBeenCalledOnce(); - const callArgs = mockDeliverOutboundPayloads.mock.calls[0]?.[0]; + const firstCall = mockDeliverOutboundPayloads.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected echo transcript delivery call"); + } + const callArgs = firstCall[0]; if (!callArgs) { throw new Error("Expected one echo transcript delivery call"); } @@ -292,7 +303,7 @@ describe("applyMediaUnderstanding – echo transcript", () => { echoTranscript: true, transcribedText: "should not appear", }); - cfg.tools!.media!.image = { enabled: false }; + disableImageUnderstanding(cfg); await applyMediaUnderstanding({ ctx, cfg, providers }); diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 43a9a2b593a..16ce4471b7d 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -382,8 +382,16 @@ describe("describeImageWithModel", () => { }), expect.any(Object), ); - const [, context] = completeMock.mock.calls[0] ?? []; - expect(context?.messages?.[0]?.content).toHaveLength(1); + const firstCall = completeMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected image completion call"); + } + const [, context] = firstCall; + const userMessage = context.messages[0]; + if (!userMessage) { + throw new Error("expected image completion user message"); + } + expect(userMessage.content).toHaveLength(1); }); it("places OpenRouter image prompts in user content before images", async () => { @@ -422,9 +430,17 @@ describe("describeImageWithModel", () => { text: "openrouter ok", model: "google/gemini-2.5-flash", }); - const [, context] = completeMock.mock.calls[0] ?? []; - expect(context?.systemPrompt).toBeUndefined(); - expect(context?.messages?.[0]?.content).toEqual([ + const firstCall = completeMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected OpenRouter image completion call"); + } + const [, context] = firstCall; + expect(context.systemPrompt).toBeUndefined(); + const userMessage = context.messages[0]; + if (!userMessage) { + throw new Error("expected OpenRouter image completion user message"); + } + expect(userMessage.content).toEqual([ { type: "text", text: "Describe the image." }, expect.objectContaining({ type: "image", @@ -536,7 +552,11 @@ describe("describeImageWithModel", () => { model: model.id, }); expect(completeMock).toHaveBeenCalledTimes(2); - const [, , retryOptions] = completeMock.mock.calls[1] ?? []; + const retryCall = completeMock.mock.calls[1]; + if (!retryCall) { + throw new Error("Expected retry image completion call"); + } + const [retryModel, , retryOptions] = retryCall; if (!retryOptions?.onPayload) { throw new Error("expected retry payload mapper"); } @@ -546,7 +566,7 @@ describe("describeImageWithModel", () => { reasoning_effort: "high", include: ["reasoning.encrypted_content"], }, - completeMock.mock.calls[1]?.[0], + retryModel, ); expect(retryPayload).toEqual(expectedRetryPayload); }, @@ -580,9 +600,16 @@ describe("describeImageWithModel", () => { const assertion = expect(result).rejects.toThrow("image description timed out after 25ms"); await vi.advanceTimersByTimeAsync(25); await assertion; - const [, , options] = completeMock.mock.calls[0] ?? []; - expect(options?.signal?.aborted).toBe(true); - expect(options?.timeoutMs).toBe(25); + const firstCall = completeMock.mock.calls[0]; + if (!firstCall) { + throw new Error("Expected timed image completion call"); + } + const [, , options] = firstCall; + if (!options?.signal) { + throw new Error("Expected image completion abort signal"); + } + expect(options.signal.aborted).toBe(true); + expect(options.timeoutMs).toBe(25); }); it("rejects when image runtime setup exceeds the request timeout", async () => { diff --git a/src/media-understanding/provider-registry.test.ts b/src/media-understanding/provider-registry.test.ts index 4a920d3660f..65961832d65 100644 --- a/src/media-understanding/provider-registry.test.ts +++ b/src/media-understanding/provider-registry.test.ts @@ -18,6 +18,17 @@ function createMediaProvider( return params; } +function requireMediaProvider( + registry: Map, + providerId: string, +): MediaUnderstandingProvider { + const provider = getMediaUnderstandingProvider(providerId, registry); + if (!provider) { + throw new Error(`expected media-understanding provider ${providerId}`); + } + return provider; +} + describe("media-understanding provider registry", () => { beforeEach(() => { resolvePluginCapabilityProvidersMock.mockReset(); @@ -32,8 +43,8 @@ describe("media-understanding provider registry", () => { const registry = buildMediaUnderstandingRegistry(); - expect(getMediaUnderstandingProvider("groq", registry)?.id).toBe("groq"); - expect(getMediaUnderstandingProvider("deepgram", registry)?.id).toBe("deepgram"); + expect(requireMediaProvider(registry, "groq").id).toBe("groq"); + expect(requireMediaProvider(registry, "deepgram").id).toBe("deepgram"); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "mediaUnderstandingProviders", cfg: undefined, @@ -47,7 +58,7 @@ describe("media-understanding provider registry", () => { const registry = buildMediaUnderstandingRegistry(); - expect(getMediaUnderstandingProvider("gemini", registry)?.id).toBe("google"); + expect(requireMediaProvider(registry, "gemini").id).toBe("google"); }); it("auto-registers media-understanding for config providers with image-capable models (#51392)", () => { @@ -96,11 +107,19 @@ describe("media-understanding provider registry", () => { } as never; const registry = buildMediaUnderstandingRegistry(undefined, cfg); - const provider = getMediaUnderstandingProvider("google", registry); + const provider = requireMediaProvider(registry, "google"); - expect(provider?.capabilities).toEqual(["image", "audio", "video"]); - expect(await provider?.describeImage?.({} as never)).toEqual({ text: "plugin image" }); - expect(await provider?.transcribeAudio?.({} as never)).toEqual({ text: "plugin audio" }); + expect(provider.capabilities).toEqual(["image", "audio", "video"]); + expect(provider.describeImage).toBeTypeOf("function"); + if (!provider.describeImage) { + throw new Error("expected google describeImage provider hook"); + } + expect(provider.transcribeAudio).toBeTypeOf("function"); + if (!provider.transcribeAudio) { + throw new Error("expected google transcribeAudio provider hook"); + } + expect(await provider.describeImage({} as never)).toEqual({ text: "plugin image" }); + expect(await provider.transcribeAudio({} as never)).toEqual({ text: "plugin audio" }); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "mediaUnderstandingProviders", cfg, diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 4e26677609b..961b851acb0 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -81,6 +81,16 @@ async function runAutoAudioCase(params: { return runResult; } +type CapabilityResult = Awaited>; + +function requireCapabilityOutput(result: CapabilityResult, index: number) { + const output = result.outputs[index]; + if (!output) { + throw new Error(`expected media-understanding output at index ${index}`); + } + return output; +} + describe("runCapability auto audio entries", () => { it("uses provider keys to auto-enable audio transcription", async () => { let seenModel: string | undefined; @@ -90,7 +100,7 @@ describe("runCapability auto audio entries", () => { return { text: "ok", model: req.model ?? "unknown" }; }, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(requireCapabilityOutput(result, 0).text).toBe("ok"); expect(seenModel).toBe("gpt-4o-transcribe"); expect(result.decision.outcome).toBe("success"); }); @@ -133,7 +143,10 @@ describe("runCapability auto audio entries", () => { }); }); - expect(runResult?.outputs[0]).toMatchObject({ + if (!runResult) { + throw new Error("expected Codex audio result"); + } + expect(requireCapabilityOutput(runResult, 0)).toMatchObject({ provider: "openai-codex", model: "gpt-4o-transcribe", text: "codex audio", @@ -163,8 +176,9 @@ describe("runCapability auto audio entries", () => { }), ); - expect(result.outputs[0]?.provider).toBe("openai"); - expect(result.outputs[0]?.text).toBe("provider transcription"); + const output = requireCapabilityOutput(result, 0); + expect(output.provider).toBe("openai"); + expect(output.text).toBe("provider transcription"); expect(seenModel).toBe("gpt-4o-transcribe"); } finally { clearMediaUnderstandingBinaryCacheForTests(); @@ -210,7 +224,7 @@ describe("runCapability auto audio entries", () => { }, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(requireCapabilityOutput(result, 0).text).toBe("ok"); expect(seenModel).toBe("whisper-1"); }); @@ -246,7 +260,7 @@ describe("runCapability auto audio entries", () => { } as Partial, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(requireCapabilityOutput(result, 0).text).toBe("ok"); expect(seenLanguage).toBe("en"); expect(seenPrompt).toBe("Focus on names"); }); @@ -322,8 +336,9 @@ describe("runCapability auto audio entries", () => { throw new Error("Expected auto audio mistral result"); } expect(runResult.decision.outcome).toBe("success"); - expect(runResult.outputs[0]?.provider).toBe("mistral"); - expect(runResult.outputs[0]?.model).toBe("voxtral-mini-latest"); - expect(runResult.outputs[0]?.text).toBe("mistral"); + const output = requireCapabilityOutput(runResult, 0); + expect(output.provider).toBe("mistral"); + expect(output.model).toBe("voxtral-mini-latest"); + expect(output.text).toBe("mistral"); }); }); diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 8e4c161e1e1..3a1592b3658 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -116,7 +116,12 @@ describe("runCapability deepgram provider options", () => { media, providerRegistry, }); - expect(result.outputs[0]?.text).toBe("ok"); + expect(result.outputs).toHaveLength(1); + const [output] = result.outputs; + if (!output) { + throw new Error("Expected Deepgram media output"); + } + expect(output.text).toBe("ok"); expect(seenBaseUrl).toBe("https://entry.example"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", diff --git a/src/media-understanding/runner.proxy.test.ts b/src/media-understanding/runner.proxy.test.ts index a924eb2775f..ba9695b12e5 100644 --- a/src/media-understanding/runner.proxy.test.ts +++ b/src/media-understanding/runner.proxy.test.ts @@ -57,6 +57,18 @@ function createOpenAiAudioCfg(providerOverrides: Record = {}): } as unknown as OpenClawConfig; } +function expectSingleOutputText( + result: Awaited>, + expectedText: string, +): void { + expect(result.outputs).toHaveLength(1); + const [output] = result.outputs; + if (!output) { + throw new Error("Expected media understanding output"); + } + expect(output.text).toBe(expectedText); +} + async function runAudioCapabilityWithFetchCapture(params: { fixturePrefix: string; outputText: string; @@ -83,7 +95,7 @@ async function runAudioCapabilityWithFetchCapture(params: { providerRegistry, }); - expect(result.outputs[0]?.text).toBe(params.outputText); + expectSingleOutputText(result, params.outputText); }); return seenFetchFn; } @@ -154,7 +166,7 @@ describe("runCapability proxy fetch passthrough", () => { ]), }); - expect(result.outputs[0]?.text).toBe("video ok"); + expectSingleOutputText(result, "video ok"); expect(seenFetchFn).toBe(proxyFetchMocks.proxyFetch); }); }); @@ -200,9 +212,12 @@ describe("runCapability proxy fetch passthrough", () => { providerRegistry, }); - expect(result.outputs[0]?.text).toBe("ok"); + expectSingleOutputText(result, "ok"); }); - expect(seenRequest?.allowPrivateNetwork).toBe(true); + if (!seenRequest) { + throw new Error("Expected audio provider request options"); + } + expect(seenRequest.allowPrivateNetwork).toBe(true); }); }); diff --git a/src/media-understanding/runner.skip-tiny-audio.test.ts b/src/media-understanding/runner.skip-tiny-audio.test.ts index fc0386c7a83..843995eaa37 100644 --- a/src/media-understanding/runner.skip-tiny-audio.test.ts +++ b/src/media-understanding/runner.skip-tiny-audio.test.ts @@ -179,9 +179,17 @@ describe("runCapability skips tiny audio files", () => { expect(result.outputs).toHaveLength(0); expect(result.decision.outcome).toBe("failed"); expect(result.decision.attachments).toHaveLength(1); - expect(result.decision.attachments[0]?.attempts).toHaveLength(1); - expect(result.decision.attachments[0]?.attempts[0]?.outcome).toBe("failed"); - expect(result.decision.attachments[0]?.attempts[0]?.reason).toContain("upstream 500"); + const attachment = result.decision.attachments[0]; + if (!attachment) { + throw new Error("expected failed audio decision attachment"); + } + expect(attachment.attempts).toHaveLength(1); + const attempt = attachment.attempts[0]; + if (!attempt) { + throw new Error("expected failed audio decision attempt"); + } + expect(attempt.outcome).toBe("failed"); + expect(attempt.reason).toContain("upstream 500"); }, }); }); diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 58647ba9f02..b451ded0702 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -22,6 +22,16 @@ vi.mock("../plugins/capability-provider-runtime.js", async () => { return createEmptyCapabilityProviderMockModule(); }); +type CapabilityResult = Awaited>; + +function requireCapabilityOutput(result: CapabilityResult, index: number) { + const output = result.outputs[index]; + if (!output) { + throw new Error(`expected media-understanding output at index ${index}`); + } + return output; +} + describe("runCapability video provider wiring", () => { it("merges video baseUrl and headers with entry precedence", async () => { let seenBaseUrl: string | undefined; @@ -83,8 +93,9 @@ describe("runCapability video provider wiring", () => { ]), }); - expect(result.outputs[0]?.text).toBe("video ok"); - expect(result.outputs[0]?.provider).toBe("moonshot"); + const output = requireCapabilityOutput(result, 0); + expect(output.text).toBe("video ok"); + expect(output.provider).toBe("moonshot"); expect(seenBaseUrl).toBe("https://entry.example/v1"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", @@ -154,8 +165,9 @@ describe("runCapability video provider wiring", () => { }); expect(result.decision.outcome).toBe("success"); - expect(result.outputs[0]?.provider).toBe("moonshot"); - expect(result.outputs[0]?.text).toBe("moonshot"); + const output = requireCapabilityOutput(result, 0); + expect(output.provider).toBe("moonshot"); + expect(output.text).toBe("moonshot"); }); }, ); diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index 7cf19e91caa..acb2d7a0981 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -97,6 +97,24 @@ function setCompatibleActiveMediaUnderstandingRegistry( setActivePluginRegistry(pluginRegistry, cacheKey); } +type CapabilityResult = Awaited>; + +function requireDecisionAttachment(result: CapabilityResult, index: number) { + const attachment = result.decision.attachments[index]; + if (!attachment) { + throw new Error(`expected media-understanding decision attachment ${index}`); + } + return attachment; +} + +function requireCapabilityOutput(result: CapabilityResult, index: number) { + const output = result.outputs[index]; + if (!output) { + throw new Error(`expected media-understanding output ${index}`); + } + return output; +} + describe("runCapability image skip", () => { beforeAll(async () => { vi.doMock("../agents/model-catalog.js", async () => { @@ -138,11 +156,14 @@ describe("runCapability image skip", () => { expect(result.outputs).toHaveLength(0); expect(result.decision.outcome).toBe("skipped"); expect(result.decision.attachments).toHaveLength(1); - expect(result.decision.attachments[0]?.attachmentIndex).toBe(0); - expect(result.decision.attachments[0]?.attempts[0]?.outcome).toBe("skipped"); - expect(result.decision.attachments[0]?.attempts[0]?.reason).toBe( - "primary model supports vision natively", - ); + const attachment = requireDecisionAttachment(result, 0); + expect(attachment.attachmentIndex).toBe(0); + const attempt = attachment.attempts[0]; + if (!attempt) { + throw new Error("expected media-understanding skipped attempt"); + } + expect(attempt.outcome).toBe("skipped"); + expect(attempt.reason).toBe("primary model supports vision natively"); } finally { await cache.cleanup(); } @@ -385,9 +406,10 @@ describe("runCapability image skip", () => { }); expect(result.decision.outcome).toBe("success"); - expect(result.outputs[0]?.provider).toBe("openrouter"); - expect(result.outputs[0]?.model).toBe("auto"); - expect(result.outputs[0]?.text).toBe("openrouter ok"); + const output = requireCapabilityOutput(result, 0); + expect(output.provider).toBe("openrouter"); + expect(output.model).toBe("auto"); + expect(output.text).toBe("openrouter ok"); expect(seenModel).toBe("auto"); }, ); diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index a19023b69fb..605b3212b02 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -47,7 +47,7 @@ afterEach(() => { }); function getFirstGuardedFetchCall() { - const call = fetchWithSsrFGuardMock.mock.calls[0]?.[0]; + const [call] = fetchWithSsrFGuardMock.mock.calls[0] ?? []; if (!call) { throw new Error("Expected fetchWithSsrFGuard to be called"); } diff --git a/src/media/audio-transcode.test.ts b/src/media/audio-transcode.test.ts index 1451d926efc..655ce7b809a 100644 --- a/src/media/audio-transcode.test.ts +++ b/src/media/audio-transcode.test.ts @@ -23,12 +23,14 @@ describe("transcodeAudioBufferToOpus", () => { runFfmpegMock.mockImplementationOnce(async (args: string[]) => { capturedInputPath = args[args.indexOf("-i") + 1]; capturedOutputPath = args.at(-1); - if (!capturedInputPath || !capturedOutputPath) { + const inputPath = capturedInputPath; + const outputPath = capturedOutputPath; + if (!inputPath || !outputPath) { throw new Error("missing ffmpeg paths"); } - await expect(readFile(capturedInputPath)).resolves.toEqual(Buffer.from("source-mp3")); + await expect(readFile(inputPath)).resolves.toEqual(Buffer.from("source-mp3")); await import("node:fs/promises").then((fs) => - fs.writeFile(capturedOutputPath!, Buffer.from("opus-output")), + fs.writeFile(outputPath, Buffer.from("opus-output")), ); }); @@ -76,14 +78,15 @@ describe("transcodeAudioBufferToOpus", () => { runFfmpegMock.mockImplementationOnce(async (args: string[]) => { capturedInputPath = args[args.indexOf("-i") + 1]; capturedOutputPath = args.at(-1); - if (!capturedOutputPath) { + const outputPath = capturedOutputPath; + if (!outputPath) { throw new Error("missing ffmpeg output path"); } - const outputBaseName = path.basename(capturedOutputPath); + const outputBaseName = path.basename(outputPath); expect(outputBaseName).toContain("escape.opus"); expect(outputBaseName).toMatch(/\.part$/); await import("node:fs/promises").then((fs) => - fs.writeFile(capturedOutputPath!, Buffer.from("opus-output")), + fs.writeFile(outputPath, Buffer.from("opus-output")), ); }); diff --git a/src/media/ffmpeg-exec.test.ts b/src/media/ffmpeg-exec.test.ts index 4549da119e2..829f4e2c37b 100644 --- a/src/media/ffmpeg-exec.test.ts +++ b/src/media/ffmpeg-exec.test.ts @@ -111,7 +111,7 @@ describe("runFfprobe", () => { const promise = runFfprobe(["pipe:0"], { input: Buffer.alloc(1024) }); const stdinError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); - expect(() => child.stdin?.emit("error", stdinError)).not.toThrow(); + child.stdin?.emit("error", stdinError); execCallback()(null, Buffer.from("ok"), Buffer.alloc(0)); await expect(promise).resolves.toBe("ok"); @@ -124,7 +124,7 @@ describe("runFfprobe", () => { const promise = runFfprobe(["pipe:0"], { input: Buffer.alloc(1024) }); const stdinError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); - expect(() => child.stdin?.emit("error", stdinError)).not.toThrow(); + child.stdin?.emit("error", stdinError); const childError = new Error("ffprobe failed"); execCallback()(childError, "", ""); diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 638fc1cfe54..fefaffb9ccc 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -53,6 +53,13 @@ type UnsafeRuntimeInvocationCase = { setup?: (tmp: string) => void; }; +function requirePathToken(pathToken: PathTokenSetup | null): PathTokenSetup { + if (!pathToken) { + throw new Error("Expected PATH token fixture"); + } + return pathToken; +} + function createScriptOperandFixture(tmp: string, fixture?: RuntimeFixture): ScriptOperandFixture { if (fixture) { return { @@ -386,7 +393,7 @@ describe("hardenApprovedExecutionPaths", () => { argv: ["poccmd", "SAFE"], shellCommand: null, withPathToken: true, - expectedArgv: ({ pathToken }) => [pathToken!.expected, "SAFE"], + expectedArgv: ({ pathToken }) => [requirePathToken(pathToken).expected, "SAFE"], expectedArgvChanged: true, }, { @@ -403,7 +410,7 @@ describe("hardenApprovedExecutionPaths", () => { mode: "build-plan", argv: ["poccmd", "hello"], withPathToken: true, - expectedArgv: ({ pathToken }) => [pathToken!.expected, "hello"], + expectedArgv: ({ pathToken }) => [requirePathToken(pathToken).expected, "hello"], checkRawCommandMatchesArgv: true, expectedCommandPreview: null, }, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index e027f0b2ac9..5a98382c1c3 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -131,6 +131,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); } + function requireFirstRunCommandArgs(runCommand: MockedRunCommand): string[] { + const args = vi.mocked(runCommand).mock.calls[0]?.[0] as string[] | undefined; + if (!args) { + throw new Error("expected runCommand args"); + } + return args; + } + function expectApprovalRequiredDenied(params: { sendNodeEvent: MockedSendNodeEvent; sendInvokeResult: MockedSendInvokeResult; @@ -598,8 +606,12 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { continue; } - const runArgs = vi.mocked(invoke.runCommand).mock.calls[0]?.[0] as string[] | undefined; - expect(runArgs).toEqual(["env", "sh", "-c", "echo SAFE"]); + expect(requireFirstRunCommandArgs(invoke.runCommand)).toEqual([ + "env", + "sh", + "-c", + "echo SAFE", + ]); expect(fs.existsSync(marker)).toBe(false); expectInvokeOk(invoke.sendInvokeResult); } @@ -621,10 +633,11 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expect(transparent.runCommand).not.toHaveBeenCalled(); expectInvokeErrorMessage(transparent.sendInvokeResult, { message: "allowlist miss" }); } else { - const runArgs = vi.mocked(transparent.runCommand).mock.calls[0]?.[0] as - | string[] - | undefined; - expect(runArgs).toEqual([expect.stringMatching(/(^|[/\\])tr$/), "a", "b"]); + expect(requireFirstRunCommandArgs(transparent.runCommand)).toEqual([ + expect.stringMatching(/(^|[/\\])tr$/), + "a", + "b", + ]); expectInvokeOk(transparent.sendInvokeResult); } diff --git a/src/oc-path/tests/find.test.ts b/src/oc-path/tests/find.test.ts index 34ba3345ca8..7cde01967fa 100644 --- a/src/oc-path/tests/find.test.ts +++ b/src/oc-path/tests/find.test.ts @@ -7,197 +7,206 @@ * (a `*` in the `item` slot produces concrete paths whose `item` field * carries the matched value). */ -import { describe, expect, it } from 'vitest'; -import { findOcPaths } from '../find.js'; -import { parseJsonc } from '../jsonc/parse.js'; -import { parseJsonl } from '../jsonl/parse.js'; -import { parseMd } from '../parse.js'; -import { parseYaml } from '../yaml/parse.js'; -import { - formatOcPath, - hasWildcard, - OcPathError, - parseOcPath, -} from '../oc-path.js'; -import { - resolveOcPath, - setOcPath, -} from '../universal.js'; +import { describe, expect, it } from "vitest"; +import { findOcPaths } from "../find.js"; +import { parseJsonc } from "../jsonc/parse.js"; +import { parseJsonl } from "../jsonl/parse.js"; +import { formatOcPath, hasWildcard, OcPathError, parseOcPath } from "../oc-path.js"; +import { parseMd } from "../parse.js"; +import { resolveOcPath, setOcPath } from "../universal.js"; +import { parseYaml } from "../yaml/parse.js"; + +function collectMatchedItems(matches: readonly { path: { item?: string } }[]): string[] { + const items: string[] = []; + for (const match of matches) { + if (match.path.item !== undefined) { + items.push(match.path.item); + } + } + return items; +} // ---------- hasWildcard ---------------------------------------------------- -describe('hasWildcard', () => { - it('detects single-segment * in any slot', () => { - expect(hasWildcard(parseOcPath('oc://X/*/y'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a/*'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a/b/*'))).toBe(true); +describe("hasWildcard", () => { + it("detects single-segment * in any slot", () => { + expect(hasWildcard(parseOcPath("oc://X/*/y"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a/*"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a/b/*"))).toBe(true); }); - it('detects ** in any slot', () => { - expect(hasWildcard(parseOcPath('oc://X/**'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a/**/c'))).toBe(true); + it("detects ** in any slot", () => { + expect(hasWildcard(parseOcPath("oc://X/**"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a/**/c"))).toBe(true); }); - it('detects wildcards inside dotted sub-segments', () => { - expect(hasWildcard(parseOcPath('oc://X/a.*.c'))).toBe(true); - expect(hasWildcard(parseOcPath('oc://X/a.**.c'))).toBe(true); + it("detects wildcards inside dotted sub-segments", () => { + expect(hasWildcard(parseOcPath("oc://X/a.*.c"))).toBe(true); + expect(hasWildcard(parseOcPath("oc://X/a.**.c"))).toBe(true); }); - it('returns false for plain paths', () => { - expect(hasWildcard(parseOcPath('oc://X/a/b/c'))).toBe(false); - expect(hasWildcard(parseOcPath('oc://X/a.b.c'))).toBe(false); + it("returns false for plain paths", () => { + expect(hasWildcard(parseOcPath("oc://X/a/b/c"))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/a.b.c"))).toBe(false); }); - it('treats `*` inside an identifier as literal', () => { - expect(hasWildcard(parseOcPath('oc://X/foo*bar'))).toBe(false); - expect(hasWildcard(parseOcPath('oc://X/a*'))).toBe(false); + it("treats `*` inside an identifier as literal", () => { + expect(hasWildcard(parseOcPath("oc://X/foo*bar"))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/a*"))).toBe(false); }); }); // ---------- Wildcard guard on resolveOcPath / setOcPath ------------------- -describe('wildcard guard', () => { - const yaml = parseYaml('steps:\n - id: a\n command: foo\n').ast; +describe("wildcard guard", () => { + const yaml = parseYaml("steps:\n - id: a\n command: foo\n").ast; - it('resolveOcPath throws OcPathError for wildcard pattern (F16)', () => { + it("resolveOcPath throws OcPathError for wildcard pattern (F16)", () => { // Previously returned `null` — indistinguishable from "path doesn't // resolve". Now throws with `OC_PATH_WILDCARD_IN_RESOLVE` so the // CLI / consumers can surface "use findOcPaths" rather than "not // found". setOcPath uses a discriminated `wildcard-not-allowed` // reason; this is the resolve-side analogue. - expect(() => - resolveOcPath(yaml, parseOcPath('oc://wf/steps/*/command')), - ).toThrow(/findOcPaths/); + expect(() => resolveOcPath(yaml, parseOcPath("oc://wf/steps/*/command"))).toThrow( + /findOcPaths/, + ); try { - resolveOcPath(yaml, parseOcPath('oc://wf/**')); - expect.fail('should have thrown'); + resolveOcPath(yaml, parseOcPath("oc://wf/**")); + expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(OcPathError); - expect((err as OcPathError).code).toBe('OC_PATH_WILDCARD_IN_RESOLVE'); + expect((err as OcPathError).code).toBe("OC_PATH_WILDCARD_IN_RESOLVE"); } }); - it('setOcPath returns wildcard-not-allowed for wildcard pattern', () => { - const r = setOcPath(yaml, parseOcPath('oc://wf/steps/*/command'), 'bar'); + it("setOcPath returns wildcard-not-allowed for wildcard pattern", () => { + const r = setOcPath(yaml, parseOcPath("oc://wf/steps/*/command"), "bar"); expect(r.ok).toBe(false); - if (!r.ok) {expect(r.reason).toBe('wildcard-not-allowed');} + if (!r.ok) { + expect(r.reason).toBe("wildcard-not-allowed"); + } }); - it('setOcPath wildcard guard reason carries actionable detail', () => { - const r = setOcPath(yaml, parseOcPath('oc://wf/**'), 'bar'); + it("setOcPath wildcard guard reason carries actionable detail", () => { + const r = setOcPath(yaml, parseOcPath("oc://wf/**"), "bar"); expect(r.ok).toBe(false); - if (!r.ok) {expect(r.detail).toContain('findOcPaths');} + if (!r.ok) { + expect(r.detail).toContain("findOcPaths"); + } }); }); // ---------- findOcPaths — fast-path (no wildcards) ------------------------- -describe('findOcPaths — non-wildcard fast-path', () => { - it('wraps resolveOcPath result for plain path', () => { - const ast = parseYaml('name: x\n').ast; - const out = findOcPaths(ast, parseOcPath('oc://wf/name')); +describe("findOcPaths — non-wildcard fast-path", () => { + it("wraps resolveOcPath result for plain path", () => { + const ast = parseYaml("name: x\n").ast; + const out = findOcPaths(ast, parseOcPath("oc://wf/name")); expect(out).toHaveLength(1); - expect(out[0].match.kind).toBe('leaf'); - expect(formatOcPath(out[0].path)).toBe('oc://wf/name'); + expect(out[0].match.kind).toBe("leaf"); + expect(formatOcPath(out[0].path)).toBe("oc://wf/name"); }); - it('returns empty for unresolved plain path', () => { - const ast = parseYaml('name: x\n').ast; - expect(findOcPaths(ast, parseOcPath('oc://wf/missing'))).toHaveLength(0); + it("returns empty for unresolved plain path", () => { + const ast = parseYaml("name: x\n").ast; + expect(findOcPaths(ast, parseOcPath("oc://wf/missing"))).toHaveLength(0); }); }); // ---------- findOcPaths — YAML -------------------------------------------- -describe('findOcPaths — YAML kind', () => { +describe("findOcPaths — YAML kind", () => { const yaml = parseYaml( - 'steps:\n' + - ' - id: build\n' + - ' command: npm run build\n' + - ' - id: test\n' + - ' command: npm test\n' + - ' - id: lint\n' + - ' command: npm run lint\n' + "steps:\n" + + " - id: build\n" + + " command: npm run build\n" + + " - id: test\n" + + " command: npm test\n" + + " - id: lint\n" + + " command: npm run lint\n", ).ast; - it('* in item slot enumerates each step', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf.lobster/steps/*/command')); + it("* in item slot enumerates each step", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf.lobster/steps/*/command")); expect(out).toHaveLength(3); const paths = out.map((m) => formatOcPath(m.path)); expect(paths).toEqual([ - 'oc://wf.lobster/steps/0/command', - 'oc://wf.lobster/steps/1/command', - 'oc://wf.lobster/steps/2/command', + "oc://wf.lobster/steps/0/command", + "oc://wf.lobster/steps/1/command", + "oc://wf.lobster/steps/2/command", ]); }); - it('preserves slot shape — concrete path has matched value in item slot', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/id')); + it("preserves slot shape — concrete path has matched value in item slot", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/id")); expect(out).toHaveLength(3); for (const m of out) { - expect(m.path.section).toBe('steps'); - expect(m.path.field).toBe('id'); + expect(m.path.section).toBe("steps"); + expect(m.path.field).toBe("id"); expect(m.path.item).toMatch(/^[0-2]$/); } }); - it('returns leaf valueText for each match', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/id')); - const leaves = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : null); - expect(leaves).toEqual(['build', 'test', 'lint']); + it("returns leaf valueText for each match", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/id")); + const leaves = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : null)); + expect(leaves).toEqual(["build", "test", "lint"]); }); - it('** descends recursively', () => { - const yaml2 = parseYaml( - 'a:\n b:\n c: deep\n d: shallow\n' - ).ast; - const out = findOcPaths(yaml2, parseOcPath('oc://wf/**')); + it("** descends recursively", () => { + const yaml2 = parseYaml("a:\n b:\n c: deep\n d: shallow\n").ast; + const out = findOcPaths(yaml2, parseOcPath("oc://wf/**")); // ** matches root + a + a.b + a.b.c + a.d - const leaves = out.filter((m) => m.match.kind === 'leaf').map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(leaves.toSorted()).toEqual(['deep', 'shallow']); + const leaves = out + .filter((m) => m.match.kind === "leaf") + .map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(leaves.toSorted()).toEqual(["deep", "shallow"]); }); - it('returns empty for path that does not match', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/missing/*/x')); + it("returns empty for path that does not match", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/missing/*/x")); expect(out).toHaveLength(0); }); - it('every returned path is consumable by resolveOcPath', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/command')); + it("every returned path is consumable by resolveOcPath", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/command")); for (const m of out) { const r = resolveOcPath(yaml, m.path); - expect(r).not.toBeNull(); - expect(r?.kind).toBe('leaf'); + expect(r?.kind).toBe("leaf"); } }); }); // ---------- findOcPaths — JSONC -------------------------------------------- -describe('findOcPaths — JSONC kind', () => { +describe("findOcPaths — JSONC kind", () => { const jsonc = parseJsonc( - '{\n' + - ' "plugins": {\n' + - ' "github": {"enabled": true},\n' + - ' "gitlab": {"enabled": false},\n' + - ' "slack": {"enabled": true}\n' + - ' }\n' + - '}\n' + "{\n" + + ' "plugins": {\n' + + ' "github": {"enabled": true},\n' + + ' "gitlab": {"enabled": false},\n' + + ' "slack": {"enabled": true}\n' + + " }\n" + + "}\n", ).ast; - it('* in item slot enumerates each plugin', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/*/enabled')); + it("* in item slot enumerates each plugin", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/*/enabled")); expect(out).toHaveLength(3); const keys = out.map((m) => m.path.item); - expect(keys.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['github', 'gitlab', 'slack']); + expect(keys.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual([ + "github", + "gitlab", + "slack", + ]); }); - it('returns boolean leaves with leafType', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/*/enabled')); + it("returns boolean leaves with leafType", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/*/enabled")); for (const m of out) { - expect(m.match.kind).toBe('leaf'); - if (m.match.kind === 'leaf') { - expect(m.match.leafType).toBe('boolean'); + expect(m.match.kind).toBe("leaf"); + if (m.match.kind === "leaf") { + expect(m.match.leafType).toBe("boolean"); } } }); @@ -205,22 +214,22 @@ describe('findOcPaths — JSONC kind', () => { // ---------- findOcPaths — JSONL -------------------------------------------- -describe('findOcPaths — JSONL kind', () => { +describe("findOcPaths — JSONL kind", () => { const jsonl = parseJsonl( '{"event":"start","userId":"u1"}\n' + - '{"event":"action","userId":"u1"}\n' + - '{"event":"end","userId":"u1"}\n' + '{"event":"action","userId":"u1"}\n' + + '{"event":"end","userId":"u1"}\n', ).ast; - it('* in section slot enumerates each value line', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/*/event')); + it("* in section slot enumerates each value line", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/*/event")); expect(out).toHaveLength(3); - const events = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(events).toEqual(['start', 'action', 'end']); + const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(events).toEqual(["start", "action", "end"]); }); - it('preserves Lnnn line addresses in concrete paths', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/*/event')); + it("preserves Lnnn line addresses in concrete paths", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/*/event")); for (const m of out) { expect(m.path.section).toMatch(/^L\d+$/); } @@ -229,206 +238,224 @@ describe('findOcPaths — JSONL kind', () => { // F8 — line-slot union and predicate. Without these, yaml/jsonc // walkers handled them but JSONL fell through to `pickLine(addr)` // which returns null for union/predicate shapes → silent zero matches. - it('union {L1,L2} at line slot enumerates each alternative', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/{L1,L3}/event')); + it("union {L1,L2} at line slot enumerates each alternative", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/{L1,L3}/event")); expect(out).toHaveLength(2); - const events = out.map((m) => (m.match.kind === 'leaf' ? m.match.valueText : '')); - expect(events).toEqual(['start', 'end']); + const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(events).toEqual(["start", "end"]); }); - it('union of positional + literal line addresses works', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/{L1,$last}/event')); + it("union of positional + literal line addresses works", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/{L1,$last}/event")); expect(out).toHaveLength(2); - const events = out.map((m) => (m.match.kind === 'leaf' ? m.match.valueText : '')); - expect(events).toEqual(['start', 'end']); + const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(events).toEqual(["start", "end"]); }); - it('predicate [event=action] at line slot filters by top-level field', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/[event=action]/userId')); + it("predicate [event=action] at line slot filters by top-level field", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/[event=action]/userId")); expect(out).toHaveLength(1); - if (out[0]?.match.kind === 'leaf') {expect(out[0].match.valueText).toBe('u1');} + if (out[0]?.match.kind === "leaf") { + expect(out[0].match.valueText).toBe("u1"); + } }); - it('predicate [event=missing] at line slot matches zero lines (silent zero is correct)', () => { - const out = findOcPaths(jsonl, parseOcPath('oc://session/[event=missing]/userId')); + it("predicate [event=missing] at line slot matches zero lines (silent zero is correct)", () => { + const out = findOcPaths(jsonl, parseOcPath("oc://session/[event=missing]/userId")); expect(out).toHaveLength(0); }); }); // ---------- Positional primitives ($first / $last / -N) ------------------- -describe('positional primitives — yaml', () => { - const yaml = parseYaml( - 'steps:\n - id: a\n - id: b\n - id: c\n' - ).ast; +describe("positional primitives — yaml", () => { + const yaml = parseYaml("steps:\n - id: a\n - id: b\n - id: c\n").ast; - it('resolveOcPath accepts $first', () => { - const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/$first/id')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('a');} + it("resolveOcPath accepts $first", () => { + const m = resolveOcPath(yaml, parseOcPath("oc://wf/steps/$first/id")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("a"); + } }); - it('resolveOcPath accepts $last', () => { - const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/$last/id')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('c');} + it("resolveOcPath accepts $last", () => { + const m = resolveOcPath(yaml, parseOcPath("oc://wf/steps/$last/id")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("c"); + } }); - it('resolveOcPath accepts negative index', () => { - const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/-2/id')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('b');} + it("resolveOcPath accepts negative index", () => { + const m = resolveOcPath(yaml, parseOcPath("oc://wf/steps/-2/id")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("b"); + } }); - it('out-of-range positional returns null', () => { - expect(resolveOcPath(yaml, parseOcPath('oc://wf/steps/-99/id'))).toBeNull(); + it("out-of-range positional returns null", () => { + expect(resolveOcPath(yaml, parseOcPath("oc://wf/steps/-99/id"))).toBeNull(); }); - it('positional on empty container returns null', () => { - const empty = parseYaml('steps: []\n').ast; - expect(resolveOcPath(empty, parseOcPath('oc://wf/steps/$first/id'))).toBeNull(); + it("positional on empty container returns null", () => { + const empty = parseYaml("steps: []\n").ast; + expect(resolveOcPath(empty, parseOcPath("oc://wf/steps/$first/id"))).toBeNull(); }); - it('findOcPaths emits concrete index for positional', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/$last/id')); + it("findOcPaths emits concrete index for positional", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/$last/id")); expect(out).toHaveLength(1); - expect(out[0].path.item).toBe('2'); + expect(out[0].path.item).toBe("2"); }); - it('hasWildcard returns false for positional patterns', () => { + it("hasWildcard returns false for positional patterns", () => { // Positional ≠ wildcard — they resolve deterministically. - expect(hasWildcard(parseOcPath('oc://X/$last/id'))).toBe(false); - expect(hasWildcard(parseOcPath('oc://X/-1/id'))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/$last/id"))).toBe(false); + expect(hasWildcard(parseOcPath("oc://X/-1/id"))).toBe(false); }); }); -describe('positional primitives — jsonc', () => { +describe("positional primitives — jsonc", () => { const jsonc = parseJsonc('{"items":[10,20,30]}').ast; - it('$first picks first array element', () => { - const m = resolveOcPath(jsonc, parseOcPath('oc://config/items/$first')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('10');} + it("$first picks first array element", () => { + const m = resolveOcPath(jsonc, parseOcPath("oc://config/items/$first")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("10"); + } }); - it('$last picks last array element', () => { - const m = resolveOcPath(jsonc, parseOcPath('oc://config/items/$last')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('30');} + it("$last picks last array element", () => { + const m = resolveOcPath(jsonc, parseOcPath("oc://config/items/$last")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("30"); + } }); - it('$first on object picks first-declared key', () => { + it("$first on object picks first-declared key", () => { const obj = parseJsonc('{"a":1,"b":2,"c":3}').ast; - const m = resolveOcPath(obj, parseOcPath('oc://config/$first')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('1');} + const m = resolveOcPath(obj, parseOcPath("oc://config/$first")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("1"); + } }); }); -describe('positional primitives — jsonl', () => { - const jsonl = parseJsonl( - '{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n' - ).ast; +describe("positional primitives — jsonl", () => { + const jsonl = parseJsonl('{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n').ast; - it('$first picks first value line', () => { - const m = resolveOcPath(jsonl, parseOcPath('oc://session/$first/event')); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('start');} + it("$first picks first value line", () => { + const m = resolveOcPath(jsonl, parseOcPath("oc://session/$first/event")); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("start"); + } }); - it('$last picks last value line (existing behavior)', () => { - const m = resolveOcPath(jsonl, parseOcPath('oc://session/$last/event')); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('end');} + it("$last picks last value line (existing behavior)", () => { + const m = resolveOcPath(jsonl, parseOcPath("oc://session/$last/event")); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("end"); + } }); - it('-1 is alias for $last', () => { - const m = resolveOcPath(jsonl, parseOcPath('oc://session/-1/event')); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('end');} + it("-1 is alias for $last", () => { + const m = resolveOcPath(jsonl, parseOcPath("oc://session/-1/event")); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("end"); + } }); }); // ---------- Segment unions {a,b,c} ----------------------------------------- -describe('union segments — yaml', () => { +describe("union segments — yaml", () => { const yaml = parseYaml( - 'steps:\n' + - ' - id: a\n command: x\n' + - ' - id: b\n run: y\n' + - ' - id: c\n pipeline: z\n' + "steps:\n" + + " - id: a\n command: x\n" + + " - id: b\n run: y\n" + + " - id: c\n pipeline: z\n", ).ast; - it('{command,run} matches each step that has either field', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/{command,run}')); + it("{command,run} matches each step that has either field", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/{command,run}")); expect(out).toHaveLength(2); const fields = out.map((m) => m.path.field); - expect(fields.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['command', 'run']); + expect(fields.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["command", "run"]); }); - it('preserves the chosen alternative in concrete paths', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/{command,pipeline}')); + it("preserves the chosen alternative in concrete paths", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/*/{command,pipeline}")); expect(out).toHaveLength(2); for (const m of out) { - expect(['command', 'pipeline']).toContain(m.path.field); + expect(["command", "pipeline"]).toContain(m.path.field); } }); - it('unions on top-level keys', () => { - const yaml2 = parseYaml('a: 1\nb: 2\nc: 3\n').ast; - const out = findOcPaths(yaml2, parseOcPath('oc://X/{a,c}')); + it("unions on top-level keys", () => { + const yaml2 = parseYaml("a: 1\nb: 2\nc: 3\n").ast; + const out = findOcPaths(yaml2, parseOcPath("oc://X/{a,c}")); expect(out).toHaveLength(2); - const values = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(values.toSorted()).toEqual(['1', '3']); + const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(values.toSorted()).toEqual(["1", "3"]); }); - it('hasWildcard detects unions (single-match guard rejects them)', () => { - expect(hasWildcard(parseOcPath('oc://X/{a,b}'))).toBe(true); + it("hasWildcard detects unions (single-match guard rejects them)", () => { + expect(hasWildcard(parseOcPath("oc://X/{a,b}"))).toBe(true); // F16 — wildcard guard now throws OC_PATH_WILDCARD_IN_RESOLVE // instead of returning silent null. - expect(() => - resolveOcPath(parseYaml('a: 1\nb: 2\n').ast, parseOcPath('oc://X/{a,b}')), - ).toThrow(/findOcPaths/); + expect(() => resolveOcPath(parseYaml("a: 1\nb: 2\n").ast, parseOcPath("oc://X/{a,b}"))).toThrow( + /findOcPaths/, + ); }); }); // ---------- Value predicates [key=value] ---------------------------------- -describe('value predicates — yaml', () => { +describe("value predicates — yaml", () => { const yaml = parseYaml( - 'steps:\n' + - ' - id: build\n command: npm run build\n' + - ' - id: test\n command: npm test\n' + - ' - id: lint\n command: npm run lint\n' + "steps:\n" + + " - id: build\n command: npm run build\n" + + " - id: test\n command: npm test\n" + + " - id: lint\n command: npm run lint\n", ).ast; - it('[id=test] selects the matching step', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=test]/command')); + it("[id=test] selects the matching step", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/[id=test]/command")); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') { - expect(out[0].match.valueText).toBe('npm test'); + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("npm test"); } - expect(out[0].path.item).toBe('1'); // concrete index of the matched step + expect(out[0].path.item).toBe("1"); // concrete index of the matched step }); - it('predicate yields no matches when key/value missing', () => { - expect(findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=nonexistent]/command'))).toHaveLength(0); + it("predicate yields no matches when key/value missing", () => { + expect(findOcPaths(yaml, parseOcPath("oc://wf/steps/[id=nonexistent]/command"))).toHaveLength( + 0, + ); }); - it('predicate concretizes the index — path round-trips through resolveOcPath', () => { - const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=build]/command')); + it("predicate concretizes the index — path round-trips through resolveOcPath", () => { + const out = findOcPaths(yaml, parseOcPath("oc://wf/steps/[id=build]/command")); expect(out).toHaveLength(1); const resolved = resolveOcPath(yaml, out[0].path); - expect(resolved?.kind).toBe('leaf'); + expect(resolved?.kind).toBe("leaf"); }); - it('predicate rejects single-match verbs (treated as wildcard)', () => { + it("predicate rejects single-match verbs (treated as wildcard)", () => { // F16 — wildcard guard throws on predicate too (predicate is a // multi-match shape; resolveOcPath is single-match only). - expect(() => - resolveOcPath(yaml, parseOcPath('oc://wf/steps/[id=build]')), - ).toThrow(/findOcPaths/); + expect(() => resolveOcPath(yaml, parseOcPath("oc://wf/steps/[id=build]"))).toThrow( + /findOcPaths/, + ); }); }); -describe('quoted segments (v1.0)', () => { +describe("quoted segments (v1.0)", () => { // Evidence: openclaw#69004 — model alias `anthropic/claude-opus-4-7`. // Slash inside the key has no other syntax that doesn't conflict with // path-level slash split. @@ -437,70 +464,80 @@ describe('quoted segments (v1.0)', () => { '"anthropic/claude-opus-4-7":{"alias":"opus47","contextWindow":1000000},' + '"github-copilot/claude-opus-4.7-1m-internal":{"alias":"copilot-opus-1m","contextWindow":1000000},' + '"plain":{"alias":"p","contextWindow":200000}' + - '}}}}' + "}}}}", ).ast; - it('resolveOcPath — quoted segment with literal slash', () => { + it("resolveOcPath — quoted segment with literal slash", () => { const m = resolveOcPath( jsonc, parseOcPath('oc://config/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'), ); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('opus47');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("opus47"); + } }); - it('resolveOcPath — quoted segment with literal slash AND dot', () => { + it("resolveOcPath — quoted segment with literal slash AND dot", () => { const m = resolveOcPath( jsonc, - parseOcPath('oc://config/agents.defaults.models/"github-copilot/claude-opus-4.7-1m-internal"/alias'), + parseOcPath( + 'oc://config/agents.defaults.models/"github-copilot/claude-opus-4.7-1m-internal"/alias', + ), ); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('copilot-opus-1m');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("copilot-opus-1m"); + } }); - it('quoted segment with whitespace', () => { + it("quoted segment with whitespace", () => { const ast = parseJsonc('{"prompts":{"hello world":"value"}}').ast; const m = resolveOcPath(ast, parseOcPath('oc://X/prompts/"hello world"')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('value');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("value"); + } }); - it('quoted segment with embedded escape sequences', () => { + it("quoted segment with embedded escape sequences", () => { // Key literally contains a backslash and a quote. const ast = parseJsonc('{"keys":{"a\\\\b":"v1","c\\"d":"v2"}}').ast; const m1 = resolveOcPath(ast, parseOcPath('oc://X/keys/"a\\\\b"')); - expect(m1?.kind).toBe('leaf'); - if (m1?.kind === 'leaf') {expect(m1.valueText).toBe('v1');} + expect(m1?.kind).toBe("leaf"); + if (m1?.kind === "leaf") { + expect(m1.valueText).toBe("v1"); + } }); - it('findOcPaths — wildcard returns paths with quoted keys when needed', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/agents.defaults.models/*/alias')); + it("findOcPaths — wildcard returns paths with quoted keys when needed", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/agents.defaults.models/*/alias")); expect(out).toHaveLength(3); // The two slash-bearing keys round-trip via quotes; `plain` stays bare. const items = out.map((m) => m.path.item); - expect(items.some((s) => s === 'plain')).toBe(true); + expect(items.some((s) => s === "plain")).toBe(true); expect(items.some((s) => s === '"anthropic/claude-opus-4-7"')).toBe(true); expect(items.some((s) => s === '"github-copilot/claude-opus-4.7-1m-internal"')).toBe(true); }); - it('findOcPaths — emitted paths round-trip through resolveOcPath', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/agents.defaults.models/*/alias')); + it("findOcPaths — emitted paths round-trip through resolveOcPath", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/agents.defaults.models/*/alias")); for (const m of out) { const r = resolveOcPath(jsonc, m.path); - expect(r?.kind).toBe('leaf'); + expect(r?.kind).toBe("leaf"); } }); - it('rejects unbalanced quotes at parse time', () => { + it("rejects unbalanced quotes at parse time", () => { expect(() => parseOcPath('oc://X/"unterminated')).toThrow(/Unbalanced/); }); - it('control characters still rejected inside quotes', () => { + it("control characters still rejected inside quotes", () => { expect(() => parseOcPath('oc://X/"\x00"')).toThrow(/Control character/); }); }); -describe('value predicates — numeric operators (v1.1)', () => { +describe("value predicates — numeric operators (v1.1)", () => { // Evidence: openclaw#54383 — compaction fails when maxTokens > model output cap. // Doctor lint rule: flag any model with maxTokens > 128000 (Anthropic per-request output cap). const jsonc = parseJsonc( @@ -508,142 +545,148 @@ describe('value predicates — numeric operators (v1.1)', () => { '{"id":"claude-sonnet-4-6","contextWindow":1000000,"maxTokens":128000},' + '{"id":"claude-opus-4-7","contextWindow":1000000,"maxTokens":240000},' + '{"id":"claude-sonnet-4-7","contextWindow":200000,"maxTokens":64000}' + - ']}}}}' + "]}}}}", ).ast; // Slot layout: section=`models.providers.anthropic.models`, item=predicate, field=`id`. - const PREFIX = 'oc://config/models.providers.anthropic.models'; + const PREFIX = "oc://config/models.providers.anthropic.models"; - it('> finds models exceeding the per-request output cap', () => { + it("> finds models exceeding the per-request output cap", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>128000]/id`)); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('claude-opus-4-7');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("claude-opus-4-7"); + } }); - it('>= matches the boundary', () => { + it(">= matches the boundary", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>=128000]/id`)); - const ids = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(ids.toSorted()).toEqual(['claude-opus-4-7', 'claude-sonnet-4-6']); + const ids = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(ids.toSorted()).toEqual(["claude-opus-4-7", "claude-sonnet-4-6"]); }); - it('< filters small context windows', () => { + it("< filters small context windows", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<500000]/id`)); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('claude-sonnet-4-7');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("claude-sonnet-4-7"); + } }); - it('<= matches the boundary', () => { + it("<= matches the boundary", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<=200000]/id`)); - const ids = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(ids).toEqual(['claude-sonnet-4-7']); + const ids = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(ids).toEqual(["claude-sonnet-4-7"]); }); - it('numeric operator rejects non-numeric leaves silently', () => { + it("numeric operator rejects non-numeric leaves silently", () => { // String leaf, numeric op — predicate doesn't match (no false positive). const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[id>5]/id`)); expect(out).toHaveLength(0); }); - it('rejects numeric predicate value that is not a number', () => { + it("rejects numeric predicate value that is not a number", () => { const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>foo]/id`)); expect(out).toHaveLength(0); }); }); -describe('value predicates — jsonc', () => { +describe("value predicates — jsonc", () => { const jsonc = parseJsonc( - '{"plugins":{"github":{"enabled":true,"role":"vcs"},"slack":{"enabled":false,"role":"chat"},"jira":{"enabled":true,"role":"tracker"}}}' + '{"plugins":{"github":{"enabled":true,"role":"vcs"},"slack":{"enabled":false,"role":"chat"},"jira":{"enabled":true,"role":"tracker"}}}', ).ast; - it('[enabled=true] filters by sibling boolean', () => { - const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/[enabled=true]/role')); + it("[enabled=true] filters by sibling boolean", () => { + const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/[enabled=true]/role")); expect(out).toHaveLength(2); - const roles = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(roles.toSorted()).toEqual(['tracker', 'vcs']); + const roles = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(roles.toSorted()).toEqual(["tracker", "vcs"]); }); }); // ---------- Ordinal addressing (#N) for distinct duplicate slugs ---------- -describe('ordinal addressing — md', () => { +describe("ordinal addressing — md", () => { // Two items with the same slug after slugify (`foo: a` and `foo: b`). - const md = parseMd( - '## Tools\n\n- foo: a\n- foo: b\n- bar: c\n' - ).ast; + const md = parseMd("## Tools\n\n- foo: a\n- foo: b\n- bar: c\n").ast; - it('#0 picks the first item by document order', () => { - const m = resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#0/foo')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('a');} + it("#0 picks the first item by document order", () => { + const m = resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#0/foo")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("a"); + } }); - it('#1 picks the second item — distinct from #0 even though slug collides', () => { - const m = resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#1/foo')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('b');} + it("#1 picks the second item — distinct from #0 even though slug collides", () => { + const m = resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#1/foo")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("b"); + } }); - it('out-of-range #N returns null', () => { - expect(resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#99/foo'))).toBeNull(); + it("out-of-range #N returns null", () => { + expect(resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#99/foo"))).toBeNull(); }); - it('findOcPaths disambiguates duplicate-slug items via #N', () => { - const out = findOcPaths(md, parseOcPath('oc://AGENTS.md/tools/*/foo')); + it("findOcPaths disambiguates duplicate-slug items via #N", () => { + const out = findOcPaths(md, parseOcPath("oc://AGENTS.md/tools/*/foo")); // 2 items have key `foo` (and matching slug); 1 has `bar` (no match). expect(out).toHaveLength(2); const items = out.map((m) => m.path.item); - expect(items).toEqual(['#0', '#1']); - const values = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); - expect(values.toSorted()).toEqual(['a', 'b']); + expect(items).toEqual(["#0", "#1"]); + const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : "")); + expect(values.toSorted()).toEqual(["a", "b"]); }); - it('non-duplicate slug keeps slug form (back-compat)', () => { - const md2 = parseMd('## Tools\n\n- foo: a\n- bar: b\n').ast; - const out = findOcPaths(md2, parseOcPath('oc://AGENTS.md/tools/*')); + it("non-duplicate slug keeps slug form (back-compat)", () => { + const md2 = parseMd("## Tools\n\n- foo: a\n- bar: b\n").ast; + const out = findOcPaths(md2, parseOcPath("oc://AGENTS.md/tools/*")); const items = out.map((m) => m.path.item); // Both unique → both stay as slugs. - expect(items.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['bar', 'foo']); + expect(items.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["bar", "foo"]); }); }); // ---------- findOcPaths — Markdown ----------------------------------------- -describe('findOcPaths — Markdown kind', () => { +describe("findOcPaths — Markdown kind", () => { const md = parseMd( - '---\nname: drafter\nrole: writer\n---\n\n' + - '## Tools\n\n' + - '- send_email: enabled\n' + - '- search: enabled\n' + - '- read_email: disabled\n' + "---\nname: drafter\nrole: writer\n---\n\n" + + "## Tools\n\n" + + "- send_email: enabled\n" + + "- search: enabled\n" + + "- read_email: disabled\n", ).ast; - it('* in field slot enumerates frontmatter keys', () => { - const out = findOcPaths(md, parseOcPath('oc://SOUL.md/[frontmatter]/*')); + it("* in field slot enumerates frontmatter keys", () => { + const out = findOcPaths(md, parseOcPath("oc://SOUL.md/[frontmatter]/*")); expect(out).toHaveLength(2); const keys = out.map((m) => m.path.item ?? m.path.field); - expect(keys.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['name', 'role']); + expect(keys.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["name", "role"]); }); - it('* in field slot enumerates each item kv key', () => { + it("* in field slot enumerates each item kv key", () => { // Item slug is the kv-key slug ('send_email' → 'send-email'). - const out = findOcPaths(md, parseOcPath('oc://SKILL.md/Tools/send-email/*')); + const out = findOcPaths(md, parseOcPath("oc://SKILL.md/Tools/send-email/*")); expect(out).toHaveLength(1); - expect(out[0].match.kind).toBe('leaf'); - if (out[0].match.kind === 'leaf') { - expect(out[0].match.valueText).toBe('enabled'); + expect(out[0].match.kind).toBe("leaf"); + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("enabled"); } }); - it('* in item slot + matching field returns each item whose kv key matches', () => { + it("* in item slot + matching field returns each item whose kv key matches", () => { // The kv key on `- send_email: enabled` is `send_email`. Pattern // field='send_email' matches that one item; the other two items // (search, read_email) have different kv keys. - const out = findOcPaths(md, parseOcPath('oc://SKILL.md/Tools/*/send_email')); + const out = findOcPaths(md, parseOcPath("oc://SKILL.md/Tools/*/send_email")); expect(out).toHaveLength(1); - expect(out[0].path.item).toBe('send-email'); + expect(out[0].path.item).toBe("send-email"); }); - it('** at section slot matches items at every depth (F14 — cross-kind symmetry)', () => { + it("** at section slot matches items at every depth (F14 — cross-kind symmetry)", () => { // Without the retain-i branch on `**`, walkMd only descended one // level (i + 1, consumed `**`) — yaml/jsonc walkers also retain // `**` to keep matching deeper. Lint rules expecting universal @@ -656,23 +699,23 @@ describe('findOcPaths — Markdown kind', () => { // section layer and then can't satisfy the item slot since the // walker is now inside the wrong block looking for an item slug. const multiBlock = parseMd( - '## Boundaries\n\n' + - '- never: rm -rf\n\n' + - '## Tools\n\n' + - '- send_email: enabled\n' + - '- search: enabled\n', + "## Boundaries\n\n" + + "- never: rm -rf\n\n" + + "## Tools\n\n" + + "- send_email: enabled\n" + + "- search: enabled\n", ).ast; - const out = findOcPaths(multiBlock, parseOcPath('oc://SOUL.md/**/send-email')); + const out = findOcPaths(multiBlock, parseOcPath("oc://SOUL.md/**/send-email")); // The `send-email` item is under the `tools` block. Pin that we // get at least one match (the substrate's md `**` should reach it). expect(out.length).toBeGreaterThanOrEqual(1); - const items = out.map((m) => m.path.item).filter((v): v is string => v !== undefined); - expect(items).toContain('send-email'); + const items = collectMatchedItems(out); + expect(items).toContain("send-email"); }); }); -describe('findOcPaths — quoted segments survive expansion (regression: resolve↔find symmetry)', () => { - it('finds keys with slashes when the path quotes them and a sibling wildcards', () => { +describe("findOcPaths — quoted segments survive expansion (regression: resolve↔find symmetry)", () => { + it("finds keys with slashes when the path quotes them and a sibling wildcards", () => { // Closes ClawSweeper P2 on PR #78678: when a pattern needs // expansion (e.g. trailing union or wildcard), the JSONC walker // bypassed `resolveJsoncOcPath` and compared object keys to the @@ -701,7 +744,9 @@ describe('findOcPaths — quoted segments survive expansion (regression: resolve ); // Both alternatives in the union should match. expect(out.length).toBe(2); - const fields = out.map((m) => m.path.field).toSorted((a, b) => (a ?? '').localeCompare(b ?? '')); - expect(fields).toEqual(['alias', 'contextWindow']); + const fields = out + .map((m) => m.path.field) + .toSorted((a, b) => (a ?? "").localeCompare(b ?? "")); + expect(fields).toEqual(["alias", "contextWindow"]); }); }); diff --git a/src/oc-path/tests/jsonl/resolve.test.ts b/src/oc-path/tests/jsonl/resolve.test.ts index 9fccd944bf6..c24d7511db2 100644 --- a/src/oc-path/tests/jsonl/resolve.test.ts +++ b/src/oc-path/tests/jsonl/resolve.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from 'vitest'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; -import { parseOcPath } from '../../oc-path.js'; -import { resolveOcPath } from '../../universal.js'; -import { findOcPaths } from '../../find.js'; +import { describe, expect, it } from "vitest"; +import { findOcPaths } from "../../find.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { resolveJsonlOcPath } from "../../jsonl/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; +import { resolveOcPath } from "../../universal.js"; const log = `{"event":"start","ts":1} {"event":"step","n":1,"result":{"ok":true,"detail":"a"}} @@ -16,51 +16,51 @@ function rs(ocPath: string) { return resolveJsonlOcPath(ast, parseOcPath(ocPath)); } -describe('resolveJsonlOcPath', () => { - it('returns root when no segments are given', () => { - expect(rs('oc://session-events')?.kind).toBe('root'); +describe("resolveJsonlOcPath", () => { + it("returns root when no segments are given", () => { + expect(rs("oc://session-events")?.kind).toBe("root"); }); - it('addresses an entire line by line number', () => { - const m = rs('oc://session-events/L1'); - expect(m?.kind).toBe('line'); + it("addresses an entire line by line number", () => { + const m = rs("oc://session-events/L1"); + expect(m?.kind).toBe("line"); }); - it('addresses fields under a line via item segment', () => { - const m = rs('oc://session-events/L2/event'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'step' }); + it("addresses fields under a line via item segment", () => { + const m = rs("oc://session-events/L2/event"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "step" }); } }); - it('descends via dotted item paths', () => { - const m = rs('oc://session-events/L2/result.ok'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'boolean', value: true }); + it("descends via dotted item paths", () => { + const m = rs("oc://session-events/L2/result.ok"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "boolean", value: true }); } }); - it('resolves $last to the most recent value line', () => { - const m = rs('oc://session-events/$last/event'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'end' }); + it("resolves $last to the most recent value line", () => { + const m = rs("oc://session-events/$last/event"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "end" }); } }); - it('returns null for unknown line addresses', () => { - expect(rs('oc://session-events/L99')).toBeNull(); - expect(rs('oc://session-events/garbage')).toBeNull(); + it("returns null for unknown line addresses", () => { + expect(rs("oc://session-events/L99")).toBeNull(); + expect(rs("oc://session-events/garbage")).toBeNull(); }); - it('returns null when descending into a blank line', () => { - expect(rs('oc://session-events/L3/anything')).toBeNull(); + it("returns null when descending into a blank line", () => { + expect(rs("oc://session-events/L3/anything")).toBeNull(); }); }); -describe('resolveJsonlToUniversal — file-relative line metadata (regression)', () => { +describe("resolveJsonlToUniversal — file-relative line metadata (regression)", () => { // Regression: surfaced via the openclaw-path CLI scenario run on // a multi-line session.jsonl. Every match returned `line: 1` // because the inside-line jsonc parser numbers from 1 within each @@ -68,30 +68,28 @@ describe('resolveJsonlToUniversal — file-relative line metadata (regression)', // number over the JsonlLine's file-relative line. const log = [ - '{"event":"start"}', // line 1 - '{"event":"step","n":1}', // line 2 - '{"event":"step","n":2}', // line 3 - '{"event":"end"}', // line 4 - '', // line 5 (blank) - ].join('\n'); + '{"event":"start"}', // line 1 + '{"event":"step","n":1}', // line 2 + '{"event":"step","n":2}', // line 3 + '{"event":"end"}', // line 4 + "", // line 5 (blank) + ].join("\n"); - it('resolves L2/event with line=2 (not 1)', () => { + it("resolves L2/event with line=2 (not 1)", () => { const { ast } = parseJsonl(log); - const m = resolveOcPath(ast, parseOcPath('oc://session.jsonl/L2/event')); - expect(m).not.toBeNull(); - if (m !== null) {expect(m.line).toBe(2);} + const m = resolveOcPath(ast, parseOcPath("oc://session.jsonl/L2/event")); + expect(m).toEqual(expect.objectContaining({ line: 2 })); }); - it('resolves L4/event with line=4', () => { + it("resolves L4/event with line=4", () => { const { ast } = parseJsonl(log); - const m = resolveOcPath(ast, parseOcPath('oc://session.jsonl/L4/event')); - expect(m).not.toBeNull(); - if (m !== null) {expect(m.line).toBe(4);} + const m = resolveOcPath(ast, parseOcPath("oc://session.jsonl/L4/event")); + expect(m).toEqual(expect.objectContaining({ line: 4 })); }); - it('findOcPaths over wildcard surfaces correct file-relative lines', () => { + it("findOcPaths over wildcard surfaces correct file-relative lines", () => { const { ast } = parseJsonl(log); - const matches = findOcPaths(ast, parseOcPath('oc://session.jsonl/*/event')); + const matches = findOcPaths(ast, parseOcPath("oc://session.jsonl/*/event")); expect(matches).toHaveLength(4); const lines = matches.map((m) => m.match.line); expect(lines).toEqual([1, 2, 3, 4]); diff --git a/src/oc-path/tests/scenarios/cross-cutting.test.ts b/src/oc-path/tests/scenarios/cross-cutting.test.ts index ab8ab5a93c7..a53f489ed2b 100644 --- a/src/oc-path/tests/scenarios/cross-cutting.test.ts +++ b/src/oc-path/tests/scenarios/cross-cutting.test.ts @@ -5,11 +5,11 @@ * across re-parses. OcPath round-trip via the AST (slugs in OcPath * must round-trip back to the resolved node). */ -import { describe, expect, it } from 'vitest'; -import { emitMd } from '../../emit.js'; -import { formatOcPath, parseOcPath } from '../../oc-path.js'; -import { parseMd } from '../../parse.js'; -import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { formatOcPath, parseOcPath } from "../../oc-path.js"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const SAMPLE = `--- name: github @@ -29,60 +29,60 @@ Preamble. - curl: HTTP client `; -describe('wave-13 cross-cutting', () => { - it('CC-01 parse → resolve → emit pipeline (block)', () => { +describe("wave-13 cross-cutting", () => { + it("CC-01 parse → resolve → emit pipeline (block)", () => { const { ast } = parseMd(SAMPLE); - const m = resolveOcPath(ast, { file: 'AGENTS.md', section: 'boundaries' }); - expect(m?.kind).toBe('block'); + const m = resolveOcPath(ast, { file: "AGENTS.md", section: "boundaries" }); + expect(m?.kind).toBe("block"); expect(emitMd(ast)).toBe(SAMPLE); }); - it('CC-02 OcPath round-trip via AST: parse + resolve + format', () => { + it("CC-02 OcPath round-trip via AST: parse + resolve + format", () => { const { ast } = parseMd(SAMPLE); for (const block of ast.blocks) { const path = parseOcPath(`oc://AGENTS.md/${block.slug}`); const m = resolveOcPath(ast, path); - expect(m?.kind, `block ${block.slug} should resolve`).toBe('block'); + expect(m?.kind, `block ${block.slug} should resolve`).toBe("block"); // Format the same path back; slug → URI shape should be stable. expect(formatOcPath(path)).toBe(`oc://AGENTS.md/${block.slug}`); } }); - it('CC-03 every item in every block is OcPath-addressable', () => { + it("CC-03 every item in every block is OcPath-addressable", () => { const { ast } = parseMd(SAMPLE); for (const block of ast.blocks) { for (const item of block.items) { const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}`); const m = resolveOcPath(ast, path); - expect(m?.kind, `${block.slug}/${item.slug} should resolve`).toBe('item'); + expect(m?.kind, `${block.slug}/${item.slug} should resolve`).toBe("item"); } } }); - it('CC-04 every kv item field is OcPath-addressable', () => { + it("CC-04 every kv item field is OcPath-addressable", () => { const { ast } = parseMd(SAMPLE); for (const block of ast.blocks) { for (const item of block.items) { - if (!item.kv) {continue;} - const path = parseOcPath( - `oc://AGENTS.md/${block.slug}/${item.slug}/${item.kv.key}`, - ); + if (!item.kv) { + continue; + } + const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}/${item.kv.key}`); const m = resolveOcPath(ast, path); - expect(m?.kind).toBe('item-field'); + expect(m?.kind).toBe("item-field"); } } }); - it('CC-05 every frontmatter entry is OcPath-addressable', () => { + it("CC-05 every frontmatter entry is OcPath-addressable", () => { const { ast } = parseMd(SAMPLE); for (const fm of ast.frontmatter) { const path = parseOcPath(`oc://AGENTS.md/[frontmatter]/${fm.key}`); const m = resolveOcPath(ast, path); - expect(m?.kind).toBe('frontmatter'); + expect(m?.kind).toBe("frontmatter"); } }); - it('CC-06 slugs are stable across re-parses (deterministic)', () => { + it("CC-06 slugs are stable across re-parses (deterministic)", () => { const a1 = parseMd(SAMPLE).ast; const a2 = parseMd(SAMPLE).ast; expect(a1.blocks.map((b) => b.slug)).toEqual(a2.blocks.map((b) => b.slug)); @@ -91,49 +91,49 @@ describe('wave-13 cross-cutting', () => { ); }); - it('CC-07 modifying raw + re-parse produces consistent AST shape', () => { + it("CC-07 modifying raw + re-parse produces consistent AST shape", () => { const a1 = parseMd(SAMPLE).ast; - const modified = SAMPLE.replace('GitHub CLI', 'GitHub command-line interface'); + const modified = SAMPLE.replace("GitHub CLI", "GitHub command-line interface"); const a2 = parseMd(modified).ast; // Block + item count + slugs unchanged. expect(a2.blocks.length).toBe(a1.blocks.length); - const a1Tools = a1.blocks.find((b) => b.slug === 'tools'); - const a2Tools = a2.blocks.find((b) => b.slug === 'tools'); + const a1Tools = a1.blocks.find((b) => b.slug === "tools"); + const a2Tools = a2.blocks.find((b) => b.slug === "tools"); expect(a2Tools?.items.length).toBe(a1Tools?.items.length); // KV value reflects the change. - const ghItem = a2Tools?.items.find((i) => i.kv?.key === 'gh'); - expect(ghItem?.kv?.value).toBe('GitHub command-line interface'); + const ghItem = a2Tools?.items.find((i) => i.kv?.key === "gh"); + expect(ghItem?.kv?.value).toBe("GitHub command-line interface"); }); - it('CC-08 unknown OcPath returns null without affecting subsequent valid resolves', () => { + it("CC-08 unknown OcPath returns null without affecting subsequent valid resolves", () => { const { ast } = parseMd(SAMPLE); - expect(resolveOcPath(ast, { file: 'X.md', section: 'nonexistent' })).toBeNull(); - expect(resolveOcPath(ast, { file: 'X.md', section: 'tools' })?.kind).toBe('block'); + expect(resolveOcPath(ast, { file: "X.md", section: "nonexistent" })).toBeNull(); + expect(resolveOcPath(ast, { file: "X.md", section: "tools" })?.kind).toBe("block"); }); - it('CC-09 resolve does not depend on file segment matching', () => { + it("CC-09 resolve does not depend on file segment matching", () => { const { ast } = parseMd(SAMPLE); - const a = resolveOcPath(ast, { file: 'A.md', section: 'tools' }); - const b = resolveOcPath(ast, { file: 'B.md', section: 'tools' }); + const a = resolveOcPath(ast, { file: "A.md", section: "tools" }); + const b = resolveOcPath(ast, { file: "B.md", section: "tools" }); expect(a?.kind).toBe(b?.kind); }); - it('CC-10 round-trip across all 9 valid OcPath shapes', () => { + it("CC-10 round-trip across all 9 valid OcPath shapes", () => { const { ast } = parseMd(SAMPLE); const cases = [ - { file: 'X.md' }, - { file: 'X.md', section: 'tools' }, - { file: 'X.md', section: 'tools', item: 'gh' }, - { file: 'X.md', section: 'tools', item: 'gh', field: 'gh' }, - { file: 'X.md', section: '[frontmatter]', field: 'name' }, - { file: 'X.md', section: 'boundaries' }, - { file: 'X.md', section: 'boundaries', item: 'never-write-to-etc' }, - { file: 'X.md', section: 'boundaries', item: 'always-confirm' }, - { file: 'X.md', section: '[frontmatter]', field: 'description' }, + { file: "X.md" }, + { file: "X.md", section: "tools" }, + { file: "X.md", section: "tools", item: "gh" }, + { file: "X.md", section: "tools", item: "gh", field: "gh" }, + { file: "X.md", section: "[frontmatter]", field: "name" }, + { file: "X.md", section: "boundaries" }, + { file: "X.md", section: "boundaries", item: "never-write-to-etc" }, + { file: "X.md", section: "boundaries", item: "always-confirm" }, + { file: "X.md", section: "[frontmatter]", field: "description" }, ]; for (const path of cases) { const m = resolveOcPath(ast, path); - expect(m, `failed for ${JSON.stringify(path)}`).not.toBeNull(); + expect(m, `failed for ${JSON.stringify(path)}`).toEqual(expect.any(Object)); } }); }); diff --git a/src/oc-path/tests/scenarios/cross-kind-properties.test.ts b/src/oc-path/tests/scenarios/cross-kind-properties.test.ts index e2622f4d6c0..4547d0e7dc8 100644 --- a/src/oc-path/tests/scenarios/cross-kind-properties.test.ts +++ b/src/oc-path/tests/scenarios/cross-kind-properties.test.ts @@ -11,82 +11,89 @@ * 5. parse → emit → parse is fixpoint * 6. hostile inputs do not throw at parse time */ -import { describe, expect, it } from 'vitest'; -import { inferKind } from '../../dispatch.js'; -import { emitMd } from '../../emit.js'; -import { setMdOcPath } from '../../edit.js'; -import { resolveMdOcPath } from '../../resolve.js'; -import { emitJsonc } from '../../jsonc/emit.js'; -import { setJsoncOcPath } from '../../jsonc/edit.js'; -import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { emitJsonl } from '../../jsonl/emit.js'; -import { setJsonlOcPath } from '../../jsonl/edit.js'; -import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { parseOcPath } from '../../oc-path.js'; -import { parseMd } from '../../parse.js'; +import { describe, expect, it } from "vitest"; +import { inferKind } from "../../dispatch.js"; +import { setMdOcPath } from "../../edit.js"; +import { emitMd } from "../../emit.js"; +import { setJsoncOcPath } from "../../jsonc/edit.js"; +import { emitJsonc } from "../../jsonc/emit.js"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { resolveJsoncOcPath } from "../../jsonc/resolve.js"; +import { setJsonlOcPath } from "../../jsonl/edit.js"; +import { emitJsonl } from "../../jsonl/emit.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { resolveJsonlOcPath } from "../../jsonl/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath } from "../../resolve.js"; -describe('wave-22 cross-kind property invariants', () => { - const mdRaw = '---\nname: x\n---\n\n## Boundaries\n\n- enabled: true\n'; +describe("wave-22 cross-kind property invariants", () => { + const mdRaw = "---\nname: x\n---\n\n## Boundaries\n\n- enabled: true\n"; const jsoncRaw = '// h\n{ "k": 1, "n": [1,2,3] }\n'; const jsonlRaw = '{"a":1}\n\nbroken\n{"b":2}\n'; - it('P-01 round-trip parse → emit is byte-stable across all kinds', () => { + it("P-01 round-trip parse → emit is byte-stable across all kinds", () => { expect(emitMd(parseMd(mdRaw).ast)).toBe(mdRaw); expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(jsoncRaw); expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(jsonlRaw); }); - it('P-02 resolve is non-mutating across all kinds', () => { + it("P-02 resolve is non-mutating across all kinds", () => { const md = parseMd(mdRaw).ast; let before = JSON.stringify(md); - resolveMdOcPath(md, parseOcPath('oc://X/[frontmatter]/name')); - resolveMdOcPath(md, parseOcPath('oc://X/boundaries')); + resolveMdOcPath(md, parseOcPath("oc://X/[frontmatter]/name")); + resolveMdOcPath(md, parseOcPath("oc://X/boundaries")); expect(JSON.stringify(md)).toBe(before); const jsonc = parseJsonc(jsoncRaw).ast; before = JSON.stringify(jsonc); - resolveJsoncOcPath(jsonc, parseOcPath('oc://X/k')); - resolveJsoncOcPath(jsonc, parseOcPath('oc://X/n.0')); + resolveJsoncOcPath(jsonc, parseOcPath("oc://X/k")); + resolveJsoncOcPath(jsonc, parseOcPath("oc://X/n.0")); expect(JSON.stringify(jsonc)).toBe(before); const jsonl = parseJsonl(jsonlRaw).ast; before = JSON.stringify(jsonl); - resolveJsonlOcPath(jsonl, parseOcPath('oc://X/L1')); - resolveJsonlOcPath(jsonl, parseOcPath('oc://X/$last')); + resolveJsonlOcPath(jsonl, parseOcPath("oc://X/L1")); + resolveJsonlOcPath(jsonl, parseOcPath("oc://X/$last")); expect(JSON.stringify(jsonl)).toBe(before); }); - it('P-03 unresolvable set never throws across all kinds', () => { - const ocPath = parseOcPath('oc://X/totally.missing.path'); - expect(() => - setMdOcPath(parseMd(mdRaw).ast, ocPath, 'x'), - ).not.toThrow(); - expect(() => + it("P-03 unresolvable set never throws across all kinds", () => { + const ocPath = parseOcPath("oc://X/totally.missing.path"); + expect(setMdOcPath(parseMd(mdRaw).ast, ocPath, "x")).toEqual({ + ok: false, + reason: "not-writable", + }); + expect( setJsoncOcPath(parseJsonc(jsoncRaw).ast, ocPath, { - kind: 'string', - value: 'x', + kind: "string", + value: "x", }), - ).not.toThrow(); - expect(() => + ).toEqual({ + ok: false, + reason: "unresolved", + }); + expect( setJsonlOcPath(parseJsonl(jsonlRaw).ast, ocPath, { - kind: 'string', - value: 'x', + kind: "string", + value: "x", }), - ).not.toThrow(); + ).toEqual({ + ok: false, + reason: "unresolved", + }); }); - it('P-04 inferKind aligns with the parser actually used', () => { - expect(inferKind('AGENTS.md')).toBe('md'); - expect(inferKind('SOUL.md')).toBe('md'); - expect(inferKind('config.jsonc')).toBe('jsonc'); - expect(inferKind('plugins.json')).toBe('jsonc'); - expect(inferKind('events.jsonl')).toBe('jsonl'); - expect(inferKind('audit.ndjson')).toBe('jsonl'); + it("P-04 inferKind aligns with the parser actually used", () => { + expect(inferKind("AGENTS.md")).toBe("md"); + expect(inferKind("SOUL.md")).toBe("md"); + expect(inferKind("config.jsonc")).toBe("jsonc"); + expect(inferKind("plugins.json")).toBe("jsonc"); + expect(inferKind("events.jsonl")).toBe("jsonl"); + expect(inferKind("audit.ndjson")).toBe("jsonl"); }); - it('P-05 parse → emit → parse is fixpoint across all kinds', () => { + it("P-05 parse → emit → parse is fixpoint across all kinds", () => { const md1 = emitMd(parseMd(mdRaw).ast); const md2 = emitMd(parseMd(md1).ast); expect(md1).toBe(md2); @@ -100,54 +107,52 @@ describe('wave-22 cross-kind property invariants', () => { expect(jl1).toBe(jl2); }); - it('P-06 hostile inputs do not throw at parse time across all kinds', () => { + it("P-06 hostile inputs do not throw at parse time across all kinds", () => { const hostile = [ - '\x00\x01\x02 binary garbage', + "\x00\x01\x02 binary garbage", '{ "unclosed":', - '## heading without anything', - '\n\n\n\n\n', + "## heading without anything", + "\n\n\n\n\n", ]; for (const raw of hostile) { - expect(() => parseMd(raw)).not.toThrow(); - expect(() => parseJsonc(raw)).not.toThrow(); - expect(() => parseJsonl(raw)).not.toThrow(); + expect(parseMd(raw).ast.raw).toBe(raw); + expect( + parseJsonc(raw).diagnostics.every((diagnostic) => diagnostic.severity === "error"), + ).toBe(true); + expect(parseJsonl(raw).ast.raw).toBe(raw); } }); - it('P-07 resolver returns null for paths past valid kinds (no throw)', () => { - const overlong = parseOcPath('oc://X/a/b/c.d.e.f.g.h'); - expect(() => resolveMdOcPath(parseMd(mdRaw).ast, overlong)).not.toThrow(); - expect(() => resolveJsoncOcPath(parseJsonc(jsoncRaw).ast, overlong)).not.toThrow(); - expect(() => resolveJsonlOcPath(parseJsonl(jsonlRaw).ast, overlong)).not.toThrow(); + it("P-07 resolver returns null for paths past valid kinds", () => { + const overlong = parseOcPath("oc://X/a/b/c.d.e.f.g.h"); + expect(resolveMdOcPath(parseMd(mdRaw).ast, overlong)).toBeNull(); + expect(resolveJsoncOcPath(parseJsonc(jsoncRaw).ast, overlong)).toBeNull(); + expect(resolveJsonlOcPath(parseJsonl(jsonlRaw).ast, overlong)).toBeNull(); }); - it('P-08 set-then-resolve produces the value just written (jsonc)', () => { + it("P-08 set-then-resolve produces the value just written (jsonc)", () => { const ast = parseJsonc('{ "k": 1 }').ast; - const r = setJsoncOcPath(ast, parseOcPath('oc://X/k'), { - kind: 'number', + const r = setJsoncOcPath(ast, parseOcPath("oc://X/k"), { + kind: "number", value: 42, }); if (r.ok) { - const m = resolveJsoncOcPath(r.ast, parseOcPath('oc://X/k')); - if (m?.kind === 'object-entry') { - expect(m.node.value).toEqual({ kind: 'number', value: 42 }); + const m = resolveJsoncOcPath(r.ast, parseOcPath("oc://X/k")); + if (m?.kind === "object-entry") { + expect(m.node.value).toEqual({ kind: "number", value: 42 }); } } }); - it('P-09 verbs are deterministic — same input twice produces same output', () => { + it("P-09 verbs are deterministic — same input twice produces same output", () => { expect(emitMd(parseMd(mdRaw).ast)).toBe(emitMd(parseMd(mdRaw).ast)); - expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe( - emitJsonc(parseJsonc(jsoncRaw).ast), - ); - expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe( - emitJsonl(parseJsonl(jsonlRaw).ast), - ); + expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(emitJsonc(parseJsonc(jsoncRaw).ast)); + expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(emitJsonl(parseJsonl(jsonlRaw).ast)); }); - it('P-10 inferKind returns null for unknown extensions', () => { - expect(inferKind('binary.bin')).toBeNull(); - expect(inferKind('no-ext')).toBeNull(); - expect(inferKind('archive.tar.gz')).toBeNull(); + it("P-10 inferKind returns null for unknown extensions", () => { + expect(inferKind("binary.bin")).toBeNull(); + expect(inferKind("no-ext")).toBeNull(); + expect(inferKind("archive.tar.gz")).toBeNull(); }); }); diff --git a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts index fb085e8b052..8183bada2fc 100644 --- a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts +++ b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts @@ -2,139 +2,139 @@ * Wave 2 — frontmatter edges. * * Substrate guarantee: frontmatter is parsed as `key: value` entries - * with quote-stripping; malformed frontmatter doesn't crash the parser - * (soft-error policy: emit diagnostic, recover). + * with quote-stripping; malformed frontmatter follows the soft-error + * policy by emitting diagnostics and recovering. */ -import { describe, expect, it } from 'vitest'; -import { parseMd } from '../../parse.js'; +import { describe, expect, it } from "vitest"; +import { parseMd } from "../../parse.js"; -describe('wave-02 frontmatter-edges', () => { - it('FM-01 simple kv pairs', () => { - const { ast } = parseMd('---\nname: x\ndescription: y\n---\n'); +describe("wave-02 frontmatter-edges", () => { + it("FM-01 simple kv pairs", () => { + const { ast } = parseMd("---\nname: x\ndescription: y\n---\n"); expect(ast.frontmatter.map((e) => [e.key, e.value])).toEqual([ - ['name', 'x'], - ['description', 'y'], + ["name", "x"], + ["description", "y"], ]); }); - it('FM-02 unclosed frontmatter emits diagnostic, treats as preamble', () => { - const { ast, diagnostics } = parseMd('---\nname: x\nno close fence\nbody\n'); - expect(diagnostics.some((d) => d.code === 'OC_FRONTMATTER_UNCLOSED')).toBe(true); + it("FM-02 unclosed frontmatter emits diagnostic, treats as preamble", () => { + const { ast, diagnostics } = parseMd("---\nname: x\nno close fence\nbody\n"); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_FRONTMATTER_UNCLOSED"); expect(ast.frontmatter).toEqual([]); }); - it('FM-03 empty frontmatter (just open + close)', () => { - const { ast } = parseMd('---\n---\n'); + it("FM-03 empty frontmatter (just open + close)", () => { + const { ast } = parseMd("---\n---\n"); expect(ast.frontmatter).toEqual([]); }); - it('FM-04 frontmatter only, file has no other content', () => { - const { ast } = parseMd('---\nk: v\n---\n'); - expect(ast.frontmatter).toEqual([{ key: 'k', value: 'v', line: 2 }]); - expect(ast.preamble).toBe(''); + it("FM-04 frontmatter only, file has no other content", () => { + const { ast } = parseMd("---\nk: v\n---\n"); + expect(ast.frontmatter).toEqual([{ key: "k", value: "v", line: 2 }]); + expect(ast.preamble).toBe(""); expect(ast.blocks).toEqual([]); }); - it('FM-05 double-quoted value', () => { + it("FM-05 double-quoted value", () => { const { ast } = parseMd('---\ntitle: "Hello, world"\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('Hello, world'); + expect(ast.frontmatter[0]?.value).toBe("Hello, world"); }); - it('FM-06 single-quoted value', () => { + it("FM-06 single-quoted value", () => { const { ast } = parseMd("---\ntitle: 'Hello, world'\n---\n"); - expect(ast.frontmatter[0]?.value).toBe('Hello, world'); + expect(ast.frontmatter[0]?.value).toBe("Hello, world"); }); - it('FM-07 unquoted value with internal colons preserved', () => { - const { ast } = parseMd('---\nurl: https://example.com:443/p\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('https://example.com:443/p'); + it("FM-07 unquoted value with internal colons preserved", () => { + const { ast } = parseMd("---\nurl: https://example.com:443/p\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("https://example.com:443/p"); }); - it('FM-08 empty value', () => { - const { ast } = parseMd('---\nk:\n---\n'); - expect(ast.frontmatter[0]).toEqual({ key: 'k', value: '', line: 2 }); + it("FM-08 empty value", () => { + const { ast } = parseMd("---\nk:\n---\n"); + expect(ast.frontmatter[0]).toEqual({ key: "k", value: "", line: 2 }); }); - it('FM-09 value with leading/trailing whitespace trimmed', () => { - const { ast } = parseMd('---\nk: spaced \n---\n'); - expect(ast.frontmatter[0]?.value).toBe('spaced'); + it("FM-09 value with leading/trailing whitespace trimmed", () => { + const { ast } = parseMd("---\nk: spaced \n---\n"); + expect(ast.frontmatter[0]?.value).toBe("spaced"); }); - it('FM-10 list-style continuations are silently dropped (substrate stays opinion-free)', () => { - const { ast } = parseMd('---\ntools:\n - gh\n - curl\n---\n'); + it("FM-10 list-style continuations are silently dropped (substrate stays opinion-free)", () => { + const { ast } = parseMd("---\ntools:\n - gh\n - curl\n---\n"); // The `tools:` key has an empty inline value; the list continuation // lines ` - gh` and ` - curl` don't match the kv regex and are // skipped. Lint rules can do their own structural reading of // frontmatter; the substrate does not. - expect(ast.frontmatter.map((e) => e.key)).toEqual(['tools']); - expect(ast.frontmatter[0]?.value).toBe(''); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["tools"]); + expect(ast.frontmatter[0]?.value).toBe(""); }); - it('FM-11 line numbers are 1-based and accurate', () => { - const { ast } = parseMd('---\nk1: v1\nk2: v2\nk3: v3\n---\n'); + it("FM-11 line numbers are 1-based and accurate", () => { + const { ast } = parseMd("---\nk1: v1\nk2: v2\nk3: v3\n---\n"); expect(ast.frontmatter.map((e) => [e.key, e.line])).toEqual([ - ['k1', 2], - ['k2', 3], - ['k3', 4], + ["k1", 2], + ["k2", 3], + ["k3", 4], ]); }); - it('FM-12 dash-key allowed', () => { - const { ast } = parseMd('---\nuser-invocable: true\n---\n'); - expect(ast.frontmatter[0]?.key).toBe('user-invocable'); + it("FM-12 dash-key allowed", () => { + const { ast } = parseMd("---\nuser-invocable: true\n---\n"); + expect(ast.frontmatter[0]?.key).toBe("user-invocable"); }); - it('FM-13 underscore-key allowed', () => { - const { ast } = parseMd('---\nparam_set: foo\n---\n'); - expect(ast.frontmatter[0]?.key).toBe('param_set'); + it("FM-13 underscore-key allowed", () => { + const { ast } = parseMd("---\nparam_set: foo\n---\n"); + expect(ast.frontmatter[0]?.key).toBe("param_set"); }); - it('FM-14 number-only value preserved as string', () => { - const { ast } = parseMd('---\ntimeout: 15000\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('15000'); + it("FM-14 number-only value preserved as string", () => { + const { ast } = parseMd("---\ntimeout: 15000\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("15000"); }); - it('FM-15 boolean-like value preserved as string', () => { - const { ast } = parseMd('---\nenabled: true\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('true'); + it("FM-15 boolean-like value preserved as string", () => { + const { ast } = parseMd("---\nenabled: true\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("true"); }); - it('FM-16 blank lines inside frontmatter are skipped', () => { - const { ast } = parseMd('---\n\nk1: v1\n\nk2: v2\n\n---\n'); - expect(ast.frontmatter.map((e) => e.key)).toEqual(['k1', 'k2']); + it("FM-16 blank lines inside frontmatter are skipped", () => { + const { ast } = parseMd("---\n\nk1: v1\n\nk2: v2\n\n---\n"); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["k1", "k2"]); }); - it('FM-17 frontmatter with same key twice — both retained (no dedup)', () => { + it("FM-17 frontmatter with same key twice — both retained (no dedup)", () => { // Substrate doesn't dedup; lint rules can flag duplicates if needed. - const { ast } = parseMd('---\nk: v1\nk: v2\n---\n'); + const { ast } = parseMd("---\nk: v1\nk: v2\n---\n"); expect(ast.frontmatter).toEqual([ - { key: 'k', value: 'v1', line: 2 }, - { key: 'k', value: 'v2', line: 3 }, + { key: "k", value: "v1", line: 2 }, + { key: "k", value: "v2", line: 3 }, ]); }); - it('FM-18 frontmatter must be at start — leading blank line breaks detection', () => { - const { ast } = parseMd('\n---\nk: v\n---\n'); + it("FM-18 frontmatter must be at start — leading blank line breaks detection", () => { + const { ast } = parseMd("\n---\nk: v\n---\n"); expect(ast.frontmatter).toEqual([]); }); - it('FM-19 frontmatter must be at start — leading text breaks detection', () => { - const { ast } = parseMd('intro\n\n---\nk: v\n---\n'); + it("FM-19 frontmatter must be at start — leading text breaks detection", () => { + const { ast } = parseMd("intro\n\n---\nk: v\n---\n"); expect(ast.frontmatter).toEqual([]); }); - it('FM-20 BOM before frontmatter open is tolerated', () => { - const { ast } = parseMd('---\nname: bom\n---\n'); - expect(ast.frontmatter[0]?.value).toBe('bom'); + it("FM-20 BOM before frontmatter open is tolerated", () => { + const { ast } = parseMd("---\nname: bom\n---\n"); + expect(ast.frontmatter[0]?.value).toBe("bom"); }); - it('FM-21 single-line file with `---` and `---` is empty frontmatter', () => { - const { ast } = parseMd('---\n---'); + it("FM-21 single-line file with `---` and `---` is empty frontmatter", () => { + const { ast } = parseMd("---\n---"); expect(ast.frontmatter).toEqual([]); }); - it('FM-22 hash-prefixed lines skipped (not yaml comments — just don\'t match kv regex)', () => { - const { ast } = parseMd('---\n# comment\nk: v\n---\n'); - expect(ast.frontmatter.map((e) => e.key)).toEqual(['k']); + it("FM-22 hash-prefixed lines skipped (not yaml comments — just don't match kv regex)", () => { + const { ast } = parseMd("---\n# comment\nk: v\n---\n"); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["k"]); }); }); diff --git a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts index 36229ee290e..f4c270b12de 100644 --- a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts +++ b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts @@ -17,10 +17,10 @@ * called it a day. JC-17 deliberately uses `assertNotParseable` — * malformed input must echo `raw` AND emit a diagnostic. */ -import { describe, expect, it } from 'vitest'; -import { emitJsonc } from '../../jsonc/emit.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import type { JsoncValue } from '../../jsonc/ast.js'; +import { describe, expect, it } from "vitest"; +import type { JsoncValue } from "../../jsonc/ast.js"; +import { emitJsonc } from "../../jsonc/emit.js"; +import { parseJsonc } from "../../jsonc/parse.js"; function rt(raw: string): string { return emitJsonc(parseJsonc(raw).ast); @@ -34,8 +34,11 @@ function rt(raw: string): string { */ function assertParseable(raw: string): JsoncValue { const result = parseJsonc(raw); - expect(result.ast.root).not.toBeNull(); - return result.ast.root as JsoncValue; + expect(result.ast.root).toEqual(expect.any(Object)); + if (result.ast.root === null) { + throw new Error("Expected parseable JSONC root"); + } + return result.ast.root; } /** @@ -47,131 +50,141 @@ function assertParseable(raw: string): JsoncValue { function assertNotParseable(raw: string): void { const result = parseJsonc(raw); expect(result.ast.root).toBeNull(); - expect(result.diagnostics.some((d) => d.severity === 'error')).toBe(true); + expect(result.diagnostics.map((diagnostic) => diagnostic.severity)).toContain("error"); } -describe('wave-15 jsonc byte-fidelity', () => { - it('JC-01 empty file', () => { - expect(rt('')).toBe(''); +describe("wave-15 jsonc byte-fidelity", () => { + it("JC-01 empty file", () => { + expect(rt("")).toBe(""); }); - it('JC-02 whitespace-only', () => { - expect(rt(' \n\n \n')).toBe(' \n\n \n'); + it("JC-02 whitespace-only", () => { + expect(rt(" \n\n \n")).toBe(" \n\n \n"); }); - it('JC-03 empty object', () => { - expect(rt('{}')).toBe('{}'); - const root = assertParseable('{}'); - expect(root.kind).toBe('object'); - if (root.kind === 'object') {expect(root.entries).toHaveLength(0);} + it("JC-03 empty object", () => { + expect(rt("{}")).toBe("{}"); + const root = assertParseable("{}"); + expect(root.kind).toBe("object"); + if (root.kind === "object") { + expect(root.entries).toHaveLength(0); + } }); - it('JC-04 empty array', () => { - expect(rt('[]')).toBe('[]'); - const root = assertParseable('[]'); - expect(root.kind).toBe('array'); - if (root.kind === 'array') {expect(root.items).toHaveLength(0);} + it("JC-04 empty array", () => { + expect(rt("[]")).toBe("[]"); + const root = assertParseable("[]"); + expect(root.kind).toBe("array"); + if (root.kind === "array") { + expect(root.items).toHaveLength(0); + } }); - it('JC-05 trivial scalar root', () => { - expect(rt('42')).toBe('42'); + it("JC-05 trivial scalar root", () => { + expect(rt("42")).toBe("42"); expect(rt('"x"')).toBe('"x"'); - expect(rt('true')).toBe('true'); - expect(rt('null')).toBe('null'); - expect(assertParseable('42').kind).toBe('number'); - expect(assertParseable('"x"').kind).toBe('string'); - expect(assertParseable('true').kind).toBe('boolean'); - expect(assertParseable('null').kind).toBe('null'); + expect(rt("true")).toBe("true"); + expect(rt("null")).toBe("null"); + expect(assertParseable("42").kind).toBe("number"); + expect(assertParseable('"x"').kind).toBe("string"); + expect(assertParseable("true").kind).toBe("boolean"); + expect(assertParseable("null").kind).toBe("null"); }); - it('JC-06 line comments preserved', () => { + it("JC-06 line comments preserved", () => { const raw = '// a leading comment\n{ "x": 1 } // trailing\n'; expect(rt(raw)).toBe(raw); // Pin parse: the structural value `x: 1` is reachable. const root = assertParseable(raw); - expect(root.kind).toBe('object'); + expect(root.kind).toBe("object"); }); - it('JC-07 block comments preserved', () => { + it("JC-07 block comments preserved", () => { const raw = '/* header */\n{\n /* inline */\n "x": 1\n}\n'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - expect(root.kind).toBe('object'); + expect(root.kind).toBe("object"); }); - it('JC-08 trailing commas preserved', () => { + it("JC-08 trailing commas preserved", () => { const raw = '{\n "x": 1,\n "y": 2,\n}'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'object') {expect(root.entries).toHaveLength(2);} + if (root.kind === "object") { + expect(root.entries).toHaveLength(2); + } }); - it('JC-09 mixed CRLF + LF preserved', () => { + it("JC-09 mixed CRLF + LF preserved", () => { const raw = '{\r\n "x": 1,\n "y": 2\r\n}'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'object') {expect(root.entries.map((e) => e.key)).toEqual(['x', 'y']);} + if (root.kind === "object") { + expect(root.entries.map((e) => e.key)).toEqual(["x", "y"]); + } }); - it('JC-10 BOM preserved on raw', () => { + it("JC-10 BOM preserved on raw", () => { const raw = '{ "x": 1 }'; expect(rt(raw)).toBe(raw); // BOM stripped before parsing — parser still sees `{` as first char. - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-11 deeply nested structures preserved', () => { + it("JC-11 deeply nested structures preserved", () => { const raw = '{ "a": { "b": { "c": { "d": [1, [2, [3, [4]]]] } } } }'; expect(rt(raw)).toBe(raw); - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-12 string with escape sequences preserved', () => { + it("JC-12 string with escape sequences preserved", () => { const raw = '{ "s": "a\\nb\\tc\\u0041\\\\d\\"e" }'; expect(rt(raw)).toBe(raw); // Pin escape resolution — parsed value carries actual control chars. const root = assertParseable(raw); - if (root.kind === 'object') { + if (root.kind === "object") { const s = root.entries[0]?.value; - if (s?.kind === 'string') { + if (s?.kind === "string") { expect(s.value).toBe('a\nb\tcA\\d"e'); } } }); - it('JC-13 numbers in scientific / negative / decimal forms preserved', () => { - const raw = '[ 0, -0, 1.5, -3.14, 1e3, -2.5e-10, 1E+5 ]'; + it("JC-13 numbers in scientific / negative / decimal forms preserved", () => { + const raw = "[ 0, -0, 1.5, -3.14, 1e3, -2.5e-10, 1E+5 ]"; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'array') { + if (root.kind === "array") { expect(root.items).toHaveLength(7); - expect(root.items.every((v) => v.kind === 'number')).toBe(true); + expect(root.items.every((item) => item.kind === "number")).toBe(true); } }); - it('JC-14 unicode characters preserved verbatim', () => { + it("JC-14 unicode characters preserved verbatim", () => { const raw = '{ "name": "héllo 世界 🎉" }'; expect(rt(raw)).toBe(raw); const root = assertParseable(raw); - if (root.kind === 'object') { + if (root.kind === "object") { const v = root.entries[0]?.value; - if (v?.kind === 'string') {expect(v.value).toBe('héllo 世界 🎉');} + if (v?.kind === "string") { + expect(v.value).toBe("héllo 世界 🎉"); + } } }); - it('JC-15 idiosyncratic whitespace preserved', () => { + it("JC-15 idiosyncratic whitespace preserved", () => { const raw = '{ "x" : 1 ,\n "y": 2}'; expect(rt(raw)).toBe(raw); - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-16 file-level trailing whitespace preserved', () => { + it("JC-16 file-level trailing whitespace preserved", () => { const raw = '{ "x": 1 }\n\n\n'; expect(rt(raw)).toBe(raw); - expect(assertParseable(raw).kind).toBe('object'); + expect(assertParseable(raw).kind).toBe("object"); }); - it('JC-17 malformed input still emits raw verbatim AND emits a diagnostic', () => { + it("JC-17 malformed input still emits raw verbatim AND emits a diagnostic", () => { const raw = '{ broken json with "key": value }'; expect(rt(raw)).toBe(raw); // Without this assertion the test passes for any input regardless @@ -179,8 +192,8 @@ describe('wave-15 jsonc byte-fidelity', () => { assertNotParseable(raw); }); - it('JC-18 comments-only file preserved', () => { - const raw = '// just a comment\n/* and a block */\n'; + it("JC-18 comments-only file preserved", () => { + const raw = "// just a comment\n/* and a block */\n"; expect(rt(raw)).toBe(raw); // Comments-only files have no structural root — that's expected. expect(parseJsonc(raw).ast.root).toBeNull(); diff --git a/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts b/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts index 06001ddcb98..d9cc8cece70 100644 --- a/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts +++ b/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts @@ -5,128 +5,136 @@ * with mixed dotted / segment paths, returns null on any unresolvable * walk, and never throws on hostile inputs. */ -import { describe, expect, it } from 'vitest'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; -import { parseOcPath } from '../../oc-path.js'; +import { describe, expect, it } from "vitest"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { resolveJsoncOcPath } from "../../jsonc/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; function rs(raw: string, ocPath: string) { return resolveJsoncOcPath(parseJsonc(raw).ast, parseOcPath(ocPath)); } -describe('wave-17 jsonc resolver edges', () => { - it('JR-01 root resolves on empty object', () => { - expect(rs('{}', 'oc://config')?.kind).toBe('root'); +describe("wave-17 jsonc resolver edges", () => { + it("JR-01 root resolves on empty object", () => { + expect(rs("{}", "oc://config")?.kind).toBe("root"); }); - it('JR-02 root resolves on scalar root', () => { - expect(rs('42', 'oc://config')?.kind).toBe('root'); + it("JR-02 root resolves on scalar root", () => { + expect(rs("42", "oc://config")?.kind).toBe("root"); }); - it('JR-03 root resolves on array root', () => { - expect(rs('[1,2,3]', 'oc://config')?.kind).toBe('root'); + it("JR-03 root resolves on array root", () => { + expect(rs("[1,2,3]", "oc://config")?.kind).toBe("root"); }); - it('JR-04 deep dotted descent within section', () => { - const m = rs('{"a":{"b":{"c":1}}}', 'oc://config/a.b.c'); - expect(m?.kind).toBe('object-entry'); + it("JR-04 deep dotted descent within section", () => { + const m = rs('{"a":{"b":{"c":1}}}', "oc://config/a.b.c"); + expect(m?.kind).toBe("object-entry"); }); - it('JR-05 missing intermediate key returns null', () => { - expect(rs('{"a":{"b":1}}', 'oc://config/a.x.b')).toBeNull(); + it("JR-05 missing intermediate key returns null", () => { + expect(rs('{"a":{"b":1}}', "oc://config/a.x.b")).toBeNull(); }); - it('JR-06 numeric segment indexes into array', () => { - const m = rs('{"items":["a","b","c"]}', 'oc://config/items.1'); - expect(m?.kind).toBe('value'); - if (m?.kind === 'value') { - expect(m.node).toMatchObject({ kind: 'string', value: 'b' }); + it("JR-06 numeric segment indexes into array", () => { + const m = rs('{"items":["a","b","c"]}', "oc://config/items.1"); + expect(m?.kind).toBe("value"); + if (m?.kind === "value") { + expect(m.node).toMatchObject({ kind: "string", value: "b" }); } }); - it('JR-07 negative array index resolves to Nth-from-last', () => { - expect(rs('{"x":[1,2]}', 'oc://config/x.-1')).toMatchObject({ kind: 'value', node: { kind: 'number', value: 2 } }); - expect(rs('{"x":[1,2]}', 'oc://config/x.-2')).toMatchObject({ kind: 'value', node: { kind: 'number', value: 1 } }); - expect(rs('{"x":[1,2]}', 'oc://config/x.-5')).toBeNull(); + it("JR-07 negative array index resolves to Nth-from-last", () => { + expect(rs('{"x":[1,2]}', "oc://config/x.-1")).toMatchObject({ + kind: "value", + node: { kind: "number", value: 2 }, + }); + expect(rs('{"x":[1,2]}', "oc://config/x.-2")).toMatchObject({ + kind: "value", + node: { kind: "number", value: 1 }, + }); + expect(rs('{"x":[1,2]}', "oc://config/x.-5")).toBeNull(); }); - it('JR-08 out-of-bounds array index returns null', () => { - expect(rs('{"x":[1,2]}', 'oc://config/x.99')).toBeNull(); + it("JR-08 out-of-bounds array index returns null", () => { + expect(rs('{"x":[1,2]}', "oc://config/x.99")).toBeNull(); }); - it('JR-09 non-integer index returns null (no NaN coercion)', () => { - expect(rs('{"x":[1,2]}', 'oc://config/x.foo')).toBeNull(); + it("JR-09 non-integer index returns null (no NaN coercion)", () => { + expect(rs('{"x":[1,2]}', "oc://config/x.foo")).toBeNull(); }); - it('JR-10 null AST root returns null on any path', () => { - expect(rs('', 'oc://config/x')).toBeNull(); + it("JR-10 null AST root returns null on any path", () => { + expect(rs("", "oc://config/x")).toBeNull(); }); - it('JR-11 descending past a primitive returns null', () => { - expect(rs('{"x":42}', 'oc://config/x.y')).toBeNull(); + it("JR-11 descending past a primitive returns null", () => { + expect(rs('{"x":42}', "oc://config/x.y")).toBeNull(); }); - it('JR-12 empty segment in dotted path throws OcPathError', () => { + it("JR-12 empty segment in dotted path throws OcPathError", () => { // v1 invariant: malformed paths fail loud at parse time, not silently null. - expect(() => rs('{"x":1}', 'oc://config/x..y')).toThrow(/Empty dotted sub-segment/); + expect(() => rs('{"x":1}', "oc://config/x..y")).toThrow(/Empty dotted sub-segment/); }); - it('JR-13 string value at leaf surfaces via object-entry shape', () => { - const m = rs('{"k":"v"}', 'oc://config/k'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') {expect(m.node.key).toBe('k');} + it("JR-13 string value at leaf surfaces via object-entry shape", () => { + const m = rs('{"k":"v"}', "oc://config/k"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.key).toBe("k"); + } }); - it('JR-14 boolean and null values resolve', () => { - const m1 = rs('{"k":true}', 'oc://config/k'); - expect(m1?.kind).toBe('object-entry'); - const m2 = rs('{"k":null}', 'oc://config/k'); - expect(m2?.kind).toBe('object-entry'); + it("JR-14 boolean and null values resolve", () => { + const m1 = rs('{"k":true}', "oc://config/k"); + expect(m1?.kind).toBe("object-entry"); + const m2 = rs('{"k":null}', "oc://config/k"); + expect(m2?.kind).toBe("object-entry"); }); - it('JR-15 mixed slash + dot segments resolve identically', () => { - const a = rs('{"a":{"b":{"c":1}}}', 'oc://config/a.b.c'); - const b = rs('{"a":{"b":{"c":1}}}', 'oc://config/a/b.c'); - const c = rs('{"a":{"b":{"c":1}}}', 'oc://config/a/b/c'); + it("JR-15 mixed slash + dot segments resolve identically", () => { + const a = rs('{"a":{"b":{"c":1}}}', "oc://config/a.b.c"); + const b = rs('{"a":{"b":{"c":1}}}', "oc://config/a/b.c"); + const c = rs('{"a":{"b":{"c":1}}}', "oc://config/a/b/c"); expect(a?.kind).toBe(b?.kind); expect(b?.kind).toBe(c?.kind); }); - it('JR-16 keys with special characters resolve', () => { - const m = rs('{"a-b_c":{"x":1}}', 'oc://config/a-b_c.x'); - expect(m?.kind).toBe('object-entry'); + it("JR-16 keys with special characters resolve", () => { + const m = rs('{"a-b_c":{"x":1}}', "oc://config/a-b_c.x"); + expect(m?.kind).toBe("object-entry"); }); - it('JR-17 unicode keys resolve', () => { - const m = rs('{"héllo":1}', 'oc://config/héllo'); - expect(m?.kind).toBe('object-entry'); + it("JR-17 unicode keys resolve", () => { + const m = rs('{"héllo":1}', "oc://config/héllo"); + expect(m?.kind).toBe("object-entry"); }); - it('JR-18 large nested structure (depth 20) resolves to leaf', () => { + it("JR-18 large nested structure (depth 20) resolves to leaf", () => { let json = '"leaf"'; const segs: string[] = []; for (let i = 19; i >= 0; i--) { json = `{"k${i}":${json}}`; segs.unshift(`k${i}`); } - const m = rs(json, `oc://config/${segs.join('.')}`); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'leaf' }); + const m = rs(json, `oc://config/${segs.join(".")}`); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "leaf" }); } }); - it('JR-19 resolver is non-mutating across calls', () => { + it("JR-19 resolver is non-mutating across calls", () => { const { ast } = parseJsonc('{"x":{"y":1}}'); const before = JSON.stringify(ast); - rs('{"x":{"y":1}}', 'oc://config/x.y'); - rs('{"x":{"y":1}}', 'oc://config/x'); - rs('{"x":{"y":1}}', 'oc://config/missing'); + rs('{"x":{"y":1}}', "oc://config/x.y"); + rs('{"x":{"y":1}}', "oc://config/x"); + rs('{"x":{"y":1}}', "oc://config/missing"); expect(JSON.stringify(ast)).toBe(before); }); - it('JR-20 hostile input shapes do not throw', () => { - expect(() => rs('{garbage}', 'oc://config/x')).not.toThrow(); - expect(() => rs('{"a":', 'oc://config/a')).not.toThrow(); + it("JR-20 hostile input shapes do not throw", () => { + expect(rs("{garbage}", "oc://config/x")).toBeNull(); + expect(rs('{"a":', "oc://config/a")).toBeNull(); }); }); diff --git a/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts b/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts index edecb2cbb03..ef38bc8b51e 100644 --- a/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts +++ b/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts @@ -5,121 +5,125 @@ * deterministically; missing addresses, blank-line targets, and * malformed-line targets all surface as null without throwing. */ -import { describe, expect, it } from 'vitest'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; -import { parseOcPath } from '../../oc-path.js'; +import { describe, expect, it } from "vitest"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { resolveJsonlOcPath } from "../../jsonl/resolve.js"; +import { parseOcPath } from "../../oc-path.js"; function rs(raw: string, ocPath: string) { return resolveJsonlOcPath(parseJsonl(raw).ast, parseOcPath(ocPath)); } -describe('wave-18 jsonl resolver edges', () => { - it('JLR-01 root resolves with no segments', () => { - expect(rs('{"a":1}\n', 'oc://log')?.kind).toBe('root'); +describe("wave-18 jsonl resolver edges", () => { + it("JLR-01 root resolves with no segments", () => { + expect(rs('{"a":1}\n', "oc://log")?.kind).toBe("root"); }); - it('JLR-02 L1 resolves to a value line', () => { - const m = rs('{"a":1}\n', 'oc://log/L1'); - expect(m?.kind).toBe('line'); + it("JLR-02 L1 resolves to a value line", () => { + const m = rs('{"a":1}\n', "oc://log/L1"); + expect(m?.kind).toBe("line"); }); - it('JLR-03 L99 unknown line returns null', () => { - expect(rs('{"a":1}\n', 'oc://log/L99')).toBeNull(); + it("JLR-03 L99 unknown line returns null", () => { + expect(rs('{"a":1}\n', "oc://log/L99")).toBeNull(); }); - it('JLR-04 $last picks the most recent value line', () => { - const m = rs('{"a":1}\n{"a":2}\n{"a":3}\n', 'oc://log/$last/a'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 3 }); + it("JLR-04 $last picks the most recent value line", () => { + const m = rs('{"a":1}\n{"a":2}\n{"a":3}\n', "oc://log/$last/a"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 3 }); } }); - it('JLR-05 $last skips trailing blank lines', () => { - const m = rs('{"a":1}\n\n\n', 'oc://log/$last/a'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 1 }); + it("JLR-05 $last skips trailing blank lines", () => { + const m = rs('{"a":1}\n\n\n', "oc://log/$last/a"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 1 }); } }); - it('JLR-06 $last skips trailing malformed lines', () => { - const m = rs('{"a":1}\nbroken\n', 'oc://log/$last/a'); - expect(m?.kind).toBe('object-entry'); + it("JLR-06 $last skips trailing malformed lines", () => { + const m = rs('{"a":1}\nbroken\n', "oc://log/$last/a"); + expect(m?.kind).toBe("object-entry"); }); - it('JLR-07 $last on empty file returns null', () => { - expect(rs('', 'oc://log/$last/x')).toBeNull(); + it("JLR-07 $last on empty file returns null", () => { + expect(rs("", "oc://log/$last/x")).toBeNull(); }); - it('JLR-08 $last on all-blank file returns null', () => { - expect(rs('\n\n\n', 'oc://log/$last/x')).toBeNull(); + it("JLR-08 $last on all-blank file returns null", () => { + expect(rs("\n\n\n", "oc://log/$last/x")).toBeNull(); }); - it('JLR-09 $last on all-malformed file returns null', () => { - expect(rs('a\nb\nc\n', 'oc://log/$last/x')).toBeNull(); + it("JLR-09 $last on all-malformed file returns null", () => { + expect(rs("a\nb\nc\n", "oc://log/$last/x")).toBeNull(); }); - it('JLR-10 garbage line address returns null', () => { - expect(rs('{"a":1}\n', 'oc://log/garbage')).toBeNull(); - expect(rs('{"a":1}\n', 'oc://log/L')).toBeNull(); - expect(rs('{"a":1}\n', 'oc://log/Labc')).toBeNull(); + it("JLR-10 garbage line address returns null", () => { + expect(rs('{"a":1}\n', "oc://log/garbage")).toBeNull(); + expect(rs('{"a":1}\n', "oc://log/L")).toBeNull(); + expect(rs('{"a":1}\n', "oc://log/Labc")).toBeNull(); }); - it('JLR-11 descent into a blank line returns null', () => { - expect(rs('{"a":1}\n\n{"b":2}\n', 'oc://log/L2/anything')).toBeNull(); + it("JLR-11 descent into a blank line returns null", () => { + expect(rs('{"a":1}\n\n{"b":2}\n', "oc://log/L2/anything")).toBeNull(); }); - it('JLR-12 descent into a malformed line returns null', () => { - expect(rs('{"a":1}\nbroken\n{"b":2}\n', 'oc://log/L2/anything')).toBeNull(); + it("JLR-12 descent into a malformed line returns null", () => { + expect(rs('{"a":1}\nbroken\n{"b":2}\n', "oc://log/L2/anything")).toBeNull(); }); - it('JLR-13 missing field on a value line returns null', () => { - expect(rs('{"a":1}\n', 'oc://log/L1/missing')).toBeNull(); + it("JLR-13 missing field on a value line returns null", () => { + expect(rs('{"a":1}\n', "oc://log/L1/missing")).toBeNull(); }); - it('JLR-14 dotted descent through line value resolves', () => { - const m = rs('{"r":{"ok":true,"d":"x"}}\n', 'oc://log/L1/r.d'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'string', value: 'x' }); + it("JLR-14 dotted descent through line value resolves", () => { + const m = rs('{"r":{"ok":true,"d":"x"}}\n', "oc://log/L1/r.d"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "string", value: "x" }); } }); - it('JLR-15 array index inside a line resolves', () => { - const m = rs('{"items":["a","b","c"]}\n', 'oc://log/L1/items.2'); - expect(m?.kind).toBe('value'); - if (m?.kind === 'value') { - expect(m.node).toMatchObject({ kind: 'string', value: 'c' }); + it("JLR-15 array index inside a line resolves", () => { + const m = rs('{"items":["a","b","c"]}\n', "oc://log/L1/items.2"); + expect(m?.kind).toBe("value"); + if (m?.kind === "value") { + expect(m.node).toMatchObject({ kind: "string", value: "c" }); } }); - it('JLR-16 line numbers are 1-indexed', () => { - const m = rs('{"a":1}\n{"a":2}\n', 'oc://log/L1/a'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 1 }); + it("JLR-16 line numbers are 1-indexed", () => { + const m = rs('{"a":1}\n{"a":2}\n', "oc://log/L1/a"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 1 }); } }); - it('JLR-17 line numbers preserved across blank/malformed entries', () => { - const m = rs('{"a":1}\n\nbroken\n{"a":4}\n', 'oc://log/L4/a'); - expect(m?.kind).toBe('object-entry'); - if (m?.kind === 'object-entry') { - expect(m.node.value).toMatchObject({ kind: 'number', value: 4 }); + it("JLR-17 line numbers preserved across blank/malformed entries", () => { + const m = rs('{"a":1}\n\nbroken\n{"a":4}\n', "oc://log/L4/a"); + expect(m?.kind).toBe("object-entry"); + if (m?.kind === "object-entry") { + expect(m.node.value).toMatchObject({ kind: "number", value: 4 }); } }); - it('JLR-18 resolver is non-mutating', () => { + it("JLR-18 resolver is non-mutating", () => { const { ast } = parseJsonl('{"a":1}\n{"b":2}\n'); const before = JSON.stringify(ast); - rs('{"a":1}\n{"b":2}\n', 'oc://log/L1'); - rs('{"a":1}\n{"b":2}\n', 'oc://log/$last'); + rs('{"a":1}\n{"b":2}\n', "oc://log/L1"); + rs('{"a":1}\n{"b":2}\n', "oc://log/$last"); expect(JSON.stringify(ast)).toBe(before); }); - it('JLR-19 hostile inputs do not throw', () => { - expect(() => rs('not json\n', 'oc://log/L1')).not.toThrow(); - expect(() => rs('', 'oc://log/$last')).not.toThrow(); + it("JLR-19 hostile inputs do not throw", () => { + const malformed = rs("not json\n", "oc://log/L1"); + expect(malformed?.kind).toBe("line"); + if (malformed?.kind === "line") { + expect(malformed.node.kind).toBe("malformed"); + } + expect(rs("", "oc://log/$last")).toBeNull(); }); }); diff --git a/src/oc-path/tests/scenarios/malformed-input.test.ts b/src/oc-path/tests/scenarios/malformed-input.test.ts index baa011352ae..cfe179034ad 100644 --- a/src/oc-path/tests/scenarios/malformed-input.test.ts +++ b/src/oc-path/tests/scenarios/malformed-input.test.ts @@ -5,151 +5,168 @@ * malformed input. Suspicious-but-recoverable inputs produce * diagnostics; unparseable structural pieces are dropped silently. */ -import { describe, expect, it } from 'vitest'; -import { parseMd } from '../../parse.js'; +import { describe, expect, it } from "vitest"; +import { parseMd } from "../../parse.js"; -describe('wave-11 malformed-input', () => { - it('M-01 truncated mid-frontmatter (no close fence)', () => { - const raw = '---\nname: github\n'; +describe("wave-11 malformed-input", () => { + it("M-01 truncated mid-frontmatter (no close fence)", () => { + const raw = "---\nname: github\n"; const { ast, diagnostics } = parseMd(raw); - expect(diagnostics.some((d) => d.code === 'OC_FRONTMATTER_UNCLOSED')).toBe(true); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_FRONTMATTER_UNCLOSED"); expect(ast.frontmatter).toEqual([]); }); - it('M-02 truncated mid-section', () => { - const raw = '## H\n- item\nmid-line'; + it("M-02 truncated mid-section", () => { + const raw = "## H\n- item\nmid-line"; const { ast } = parseMd(raw); expect(ast.blocks.length).toBe(1); }); - it('M-03 only `---` (single fence, no content)', () => { - expect(() => parseMd('---\n')).not.toThrow(); + it("M-03 only `---` (single fence, no content)", () => { + const { ast, diagnostics } = parseMd("---\n"); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_FRONTMATTER_UNCLOSED"); + expect(ast.frontmatter).toEqual([]); + expect(ast.preamble).toBe("---\n"); }); - it('M-04 only `---\\n---`', () => { - const { ast } = parseMd('---\n---'); + it("M-04 only `---\\n---`", () => { + const { ast } = parseMd("---\n---"); expect(ast.frontmatter).toEqual([]); }); - it('M-05 binary-ish bytes (non-ASCII control chars)', () => { - const raw = '## H\n\x00\x01\x02\n'; - expect(() => parseMd(raw)).not.toThrow(); + it("M-05 binary-ish bytes (non-ASCII control chars)", () => { + const raw = "## H\n\x00\x01\x02\n"; + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics).toEqual([]); + expect(ast.blocks[0]?.bodyText).toBe("\x00\x01\x02\n"); }); - it('M-06 very long single line (10k chars)', () => { - const raw = `## H\n${'x'.repeat(10_000)}\n`; + it("M-06 very long single line (10k chars)", () => { + const raw = `## H\n${"x".repeat(10_000)}\n`; const { ast } = parseMd(raw); - expect(ast.blocks[0]?.heading).toBe('H'); + expect(ast.blocks[0]?.heading).toBe("H"); }); - it('M-07 deeply repeated headings (1000 H2 blocks)', () => { + it("M-07 deeply repeated headings (1000 H2 blocks)", () => { const lines: string[] = []; for (let i = 0; i < 1000; i++) { lines.push(`## H${i}`); lines.push(`- item ${i}`); } - const raw = lines.join('\n') + '\n'; + const raw = lines.join("\n") + "\n"; const { ast } = parseMd(raw); expect(ast.blocks.length).toBe(1000); }); - it('M-08 bullet shape that isn\'t actually a bullet (`-not-a-bullet`)', () => { - const { ast } = parseMd('## H\n-not-a-bullet\n- real\n'); + it("M-08 bullet shape that isn't actually a bullet (`-not-a-bullet`)", () => { + const { ast } = parseMd("## H\n-not-a-bullet\n- real\n"); expect(ast.blocks[0]?.items.length).toBe(1); }); - it('M-09 unclosed code fence', () => { - const raw = '## H\n```\nbody\n'; - expect(() => parseMd(raw)).not.toThrow(); + it("M-09 unclosed code fence", () => { + const raw = "## H\n```\nbody\n"; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("```\nbody\n"); }); - it('M-10 mismatched fence (open with ``` close with ~~~)', () => { - const raw = '## H\n```\nbody\n~~~\n'; - expect(() => parseMd(raw)).not.toThrow(); + it("M-10 mismatched fence (open with ``` close with ~~~)", () => { + const raw = "## H\n```\nbody\n~~~\n"; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("```\nbody\n~~~\n"); }); - it('M-11 nested fences (treated linearly, not nested)', () => { - const raw = '## H\n```\n```\nstill-in-second\n```\n'; - expect(() => parseMd(raw)).not.toThrow(); + it("M-11 nested fences (treated linearly, not nested)", () => { + const raw = "## H\n```\n```\nstill-in-second\n```\n"; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.bodyText).toBe("```\n```\nstill-in-second\n```\n"); }); - it('M-12 empty file', () => { - const { ast, diagnostics } = parseMd(''); - expect(ast.raw).toBe(''); + it("M-12 empty file", () => { + const { ast, diagnostics } = parseMd(""); + expect(ast.raw).toBe(""); expect(ast.frontmatter).toEqual([]); expect(ast.blocks).toEqual([]); expect(diagnostics).toEqual([]); }); - it('M-13 single character file', () => { - const { ast } = parseMd('x'); - expect(ast.preamble).toBe('x'); + it("M-13 single character file", () => { + const { ast } = parseMd("x"); + expect(ast.preamble).toBe("x"); expect(ast.blocks).toEqual([]); }); - it('M-14 single newline file', () => { - const { ast } = parseMd('\n'); + it("M-14 single newline file", () => { + const { ast } = parseMd("\n"); expect(ast.blocks).toEqual([]); }); - it('M-15 file with mixed indentation extremes (tabs, spaces, mixed)', () => { - const raw = '## H\n\t- tabbed\n - spaced\n\t - mixed\n'; - expect(() => parseMd(raw)).not.toThrow(); - }); - - it('M-16 frontmatter with frontmatter-shaped content inside (---)', () => { - const raw = '---\nk: v\n---\n\n---\nshould not parse as second frontmatter\n---\n'; + it("M-15 file with mixed indentation extremes (tabs, spaces, mixed)", () => { + const raw = "## H\n\t- tabbed\n - spaced\n\t - mixed\n"; const { ast } = parseMd(raw); - expect(ast.frontmatter.map((e) => e.key)).toEqual(['k']); + expect(ast.blocks[0]?.bodyText).toBe("\t- tabbed\n - spaced\n\t - mixed\n"); + }); + + it("M-16 frontmatter with frontmatter-shaped content inside (---)", () => { + const raw = "---\nk: v\n---\n\n---\nshould not parse as second frontmatter\n---\n"; + const { ast } = parseMd(raw); + expect(ast.frontmatter.map((e) => e.key)).toEqual(["k"]); // Second `---` block becomes part of preamble/body (it's not at file start). - expect(ast.preamble).toContain('---'); + expect(ast.preamble).toContain("---"); }); - it('M-17 lines starting with `#` but not heading (raw `#` chars in body)', () => { - const raw = '## H\n\n# This is text starting with #\n#### h4 not parsed as block\n'; + it("M-17 lines starting with `#` but not heading (raw `#` chars in body)", () => { + const raw = "## H\n\n# This is text starting with #\n#### h4 not parsed as block\n"; const { ast } = parseMd(raw); expect(ast.blocks.length).toBe(1); - expect(ast.blocks[0]?.bodyText).toContain('# This is text'); + expect(ast.blocks[0]?.bodyText).toContain("# This is text"); }); - it('M-18 lines starting with multiple ## but malformed (####, ######)', () => { - const { ast } = parseMd('## Real\n#### Not block\n###### Not block\n'); + it("M-18 lines starting with multiple ## but malformed (####, ######)", () => { + const { ast } = parseMd("## Real\n#### Not block\n###### Not block\n"); expect(ast.blocks.length).toBe(1); - expect(ast.blocks[0]?.heading).toBe('Real'); + expect(ast.blocks[0]?.heading).toBe("Real"); }); - it('M-19 file with just whitespace', () => { - expect(() => parseMd(' \n\t\n \n')).not.toThrow(); + it("M-19 file with just whitespace", () => { + const { ast, diagnostics } = parseMd(" \n\t\n \n"); + expect(diagnostics).toEqual([]); + expect(ast.preamble).toBe(" \n\t\n \n"); + expect(ast.blocks).toEqual([]); }); - it('M-20 file with only BOM', () => { - const { ast } = parseMd(''); - expect(ast.raw).toBe(''); + it("M-20 file with only BOM", () => { + const { ast } = parseMd(""); + expect(ast.raw).toBe(""); }); - it('M-21 file mixing BOM + frontmatter + body + sections', () => { - const raw = '---\nk: v\n---\n\nbody\n## Section\n- item\n'; - expect(() => parseMd(raw)).not.toThrow(); + it("M-21 file mixing BOM + frontmatter + body + sections", () => { + const raw = "---\nk: v\n---\n\nbody\n## Section\n- item\n"; const { ast } = parseMd(raw); - expect(ast.frontmatter[0]?.value).toBe('v'); - expect(ast.blocks[0]?.heading).toBe('Section'); + expect(ast.frontmatter[0]?.value).toBe("v"); + expect(ast.blocks[0]?.heading).toBe("Section"); + expect(ast.blocks[0]?.items[0]?.text).toBe("item"); }); - it('M-22 line endings: legacy CR-only (Mac classic)', () => { + it("M-22 line endings: legacy CR-only (Mac classic)", () => { // Our regex /\r?\n/ doesn't split on CR-only. Treats whole as one line. - const raw = 'line1\rline2\r## Heading\r'; - expect(() => parseMd(raw)).not.toThrow(); + const raw = "line1\rline2\r## Heading\r"; + const { ast } = parseMd(raw); + expect(ast.preamble).toBe(raw); + expect(ast.blocks).toEqual([]); }); - it('M-23 100 KB file', () => { + it("M-23 100 KB file", () => { const lines: string[] = []; for (let i = 0; i < 1000; i++) { - lines.push('## H' + i); + lines.push("## H" + i); for (let j = 0; j < 5; j++) { lines.push(`- item-${i}-${j}: value with some text content here`); } } - const raw = lines.join('\n'); - expect(() => parseMd(raw)).not.toThrow(); + const raw = lines.join("\n"); + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics).toEqual([]); + expect(ast.blocks).toHaveLength(1000); + expect(ast.blocks[999]?.items).toHaveLength(5); }); }); diff --git a/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts b/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts index 1f0381a8e6c..6cf5b8e1f3e 100644 --- a/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts +++ b/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts @@ -6,9 +6,9 @@ * item returns `null` (not a guess). Frontmatter via the `[frontmatter]` * sentinel section. */ -import { describe, expect, it } from 'vitest'; -import { parseMd } from '../../parse.js'; -import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; +import { describe, expect, it } from "vitest"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const SAMPLE = `--- name: github @@ -34,37 +34,39 @@ Preamble prose. - item one `; -describe('wave-08 oc-path-resolver-edges', () => { +describe("wave-08 oc-path-resolver-edges", () => { const { ast } = parseMd(SAMPLE); - it('R-01 root resolves to AST', () => { - const m = resolveOcPath(ast, { file: 'X.md' }); - expect(m?.kind).toBe('root'); + it("R-01 root resolves to AST", () => { + const m = resolveOcPath(ast, { file: "X.md" }); + expect(m?.kind).toBe("root"); }); - it('R-02 block by exact slug', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'boundaries' }); - expect(m?.kind).toBe('block'); + it("R-02 block by exact slug", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "boundaries" }); + expect(m?.kind).toBe("block"); }); - it('R-03 block by case-mismatched slug (Boundaries → boundaries)', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'Boundaries' }); - expect(m?.kind).toBe('block'); + it("R-03 block by case-mismatched slug (Boundaries → boundaries)", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "Boundaries" }); + expect(m?.kind).toBe("block"); }); - it('R-04 block by uppercased slug', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'BOUNDARIES' }); - expect(m?.kind).toBe('block'); + it("R-04 block by uppercased slug", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "BOUNDARIES" }); + expect(m?.kind).toBe("block"); }); - it('R-05 multi-word section by slug', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'multi-word-section' }); - expect(m?.kind).toBe('block'); - if (m?.kind === 'block') {expect(m.node.heading).toBe('Multi-Word Section');} + it("R-05 multi-word section by slug", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "multi-word-section" }); + expect(m?.kind).toBe("block"); + if (m?.kind === "block") { + expect(m.node.heading).toBe("Multi-Word Section"); + } }); - it('R-06 multi-word section by exact heading text (case-folded)', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'Multi-Word Section' }); + it("R-06 multi-word section by exact heading text (case-folded)", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "Multi-Word Section" }); // The OcPath section is matched case-insensitively against block.slug. // Block.slug for "Multi-Word Section" is "multi-word-section", and // path.section.toLowerCase() = "multi-word section" which does NOT @@ -73,163 +75,172 @@ describe('wave-08 oc-path-resolver-edges', () => { expect(m).toBeNull(); }); - it('R-07 unknown section returns null', () => { - const m = resolveOcPath(ast, { file: 'X.md', section: 'unknown' }); + it("R-07 unknown section returns null", () => { + const m = resolveOcPath(ast, { file: "X.md", section: "unknown" }); expect(m).toBeNull(); }); - it('R-08 item by slug under known section', () => { + it("R-08 item by slug under known section", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', + file: "X.md", + section: "tools", + item: "gh", }); - expect(m?.kind).toBe('item'); + expect(m?.kind).toBe("item"); }); it('R-09 item slug for KV uses kv.key (gh, not "gh-github-cli")', () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', + file: "X.md", + section: "tools", + item: "gh", }); - expect(m).not.toBeNull(); - if (m?.kind === 'item') {expect(m.node.kv?.value).toBe('GitHub CLI');} + expect(m).toEqual(expect.objectContaining({ kind: "item" })); + if (m?.kind !== "item") { + throw new Error("Expected item match for gh"); + } + expect(m.node.kv?.value).toBe("GitHub CLI"); }); - it('R-10 item slug for plain bullet uses text', () => { + it("R-10 item slug for plain bullet uses text", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'boundaries', - item: 'never-write-to-etc', + file: "X.md", + section: "boundaries", + item: "never-write-to-etc", }); - expect(m?.kind).toBe('item'); + expect(m?.kind).toBe("item"); }); - it('R-11 item slug case-insensitive', () => { + it("R-11 item slug case-insensitive", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'GH', + file: "X.md", + section: "tools", + item: "GH", }); - expect(m?.kind).toBe('item'); + expect(m?.kind).toBe("item"); }); - it('R-12 item with spaces in key (slugified)', () => { + it("R-12 item with spaces in key (slugified)", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'the-tool', + file: "X.md", + section: "tools", + item: "the-tool", }); - expect(m?.kind).toBe('item'); - if (m?.kind === 'item') {expect(m.node.kv?.value).toBe('with caps and spaces');} + expect(m?.kind).toBe("item"); + if (m?.kind === "item") { + expect(m.node.kv?.value).toBe("with caps and spaces"); + } }); - it('R-13 unknown item returns null', () => { + it("R-13 unknown item returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'nonexistent', + file: "X.md", + section: "tools", + item: "nonexistent", }); expect(m).toBeNull(); }); - it('R-14 item-field matches kv.key (case-insensitive)', () => { + it("R-14 item-field matches kv.key (case-insensitive)", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', - field: 'gh', + file: "X.md", + section: "tools", + item: "gh", + field: "gh", }); - expect(m?.kind).toBe('item-field'); + expect(m?.kind).toBe("item-field"); }); - it('R-15 field on plain (non-kv) item returns null', () => { + it("R-15 field on plain (non-kv) item returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'boundaries', - item: 'never-write-to-etc', - field: 'risk', + file: "X.md", + section: "boundaries", + item: "never-write-to-etc", + field: "risk", }); expect(m).toBeNull(); }); - it('R-16 field that does not match kv.key returns null', () => { + it("R-16 field that does not match kv.key returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: 'tools', - item: 'gh', - field: 'nonexistent', + file: "X.md", + section: "tools", + item: "gh", + field: "nonexistent", }); expect(m).toBeNull(); }); - it('R-17 frontmatter via [frontmatter] sentinel section', () => { + it("R-17 frontmatter via [frontmatter] sentinel section", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: '[frontmatter]', - field: 'name', + file: "X.md", + section: "[frontmatter]", + field: "name", }); - expect(m?.kind).toBe('frontmatter'); - if (m?.kind === 'frontmatter') {expect(m.node.value).toBe('github');} + expect(m?.kind).toBe("frontmatter"); + if (m?.kind === "frontmatter") { + expect(m.node.value).toBe("github"); + } }); - it('R-18 frontmatter unknown key returns null', () => { + it("R-18 frontmatter unknown key returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: '[frontmatter]', - field: 'nonexistent', + file: "X.md", + section: "[frontmatter]", + field: "nonexistent", }); expect(m).toBeNull(); }); - it('R-19 frontmatter without field returns null', () => { + it("R-19 frontmatter without field returns null", () => { const m = resolveOcPath(ast, { - file: 'X.md', - section: '[frontmatter]', + file: "X.md", + section: "[frontmatter]", }); expect(m).toBeNull(); }); - it('R-20 multiple frontmatter keys with same name — first match wins', () => { + it("R-20 multiple frontmatter keys with same name — first match wins", () => { // Build an AST manually to test const dupeAst = { - kind: 'md' as const, - raw: '', + kind: "md" as const, + raw: "", frontmatter: [ - { key: 'k', value: 'first', line: 2 }, - { key: 'k', value: 'second', line: 3 }, + { key: "k", value: "first", line: 2 }, + { key: "k", value: "second", line: 3 }, ], - preamble: '', + preamble: "", blocks: [], }; const m = resolveOcPath(dupeAst, { - file: 'X.md', - section: '[frontmatter]', - field: 'k', + file: "X.md", + section: "[frontmatter]", + field: "k", }); - expect(m?.kind).toBe('frontmatter'); - if (m?.kind === 'frontmatter') {expect(m.node.value).toBe('first');} + expect(m?.kind).toBe("frontmatter"); + if (m?.kind === "frontmatter") { + expect(m.node.value).toBe("first"); + } }); - it('R-21 empty AST resolves root only', () => { - const empty = { kind: 'md' as const, raw: '', frontmatter: [], preamble: '', blocks: [] }; - expect(resolveOcPath(empty, { file: 'X.md' })?.kind).toBe('root'); - expect(resolveOcPath(empty, { file: 'X.md', section: 'any' })).toBeNull(); + it("R-21 empty AST resolves root only", () => { + const empty = { kind: "md" as const, raw: "", frontmatter: [], preamble: "", blocks: [] }; + expect(resolveOcPath(empty, { file: "X.md" })?.kind).toBe("root"); + expect(resolveOcPath(empty, { file: "X.md", section: "any" })).toBeNull(); }); - it('R-22 resolver does not mutate the AST', () => { + it("R-22 resolver does not mutate the AST", () => { const before = JSON.stringify(ast); - resolveOcPath(ast, { file: 'X.md', section: 'tools', item: 'gh', field: 'gh' }); + resolveOcPath(ast, { file: "X.md", section: "tools", item: "gh", field: "gh" }); const after = JSON.stringify(ast); expect(after).toBe(before); }); - it('R-23 file segment is informational — resolver doesn\'t check it', () => { + it("R-23 file segment is informational — resolver doesn't check it", () => { // The file name in OcPath is metadata; resolver assumes the AST // matches. Callers verify file mapping before passing the AST. - const m1 = resolveOcPath(ast, { file: 'SOUL.md', section: 'tools' }); - const m2 = resolveOcPath(ast, { file: 'AGENTS.md', section: 'tools' }); + const m1 = resolveOcPath(ast, { file: "SOUL.md", section: "tools" }); + const m2 = resolveOcPath(ast, { file: "AGENTS.md", section: "tools" }); expect(m1?.kind).toBe(m2?.kind); }); }); diff --git a/src/oc-path/tests/scenarios/pitfalls.test.ts b/src/oc-path/tests/scenarios/pitfalls.test.ts index 245c2dfabce..bb6b54c6bd5 100644 --- a/src/oc-path/tests/scenarios/pitfalls.test.ts +++ b/src/oc-path/tests/scenarios/pitfalls.test.ts @@ -14,7 +14,7 @@ * pitfalls — e.g., P-033 there is "Memory poisoning"). The package * boundary disambiguates. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; import { MAX_PATH_LENGTH, MAX_TRAVERSAL_DEPTH, @@ -24,198 +24,207 @@ import { parseOcPath, resolveOcPath, setOcPath, -} from '../../index.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { parseYaml } from '../../yaml/parse.js'; +} from "../../index.js"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { parseYaml } from "../../yaml/parse.js"; // ---------- Encoding pitfalls -------------------------------------------- -describe('wave-23 pitfalls — encoding', () => { - it('P-001 strips leading UTF-8 BOM from path string', () => { - const bom = ''; - expect(parseOcPath(`${bom}oc://X/Y`).file).toBe('X'); +describe("wave-23 pitfalls — encoding", () => { + it("P-001 strips leading UTF-8 BOM from path string", () => { + const bom = ""; + expect(parseOcPath(`${bom}oc://X/Y`).file).toBe("X"); }); - it('P-002 normalizes path to NFC', () => { - const nfc = 'café'; // composed - const nfd = 'café'; // decomposed + it("P-002 normalizes path to NFC", () => { + const nfc = "café"; // composed + const nfd = "café"; // decomposed expect(parseOcPath(`oc://X/${nfd}`).section).toBe(nfc); expect(parseOcPath(`oc://X/${nfc}`).section).toBe(nfc); // Same struct out for both inputs. expect(parseOcPath(`oc://X/${nfd}`)).toEqual(parseOcPath(`oc://X/${nfc}`)); }); - it('P-003 rejects whitespace in identifier-shaped segments', () => { - expect(() => parseOcPath('oc://X/foo /bar')).toThrow(OcPathError); - expect(() => parseOcPath('oc://X/ foo')).toThrow(OcPathError); - expect(() => parseOcPath('oc://X/foo\tbar')).toThrow(OcPathError); + it("P-003 rejects whitespace in identifier-shaped segments", () => { + expect(() => parseOcPath("oc://X/foo /bar")).toThrow(OcPathError); + expect(() => parseOcPath("oc://X/ foo")).toThrow(OcPathError); + expect(() => parseOcPath("oc://X/foo\tbar")).toThrow(OcPathError); }); - it('P-003 allows whitespace inside predicate values (content)', () => { + it("P-003 allows whitespace inside predicate values (content)", () => { // Spaces inside a predicate value are legitimate — they're filtering // against actual content. - expect(() => parseOcPath('oc://X/[name=hello world]')).not.toThrow(); + const path = parseOcPath("oc://X/[name=hello world]"); + expect(path.file).toBe("X"); + expect(path.section).toBe("[name=hello world]"); }); - it('P-004 / P-011 rejects control characters and null bytes', () => { - expect(() => parseOcPath('oc://X/\x00')).toThrow(/Control character/); - expect(() => parseOcPath('oc://X/foo\x01bar')).toThrow(/Control character/); - expect(() => parseOcPath('oc://X/foo\x7Fbar')).toThrow(/Control character/); + it("P-004 / P-011 rejects control characters and null bytes", () => { + expect(() => parseOcPath("oc://X/\x00")).toThrow(/Control character/); + expect(() => parseOcPath("oc://X/foo\x01bar")).toThrow(/Control character/); + expect(() => parseOcPath("oc://X/foo\x7Fbar")).toThrow(/Control character/); }); }); // ---------- Empty / structural pitfalls ---------------------------------- -describe('wave-23 pitfalls — empty & structural', () => { - it('P-008 rejects empty segments', () => { - expect(() => parseOcPath('oc://X//Y')).toThrow(/Empty segment/); +describe("wave-23 pitfalls — empty & structural", () => { + it("P-008 rejects empty segments", () => { + expect(() => parseOcPath("oc://X//Y")).toThrow(/Empty segment/); }); - it('P-009 rejects empty dotted sub-segments', () => { - expect(() => parseOcPath('oc://X/a..b')).toThrow(/Empty dotted sub-segment/); + it("P-009 rejects empty dotted sub-segments", () => { + expect(() => parseOcPath("oc://X/a..b")).toThrow(/Empty dotted sub-segment/); }); - it('P-010 rejects scheme-only path', () => { - expect(() => parseOcPath('oc://')).toThrow(/Empty oc:\/\/ path/); + it("P-010 rejects scheme-only path", () => { + expect(() => parseOcPath("oc://")).toThrow(/Empty oc:\/\/ path/); }); - it('P-014 rejects empty predicate key', () => { - expect(() => parseOcPath('oc://X/[=foo]')).toThrow(/Malformed predicate/); + it("P-014 rejects empty predicate key", () => { + expect(() => parseOcPath("oc://X/[=foo]")).toThrow(/Malformed predicate/); }); - it('P-014 rejects empty predicate value', () => { - expect(() => parseOcPath('oc://X/[id=]')).toThrow(/Malformed predicate/); + it("P-014 rejects empty predicate value", () => { + expect(() => parseOcPath("oc://X/[id=]")).toThrow(/Malformed predicate/); }); - it('P-015 accepts bracket segment with no operator as literal sentinel', () => { + it("P-015 accepts bracket segment with no operator as literal sentinel", () => { // `[frontmatter]` predates the predicate grammar — kept as literal. - expect(parseOcPath('oc://AGENTS.md/[frontmatter]/key').section).toBe('[frontmatter]'); + expect(parseOcPath("oc://AGENTS.md/[frontmatter]/key").section).toBe("[frontmatter]"); }); - it('P-016 rejects mismatched brackets', () => { - expect(() => parseOcPath('oc://X/[unclosed')).toThrow(OcPathError); - expect(() => parseOcPath('oc://X/closed]')).toThrow(OcPathError); + it("P-016 rejects mismatched brackets", () => { + expect(() => parseOcPath("oc://X/[unclosed")).toThrow(OcPathError); + expect(() => parseOcPath("oc://X/closed]")).toThrow(OcPathError); }); - it('P-016 rejects mismatched braces', () => { - expect(() => parseOcPath('oc://X/{a,b')).toThrow(OcPathError); + it("P-016 rejects mismatched braces", () => { + expect(() => parseOcPath("oc://X/{a,b")).toThrow(OcPathError); }); - it('P-018 rejects empty union', () => { - expect(() => parseOcPath('oc://X/{}')).toThrow(/Empty union/); + it("P-018 rejects empty union", () => { + expect(() => parseOcPath("oc://X/{}")).toThrow(/Empty union/); }); - it('P-018 rejects union with empty alternative', () => { - expect(() => parseOcPath('oc://X/{a,,b}')).toThrow(/Empty alternative/); + it("P-018 rejects union with empty alternative", () => { + expect(() => parseOcPath("oc://X/{a,,b}")).toThrow(/Empty alternative/); }); }); // ---------- Predicate-content pitfalls ----------------------------------- -describe('wave-23 pitfalls — predicate content', () => { - it('P-012 predicate value containing `/` round-trips', () => { +describe("wave-23 pitfalls — predicate content", () => { + it("P-012 predicate value containing `/` round-trips", () => { // The path-level `/` split must respect bracket boundaries. - const p = parseOcPath('oc://X/[id=foo/bar]/cmd'); - expect(p.section).toBe('[id=foo/bar]'); - expect(p.item).toBe('cmd'); + const p = parseOcPath("oc://X/[id=foo/bar]/cmd"); + expect(p.section).toBe("[id=foo/bar]"); + expect(p.item).toBe("cmd"); }); - it('P-012 findOcPaths matches a leaf whose id contains a slash', () => { - const ast = parseYaml( - 'steps:\n - id: foo/bar\n cmd: x\n - id: baz\n cmd: y\n' - ).ast; - const out = findOcPaths(ast, parseOcPath('oc://wf/steps/[id=foo/bar]/cmd')); + it("P-012 findOcPaths matches a leaf whose id contains a slash", () => { + const ast = parseYaml("steps:\n - id: foo/bar\n cmd: x\n - id: baz\n cmd: y\n").ast; + const out = findOcPaths(ast, parseOcPath("oc://wf/steps/[id=foo/bar]/cmd")); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('x');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("x"); + } }); - it('P-013 predicate value containing `.` round-trips', () => { - const p = parseOcPath('oc://X/steps.[id=1.0].cmd'); - expect(p.section).toBe('steps.[id=1.0].cmd'); + it("P-013 predicate value containing `.` round-trips", () => { + const p = parseOcPath("oc://X/steps.[id=1.0].cmd"); + expect(p.section).toBe("steps.[id=1.0].cmd"); }); - it('P-013 findOcPaths matches a leaf whose id is `1.0`', () => { - const ast = parseYaml( - 'steps:\n - id: "1.0"\n cmd: x\n - id: "2.0"\n cmd: y\n' - ).ast; - const out = findOcPaths(ast, parseOcPath('oc://wf/steps/[id=1.0]/cmd')); + it("P-013 findOcPaths matches a leaf whose id is `1.0`", () => { + const ast = parseYaml('steps:\n - id: "1.0"\n cmd: x\n - id: "2.0"\n cmd: y\n').ast; + const out = findOcPaths(ast, parseOcPath("oc://wf/steps/[id=1.0]/cmd")); expect(out).toHaveLength(1); - if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('x');} + if (out[0].match.kind === "leaf") { + expect(out[0].match.valueText).toBe("x"); + } }); }); // ---------- Sentinel & collision pitfalls -------------------------------- -describe('wave-23 pitfalls — sentinels & collisions', () => { - it('P-020/openclaw#59934 negative numeric key on object resolves as literal key', () => { +describe("wave-23 pitfalls — sentinels & collisions", () => { + it("P-020/openclaw#59934 negative numeric key on object resolves as literal key", () => { // Telegram supergroup IDs are negative numbers used as map keys. // Our positional `-N` token would otherwise hijack them. Resolver // falls through to literal-key lookup on non-indexable containers. const ast = parseJsonc( - '{"channels":{"telegram":{"groups":{"-5028303500":{"requireMention":false}}}}}' + '{"channels":{"telegram":{"groups":{"-5028303500":{"requireMention":false}}}}}', ).ast; const m = resolveOcPath( ast, - parseOcPath('oc://config/channels.telegram.groups.-5028303500.requireMention'), + parseOcPath("oc://config/channels.telegram.groups.-5028303500.requireMention"), ); - expect(m).not.toBeNull(); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') { - expect(m.valueText).toBe('false'); - expect(m.leafType).toBe('boolean'); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("false"); + expect(m.leafType).toBe("boolean"); } }); - it('P-020 negative `-N` still works as positional on arrays', () => { + it("P-020 negative `-N` still works as positional on arrays", () => { // Same syntax, indexable container — positional resolution wins. const ast = parseJsonc('{"items":[10,20,30]}').ast; - const m = resolveOcPath(ast, parseOcPath('oc://X/items/-1')); - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('30');} + const m = resolveOcPath(ast, parseOcPath("oc://X/items/-1")); + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("30"); + } }); - it('P-020 numeric segment dispatches by node kind (array index vs map key)', () => { + it("P-020 numeric segment dispatches by node kind (array index vs map key)", () => { // Same path string against two different ASTs — kind disambiguates. const arr = parseJsonc('{"x":["a","b"]}').ast; const map = parseJsonc('{"x":{"0":"a","1":"b"}}').ast; - const arrM = resolveOcPath(arr, parseOcPath('oc://config/x/0')); - const mapM = resolveOcPath(map, parseOcPath('oc://config/x/0')); - expect(arrM?.kind).toBe('leaf'); - expect(mapM?.kind).toBe('leaf'); - if (arrM?.kind === 'leaf') {expect(arrM.valueText).toBe('a');} - if (mapM?.kind === 'leaf') {expect(mapM.valueText).toBe('a');} + const arrM = resolveOcPath(arr, parseOcPath("oc://config/x/0")); + const mapM = resolveOcPath(map, parseOcPath("oc://config/x/0")); + expect(arrM?.kind).toBe("leaf"); + expect(mapM?.kind).toBe("leaf"); + if (arrM?.kind === "leaf") { + expect(arrM.valueText).toBe("a"); + } + if (mapM?.kind === "leaf") { + expect(mapM.valueText).toBe("a"); + } }); - it('P-021 `$last` literal in a yaml key is shadowed by positional sentinel', () => { + it("P-021 `$last` literal in a yaml key is shadowed by positional sentinel", () => { // Document v0 limitation: `$last` always means "last", never a literal key. // Authors with `$last` literal keys must use kind-narrow access. - const ast = parseYaml('$last: literal-value\nfoo: bar\n').ast; - const m = resolveOcPath(ast, parseOcPath('oc://X/$last')); + const ast = parseYaml("$last: literal-value\nfoo: bar\n").ast; + const m = resolveOcPath(ast, parseOcPath("oc://X/$last")); // `$last` resolves to the LAST key (`foo` → `bar`), not the literal `$last` key. - expect(m?.kind).toBe('leaf'); - if (m?.kind === 'leaf') {expect(m.valueText).toBe('bar');} + expect(m?.kind).toBe("leaf"); + if (m?.kind === "leaf") { + expect(m.valueText).toBe("bar"); + } }); }); // ---------- Round-trip pitfalls ------------------------------------------ -describe('wave-23 pitfalls — round-trip', () => { - it('P-023 parseOcPath ∘ formatOcPath is idempotent across path shapes', () => { +describe("wave-23 pitfalls — round-trip", () => { + it("P-023 parseOcPath ∘ formatOcPath is idempotent across path shapes", () => { const inputs = [ - 'oc://X', - 'oc://X/a', - 'oc://X/a/b', - 'oc://X/a/b/c', - 'oc://X/a.b.c', - 'oc://X/a?session=s1', - 'oc://X/[frontmatter]/key', - 'oc://X/steps/*/command', - 'oc://X/steps/$last/id', - 'oc://X/steps/-2/id', - 'oc://X/steps/{command,run}', - 'oc://X/steps/[id=foo]/cmd', - 'oc://X/steps/#0/foo', + "oc://X", + "oc://X/a", + "oc://X/a/b", + "oc://X/a/b/c", + "oc://X/a.b.c", + "oc://X/a?session=s1", + "oc://X/[frontmatter]/key", + "oc://X/steps/*/command", + "oc://X/steps/$last/id", + "oc://X/steps/-2/id", + "oc://X/steps/{command,run}", + "oc://X/steps/[id=foo]/cmd", + "oc://X/steps/#0/foo", ]; for (const s of inputs) { const parsed = parseOcPath(s); @@ -227,36 +236,36 @@ describe('wave-23 pitfalls — round-trip', () => { // ---------- Sentinel-guard pitfalls -------------------------------------- -describe('wave-23 pitfalls — sentinel at format boundary (F9)', () => { - it('formatOcPath rejects an OcPath struct carrying the redaction sentinel', () => { +describe("wave-23 pitfalls — sentinel at format boundary (F9)", () => { + it("formatOcPath rejects an OcPath struct carrying the redaction sentinel", () => { // Path strings flow into telemetry, audit events, error messages, // find-result `path` fields. Without the format-time guard, a // struct with `section: REDACTED_SENTINEL` would slip past every // consumer except the CLI's scrubSentinel layer. The substrate's // contract is "emit boundaries refuse the sentinel" — formatOcPath // IS such a boundary for path strings. - expect(() => - formatOcPath({ file: 'AGENTS.md', section: '__OPENCLAW_REDACTED__' }), - ).toThrow(/sentinel literal/); + expect(() => formatOcPath({ file: "AGENTS.md", section: "__OPENCLAW_REDACTED__" })).toThrow( + /sentinel literal/, + ); }); }); // ---------- Containment pitfalls ----------------------------------------- -describe('wave-23 pitfalls — file-slot containment', () => { +describe("wave-23 pitfalls — file-slot containment", () => { // oc:// paths are workspace-relative. Absolute paths and `..` segments // would let a hostile workflow / skill manifest persuade // `openclaw path resolve|set|emit` into reading or writing arbitrary // filesystem locations (Node `path.resolve(cwd, absolute)` returns // `absolute`, bypassing the workspace root). Reject at parseOcPath // and formatOcPath for symmetric defense. - it('rejects an absolute POSIX file slot', () => { - expect(() => parseOcPath('oc:///etc/passwd')).toThrow(/Empty segment/); + it("rejects an absolute POSIX file slot", () => { + expect(() => parseOcPath("oc:///etc/passwd")).toThrow(/Empty segment/); // Quoted form — same containment violation, different parse path. expect(() => parseOcPath('oc://"/etc/passwd"/section')).toThrow(/Absolute file slot/); }); - it('rejects a Windows drive-letter file slot', () => { + it("rejects a Windows drive-letter file slot", () => { expect(() => parseOcPath('oc://"C:/Windows/System32/foo"/section')).toThrow( /Absolute file slot/, ); @@ -265,22 +274,22 @@ describe('wave-23 pitfalls — file-slot containment', () => { ); }); - it('rejects a leading-backslash file slot', () => { + it("rejects a leading-backslash file slot", () => { expect(() => parseOcPath('oc://"\\\\srv\\\\share\\\\foo"/section')).toThrow( /Absolute file slot/, ); }); - it('rejects a parent-directory escape via plain `..`', () => { + it("rejects a parent-directory escape via plain `..`", () => { expect(() => parseOcPath('oc://"../foo"/section')).toThrow(/Parent-directory/); expect(() => parseOcPath('oc://".."/section')).toThrow(/Parent-directory/); }); - it('rejects a parent-directory escape mid-path', () => { + it("rejects a parent-directory escape mid-path", () => { expect(() => parseOcPath('oc://"foo/../bar"/section')).toThrow(/Parent-directory/); }); - it('does not decode URL-encoded `..` — literal `%2E%2E` is treated as a filename', () => { + it("does not decode URL-encoded `..` — literal `%2E%2E` is treated as a filename", () => { // The substrate does NOT do URL decoding — `%2E%2E` is the literal // five-character filename, not a parent-directory escape. Documented // limitation: consumers that pre-decode (HTTP layers, browser UI) @@ -288,72 +297,70 @@ describe('wave-23 pitfalls — file-slot containment', () => { // Pin the current behavior so a future "let's decode for them" PR // sees the explicit choice. const p = parseOcPath('oc://"%2E%2E/foo"/section'); - expect(p.file).toBe('%2E%2E/foo'); + expect(p.file).toBe("%2E%2E/foo"); }); - it('formatOcPath rejects an OcPath struct with absolute file', () => { - expect(() => formatOcPath({ file: '/etc/passwd' })).toThrow(/Absolute file slot/); - expect(() => formatOcPath({ file: 'C:/Windows' })).toThrow(/Absolute file slot/); + it("formatOcPath rejects an OcPath struct with absolute file", () => { + expect(() => formatOcPath({ file: "/etc/passwd" })).toThrow(/Absolute file slot/); + expect(() => formatOcPath({ file: "C:/Windows" })).toThrow(/Absolute file slot/); }); - it('formatOcPath rejects an OcPath struct with parent-directory file', () => { - expect(() => formatOcPath({ file: '..' })).toThrow(/Parent-directory/); - expect(() => formatOcPath({ file: '../etc/passwd' })).toThrow(/Parent-directory/); - expect(() => formatOcPath({ file: 'foo/../bar' })).toThrow(/Parent-directory/); + it("formatOcPath rejects an OcPath struct with parent-directory file", () => { + expect(() => formatOcPath({ file: ".." })).toThrow(/Parent-directory/); + expect(() => formatOcPath({ file: "../etc/passwd" })).toThrow(/Parent-directory/); + expect(() => formatOcPath({ file: "foo/../bar" })).toThrow(/Parent-directory/); }); }); // ---------- formatOcPath ↔ parseOcPath round-trip ------------------------ -describe('wave-23 pitfalls — format/parse round-trip', () => { +describe("wave-23 pitfalls — format/parse round-trip", () => { // The contract on oc-path.ts:13 — `formatOcPath(parseOcPath(s)) === s` // for any string the formatter accepts. Round-trip breaks were // observable on (a) struct fields with empty dotted sub-segments // (`section: 'foo.'` → `oc://X/foo.""` → re-parses with `section: // 'foo.""'`) and (b) struct fields with control chars (formatter // emitted unquoted, parser refused). Pin both directions. - it('formatOcPath rejects empty dotted sub-segment in a slot', () => { - expect(() => formatOcPath({ file: 'a.md', section: 'foo.' })).toThrow( + it("formatOcPath rejects empty dotted sub-segment in a slot", () => { + expect(() => formatOcPath({ file: "a.md", section: "foo." })).toThrow( /Empty dotted sub-segment/, ); - expect(() => formatOcPath({ file: 'a.md', section: '.foo' })).toThrow( + expect(() => formatOcPath({ file: "a.md", section: ".foo" })).toThrow( /Empty dotted sub-segment/, ); - expect(() => formatOcPath({ file: 'a.md', section: 'foo..bar' })).toThrow( + expect(() => formatOcPath({ file: "a.md", section: "foo..bar" })).toThrow( /Empty dotted sub-segment/, ); }); - it('formatOcPath rejects control characters in any slot', () => { - expect(() => formatOcPath({ file: 'a.md', section: 'sec\x00tion' })).toThrow( + it("formatOcPath rejects control characters in any slot", () => { + expect(() => formatOcPath({ file: "a.md", section: "sec\x00tion" })).toThrow( /Control character/, ); - expect(() => formatOcPath({ file: 'a.md', section: 'sec\x01tion' })).toThrow( + expect(() => formatOcPath({ file: "a.md", section: "sec\x01tion" })).toThrow( /Control character/, ); - expect(() => formatOcPath({ file: 'a.md', section: 'tab\ttion' })).toThrow( - /Control character/, - ); - expect(() => formatOcPath({ file: 'a\x00b.md' })).toThrow(/Control character/); + expect(() => formatOcPath({ file: "a.md", section: "tab\ttion" })).toThrow(/Control character/); + expect(() => formatOcPath({ file: "a\x00b.md" })).toThrow(/Control character/); }); - it('round-trips every shape parseOcPath accepts', () => { + it("round-trips every shape parseOcPath accepts", () => { // For every valid input, formatOcPath(parseOcPath(s)) MUST be // re-parseable to the same struct. Don't string-compare (the // formatter normalizes quoting); parse the round-tripped output // and compare structs. const inputs = [ - 'oc://X', - 'oc://X/a', - 'oc://X/a/b', - 'oc://X/a/b/c', - 'oc://X/a.b.c', - 'oc://X/a?session=s1', - 'oc://X/[frontmatter]/key', - 'oc://X/steps/$last/id', - 'oc://X/steps/-2/id', - 'oc://X/steps/[id=foo]/cmd', - 'oc://X/steps/{a,b}/cmd', + "oc://X", + "oc://X/a", + "oc://X/a/b", + "oc://X/a/b/c", + "oc://X/a.b.c", + "oc://X/a?session=s1", + "oc://X/[frontmatter]/key", + "oc://X/steps/$last/id", + "oc://X/steps/-2/id", + "oc://X/steps/[id=foo]/cmd", + "oc://X/steps/{a,b}/cmd", 'oc://X/"foo/bar"/baz', 'oc://X/agents/"anthropic/claude-opus-4-7"/alias', ]; @@ -368,58 +375,58 @@ describe('wave-23 pitfalls — format/parse round-trip', () => { // ---------- Performance pitfalls ----------------------------------------- -describe('wave-23 pitfalls — performance & limits', () => { - it('P-031 / P-033 walker depth cap throws on pathological recursion', () => { +describe("wave-23 pitfalls — performance & limits", () => { + it("P-031 / P-033 walker depth cap throws on pathological recursion", () => { // Construct a yaml that nests deeper than MAX_TRAVERSAL_DEPTH. // We're using `**` against a synthetic deeply-nested structure. - let yaml = 'root:\n'; - let indent = ' '; + let yaml = "root:\n"; + let indent = " "; for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) { yaml += `${indent}a:\n`; - indent += ' '; + indent += " "; } yaml += `${indent}leaf: x\n`; const ast = parseYaml(yaml).ast; - expect(() => findOcPaths(ast, parseOcPath('oc://X/**'))).toThrow(/MAX_TRAVERSAL_DEPTH/); + expect(() => findOcPaths(ast, parseOcPath("oc://X/**"))).toThrow(/MAX_TRAVERSAL_DEPTH/); }); - it('P-032 rejects path strings longer than MAX_PATH_LENGTH', () => { - const big = 'oc://X/' + 'a'.repeat(MAX_PATH_LENGTH); + it("P-032 rejects path strings longer than MAX_PATH_LENGTH", () => { + const big = "oc://X/" + "a".repeat(MAX_PATH_LENGTH); expect(() => parseOcPath(big)).toThrow(/exceeds .* bytes/); }); - it('P-032 path at the cap parses cleanly', () => { - const justUnder = 'oc://X/' + 'a'.repeat(MAX_PATH_LENGTH - 'oc://X/'.length); - expect(() => parseOcPath(justUnder)).not.toThrow(); + it("P-032 path at the cap parses cleanly", () => { + const justUnder = "oc://X/" + "a".repeat(MAX_PATH_LENGTH - "oc://X/".length); + expect(parseOcPath(justUnder).section).toBe("a".repeat(MAX_PATH_LENGTH - "oc://X/".length)); }); - it('P-032 formatOcPath enforces the same cap on output', () => { + it("P-032 formatOcPath enforces the same cap on output", () => { // Symmetric upper bound — without this guard, a struct whose // formatted form crosses the cap would emit a string parseOcPath // would immediately reject (round-trip break). - expect(() => - formatOcPath({ file: 'X', section: 'a'.repeat(MAX_PATH_LENGTH) }), - ).toThrow(/Formatted oc:\/\/ exceeds/); + expect(() => formatOcPath({ file: "X", section: "a".repeat(MAX_PATH_LENGTH) })).toThrow( + /Formatted oc:\/\/ exceeds/, + ); }); - it('parser depth cap fires on pathological JSONC nesting (F6)', () => { + it("parser depth cap fires on pathological JSONC nesting (F6)", () => { // Without `MAX_PARSE_DEPTH`, pathological input like // `'['.repeat(20000) + '0' + ']'.repeat(20000)` triggers a V8 // RangeError ("Maximum call stack size exceeded") that escapes // commander as a raw stringified error — no `OcEmitSentinelError`- // style structured catch. Pin the structured-diagnostic path: // parser must surface OC_JSONC_DEPTH_EXCEEDED, not bare RangeError. - const open = '['.repeat(MAX_TRAVERSAL_DEPTH + 100); - const close = ']'.repeat(MAX_TRAVERSAL_DEPTH + 100); + const open = "[".repeat(MAX_TRAVERSAL_DEPTH + 100); + const close = "]".repeat(MAX_TRAVERSAL_DEPTH + 100); const raw = `${open}0${close}`; const result = parseJsonc(raw); expect(result.ast.root).toBeNull(); - expect( - result.diagnostics.some((d) => d.code === 'OC_JSONC_DEPTH_EXCEEDED'), - ).toBe(true); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "OC_JSONC_DEPTH_EXCEEDED", + ); }); - it('parser depth cap fires on JSONL line with deeply-nested JSON (F6)', () => { + it("parser depth cap fires on JSONL line with deeply-nested JSON (F6)", () => { // Per-line parseJsonc dispatch carries the same protection — each // value line is parsed in isolation and gets its own depth cap. // The line surfaces as `kind: 'malformed'` with the depth diagnostic. @@ -427,137 +434,131 @@ describe('wave-23 pitfalls — performance & limits', () => { for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) { nested = `{"a":${nested}}`; } - const { diagnostics } = parseJsonl(nested + '\n'); + const { diagnostics } = parseJsonl(nested + "\n"); // The line-level diagnostic is OC_JSONL_LINE_MALFORMED (line failed); // we don't promote OC_JSONC_DEPTH_EXCEEDED through the JSONL layer // but the malformed-line detection prevents stack-overflow escape. - expect(diagnostics.some((d) => d.code === 'OC_JSONL_LINE_MALFORMED')).toBe(true); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("OC_JSONL_LINE_MALFORMED"); }); }); // ---------- Coercion pitfalls -------------------------------------------- -describe('wave-23 pitfalls — coercion', () => { - it('P-029 numeric coercion is locale-independent', () => { +describe("wave-23 pitfalls — coercion", () => { + it("P-029 numeric coercion is locale-independent", () => { // `Number()` doesn't honor locale; `parseFloat` doesn't either in // practice, but we never use `parseFloat`. Verify `Number("1,5")` // returns NaN (which is rejected) and `"1.5"` returns 1.5. const ast = parseJsonc('{"x":1.0}').ast; - const r1 = setOcPath(ast, parseOcPath('oc://X/x'), '1.5'); + const r1 = setOcPath(ast, parseOcPath("oc://X/x"), "1.5"); expect(r1.ok).toBe(true); - const r2 = setOcPath(ast, parseOcPath('oc://X/x'), '1,5'); + const r2 = setOcPath(ast, parseOcPath("oc://X/x"), "1,5"); expect(r2.ok).toBe(false); - if (!r2.ok) {expect(r2.reason).toBe('parse-error');} + if (!r2.ok) { + expect(r2.reason).toBe("parse-error"); + } }); - it('P-030 boolean coercion is exact-match lowercase', () => { + it("P-030 boolean coercion is exact-match lowercase", () => { const ast = parseJsonc('{"x":true}').ast; - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'false').ok).toBe(true); - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'False').ok).toBe(false); - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'TRUE').ok).toBe(false); - expect(setOcPath(ast, parseOcPath('oc://X/x'), 'yes').ok).toBe(false); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "false").ok).toBe(true); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "False").ok).toBe(false); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "TRUE").ok).toBe(false); + expect(setOcPath(ast, parseOcPath("oc://X/x"), "yes").ok).toBe(false); }); }); // ---------- Reserved character pitfalls ---------------------------------- -describe('wave-23 pitfalls — reserved characters', () => { - it('P-026 rejects `?` outside the query separator position', () => { +describe("wave-23 pitfalls — reserved characters", () => { + it("P-026 rejects `?` outside the query separator position", () => { // `?` triggers the query split. `oc://X/foo?session=s` is fine // (legitimate query). But `?` *inside* a segment after the query // section is consumed isn't a normal use case — the parser treats // the first `?` as the query split. - expect(parseOcPath('oc://X/foo?session=s').section).toBe('foo'); + expect(parseOcPath("oc://X/foo?session=s").section).toBe("foo"); // Empty key after `?` (no `=`): query parser silently ignores. - expect(() => parseOcPath('oc://X/foo?')).not.toThrow(); + const path = parseOcPath("oc://X/foo?"); + expect(path.section).toBe("foo"); + expect(path.session).toBeUndefined(); }); - it('P-040 negative-index magnitude is bounded', () => { + it("P-040 negative-index magnitude is bounded", () => { // Out-of-range negative index → null at resolve time, not crash. const ast = parseJsonc('{"x":[1,2,3]}').ast; - expect(resolveOcPath(ast, parseOcPath('oc://X/x/-9999999999'))).toBeNull(); - expect(resolveOcPath(ast, parseOcPath('oc://X/x/-1'))?.kind).toBe('leaf'); - }); -}); - -// ---------- Sentinel-redaction pitfall (P-036) --------------------------- - -describe('wave-23 pitfalls — redaction sentinel', () => { - // P-036 is fully covered by wave-21-sentinel-cross-kind. This is a - // smoke test asserting the link is intact. - it('P-036 sentinel guard activates at emit time (covered by wave-21)', () => { - expect(true).toBe(true); + expect(resolveOcPath(ast, parseOcPath("oc://X/x/-9999999999"))).toBeNull(); + expect(resolveOcPath(ast, parseOcPath("oc://X/x/-1"))?.kind).toBe("leaf"); }); }); // ---------- DEFERRED — documented limits --------------------------------- -describe('wave-23 pitfalls — deferred (v0 limits)', () => { - it.skip('P-005 slash literal in key — v1: quoted segments', () => {}); - it.skip('P-006 dot literal in key — v1: quoted segments', () => {}); - it.skip('P-017 nested unions {a,{b,c}} — v1: parser stack', () => {}); - it.skip('P-019 wildcard inside wildcard — v1: pattern composition', () => {}); - it.skip('P-025 leading-zero numeric `01` — v1: explicit form', () => {}); - it.skip('P-027 `&` in segments — v1: percent-encoding', () => {}); - it.skip('P-028 percent-encoded segments — v1: rfc3986 layer', () => {}); - it.skip('P-034 ast mutation between resolve & consume — caller invariant', () => {}); - it.skip('P-035 stale paths from prior find — caller invariant', () => {}); +describe("wave-23 pitfalls — deferred (v0 limits)", () => { + it.todo("P-005 slash literal in key — v1: quoted segments"); + it.todo("P-006 dot literal in key — v1: quoted segments"); + it.todo("P-017 nested unions {a,{b,c}} — v1: parser stack"); + it.todo("P-019 wildcard inside wildcard — v1: pattern composition"); + it.todo("P-025 leading-zero numeric `01` — v1: explicit form"); + it.todo("P-027 `&` in segments — v1: percent-encoding"); + it.todo("P-028 percent-encoded segments — v1: rfc3986 layer"); + it.todo("P-034 ast mutation between resolve & consume — caller invariant"); + it.todo("P-035 stale paths from prior find — caller invariant"); }); // ---------- Injection pitfalls (C12 / W12) ------------------------------- -describe('wave-23 pitfalls — injection (caller-supplied hostile input)', () => { +describe("wave-23 pitfalls — injection (caller-supplied hostile input)", () => { // P-037: a hostile path string. The substrate's job is to either // parse safely or reject with `OcPathError` — never let undefined // behavior leak. These cases lock the rejection-or-safe contract. - it('P-037a control characters in path body are rejected', () => { - expect(() => parseOcPath('oc://a\x00b')).toThrow(OcPathError); - expect(() => parseOcPath('oc://a\x01b/c')).toThrow(OcPathError); - expect(() => parseOcPath('oc://a/b\x1Fc')).toThrow(OcPathError); + it("P-037a control characters in path body are rejected", () => { + expect(() => parseOcPath("oc://a\x00b")).toThrow(OcPathError); + expect(() => parseOcPath("oc://a\x01b/c")).toThrow(OcPathError); + expect(() => parseOcPath("oc://a/b\x1Fc")).toThrow(OcPathError); }); - it('P-037b NUL byte anywhere in path is rejected', () => { - expect(() => parseOcPath('oc://X.md/sec\x00tion')).toThrow(OcPathError); + it("P-037b NUL byte anywhere in path is rejected", () => { + expect(() => parseOcPath("oc://X.md/sec\x00tion")).toThrow(OcPathError); }); - it('P-037c BOM at start of path is stripped, not interpreted', () => { + it("P-037c BOM at start of path is stripped, not interpreted", () => { // BOM is unicode U+FEFF (0xFEFF). The substrate strips it before // scheme check; without stripping, the BOM-prefixed string would // fail the `oc://` scheme test. - const path = parseOcPath('oc://X.md/section'); - expect(path.file).toBe('X.md'); - expect(path.section).toBe('section'); + const path = parseOcPath("oc://X.md/section"); + expect(path.file).toBe("X.md"); + expect(path.section).toBe("section"); }); - it('P-037d session query is parsed only via the documented `?session=...` form', () => { + it("P-037d session query is parsed only via the documented `?session=...` form", () => { // Legal session form parses cleanly. - const ok = parseOcPath('oc://X.md/sec?session=cron:daily'); - expect(ok.section).toBe('sec'); - expect(ok.session).toBe('cron:daily'); + const ok = parseOcPath("oc://X.md/sec?session=cron:daily"); + expect(ok.section).toBe("sec"); + expect(ok.session).toBe("cron:daily"); // Substrate is lenient about loose `?garbage` — caller's // responsibility to construct paths from `formatOcPath`. Confirm // the loose form does NOT silently invent a session value. - const loose = parseOcPath('oc://X.md/sec?garbage'); + const loose = parseOcPath("oc://X.md/sec?garbage"); expect(loose.session).toBeUndefined(); }); - it('P-037e unescaped `&` in segments is rejected', () => { - expect(() => parseOcPath('oc://X.md/a&b')).toThrow(OcPathError); + it("P-037e unescaped `&` in segments is rejected", () => { + expect(() => parseOcPath("oc://X.md/a&b")).toThrow(OcPathError); }); - it('P-037f unescaped `%` in segments is rejected', () => { - expect(() => parseOcPath('oc://X.md/a%b')).toThrow(OcPathError); + it("P-037f unescaped `%` in segments is rejected", () => { + expect(() => parseOcPath("oc://X.md/a%b")).toThrow(OcPathError); }); - it('P-037g empty file slot is rejected', () => { - expect(() => parseOcPath('oc:///section')).toThrow(OcPathError); + it("P-037g empty file slot is rejected", () => { + expect(() => parseOcPath("oc:///section")).toThrow(OcPathError); }); - it('P-037h backslash-escape attempts are not treated as path traversal', () => { + it("P-037h backslash-escape attempts are not treated as path traversal", () => { // No special meaning — the literal backslash is just a regular // character. Doesn't allow escaping forward slashes. - expect(() => parseOcPath('oc://X.md/a\\../b')).toThrow(OcPathError); + expect(() => parseOcPath("oc://X.md/a\\../b")).toThrow(OcPathError); }); // P-038: predicate-value injection. `[k=v]` predicates filter @@ -565,60 +566,60 @@ describe('wave-23 pitfalls — injection (caller-supplied hostile input)', () => // operators must NOT escape the predicate scope or be interpreted // as a regex. - it('P-038a regex metacharacters in predicate value match literally', () => { + it("P-038a regex metacharacters in predicate value match literally", () => { const ast = parseJsonc('{ "items": [ {"name": "a.*"}, {"name": "abc"} ] }').ast; // Looking for the literal string "a.*" — should match only the // first item, not "abc" (which would match if `.*` were treated // as a regex). - const matches = findOcPaths(ast, parseOcPath('oc://X.jsonc/items/[name=a.*]')); + const matches = findOcPaths(ast, parseOcPath("oc://X.jsonc/items/[name=a.*]")); expect(matches).toHaveLength(1); }); - it('P-038b nested-bracket attempts in predicate value are kept literal', () => { + it("P-038b nested-bracket attempts in predicate value are kept literal", () => { // The substrate is permissive on nested brackets — they're part // of the literal predicate value, not interpreted as path syntax. // The match would be against the literal string "a[b]"; a // resolver that finds zero matches fails closed. - const path = parseOcPath('oc://X.jsonc/items/[name=a[b]]'); - expect(path.item).toBe('[name=a[b]]'); + const path = parseOcPath("oc://X.jsonc/items/[name=a[b]]"); + expect(path.item).toBe("[name=a[b]]"); // No data has the literal value `a[b]` here, so finding empty. const ast = parseJsonc('{ "items": [ {"name": "abc"} ] }').ast; expect(findOcPaths(ast, path)).toHaveLength(0); }); - it('P-038c equals-sign in predicate value is treated as part of the value', () => { + it("P-038c equals-sign in predicate value is treated as part of the value", () => { // The FIRST `=` separates key from value; subsequent `=`s belong // to the value. The rule keeps the predicate parser simple — // operators that prefix-match (`!=`, `<=`, `>=`) are tried // before `=`, then `=` consumes the rest. const ast = parseJsonc('{ "items": [ {"k": "a=b"}, {"k": "c"} ] }').ast; - const matches = findOcPaths(ast, parseOcPath('oc://X.jsonc/items/[k=a=b]')); + const matches = findOcPaths(ast, parseOcPath("oc://X.jsonc/items/[k=a=b]")); expect(matches).toHaveLength(1); }); - it('P-038d control characters in predicate value are rejected', () => { - expect(() => parseOcPath('oc://X.jsonc/items/[k=a\x00b]')).toThrow(OcPathError); + it("P-038d control characters in predicate value are rejected", () => { + expect(() => parseOcPath("oc://X.jsonc/items/[k=a\x00b]")).toThrow(OcPathError); }); - it('P-038e empty predicate body is rejected', () => { - expect(() => parseOcPath('oc://X.jsonc/items/[]')).toThrow(OcPathError); + it("P-038e empty predicate body is rejected", () => { + expect(() => parseOcPath("oc://X.jsonc/items/[]")).toThrow(OcPathError); }); - it('P-038f predicate-shaped bracket without operator is treated as literal sentinel', () => { + it("P-038f predicate-shaped bracket without operator is treated as literal sentinel", () => { // `[name]` without `=` is parsed as a literal-bracket sentinel // (e.g. `[frontmatter]`-style). The substrate accepts it as a // literal path segment — predicate parsing only kicks in when an // operator is present. Document this to lock the behavior. - const path = parseOcPath('oc://X.jsonc/items/[name]'); - expect(path.item).toBe('[name]'); + const path = parseOcPath("oc://X.jsonc/items/[name]"); + expect(path.item).toBe("[name]"); }); - it('P-038g predicate-shaped bracket with unsupported operator parses as literal', () => { + it("P-038g predicate-shaped bracket with unsupported operator parses as literal", () => { // `~` isn't in the supported-operator set; the parser doesn't // recognize it as a predicate, so it's accepted as a literal // bracket segment. This is the documented v1.1 behavior — a // future version may add `~` (regex) and bump SDK_VERSION. - const path = parseOcPath('oc://X.jsonc/items/[k~v]'); - expect(path.item).toBe('[k~v]'); + const path = parseOcPath("oc://X.jsonc/items/[k~v]"); + expect(path.item).toBe("[k~v]"); }); }); diff --git a/src/oc-path/tests/scenarios/real-world-fixtures.test.ts b/src/oc-path/tests/scenarios/real-world-fixtures.test.ts index f633d08fa66..b4ab6c8e214 100644 --- a/src/oc-path/tests/scenarios/real-world-fixtures.test.ts +++ b/src/oc-path/tests/scenarios/real-world-fixtures.test.ts @@ -5,24 +5,24 @@ * filename) — each parsed, resolved, and round-tripped to verify the * substrate handles realistic content. */ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { join, dirname } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { emitMd } from '../../emit.js'; -import { parseMd } from '../../parse.js'; -import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { parseMd } from "../../parse.js"; +import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const HERE = dirname(fileURLToPath(import.meta.url)); -const FIXTURES = join(HERE, '..', 'fixtures', 'real'); +const FIXTURES = join(HERE, "..", "fixtures", "real"); function load(name: string): string { - return readFileSync(join(FIXTURES, name), 'utf-8'); + return readFileSync(join(FIXTURES, name), "utf-8"); } -describe('wave-12 real-world-fixtures', () => { - it('F-01 SOUL.md parses + round-trips', () => { - const raw = load('SOUL.md'); +describe("wave-12 real-world-fixtures", () => { + it("F-01 SOUL.md parses + round-trips", () => { + const raw = load("SOUL.md"); const { ast, diagnostics } = parseMd(raw); expect(diagnostics).toEqual([]); expect(emitMd(ast)).toBe(raw); @@ -30,107 +30,109 @@ describe('wave-12 real-world-fixtures', () => { expect(ast.blocks.length).toBeGreaterThan(0); }); - it('F-02 AGENTS.md parses + resolves Tools section', () => { - const raw = load('AGENTS.md'); + it("F-02 AGENTS.md parses + resolves Tools section", () => { + const raw = load("AGENTS.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - const tools = resolveOcPath(ast, { file: 'AGENTS.md', section: 'tools' }); - expect(tools?.kind).toBe('block'); - if (tools?.kind === 'block') { - expect(tools.node.items.some((i) => i.kv?.key === 'gh')).toBe(true); + const tools = resolveOcPath(ast, { file: "AGENTS.md", section: "tools" }); + expect(tools?.kind).toBe("block"); + if (tools?.kind === "block") { + expect(tools.node.items.map((item) => item.kv?.key)).toContain("gh"); } }); - it('F-03 MEMORY.md frontmatter scope resolves via [frontmatter]', () => { - const raw = load('MEMORY.md'); + it("F-03 MEMORY.md frontmatter scope resolves via [frontmatter]", () => { + const raw = load("MEMORY.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const scope = resolveOcPath(ast, { - file: 'MEMORY.md', - section: '[frontmatter]', - field: 'scope', + file: "MEMORY.md", + section: "[frontmatter]", + field: "scope", }); - expect(scope?.kind).toBe('frontmatter'); - if (scope?.kind === 'frontmatter') {expect(scope.node.value).toBe('project');} - }); - - it('F-04 TOOLS.md table extracted from Tool Guidance section', () => { - const raw = load('TOOLS.md'); - const { ast } = parseMd(raw); - expect(emitMd(ast)).toBe(raw); - const guidance = resolveOcPath(ast, { - file: 'TOOLS.md', - section: 'tool-guidance', - }); - expect(guidance?.kind).toBe('block'); - if (guidance?.kind === 'block') { - expect(guidance.node.tables.length).toBeGreaterThan(0); - expect(guidance.node.tables[0]?.headers).toEqual(['tool', 'guidance']); + expect(scope?.kind).toBe("frontmatter"); + if (scope?.kind === "frontmatter") { + expect(scope.node.value).toBe("project"); } }); - it('F-05 IDENTITY.md sections resolvable by slug', () => { - const raw = load('IDENTITY.md'); + it("F-04 TOOLS.md table extracted from Tool Guidance section", () => { + const raw = load("TOOLS.md"); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const guidance = resolveOcPath(ast, { + file: "TOOLS.md", + section: "tool-guidance", + }); + expect(guidance?.kind).toBe("block"); + if (guidance?.kind === "block") { + expect(guidance.node.tables.length).toBeGreaterThan(0); + expect(guidance.node.tables[0]?.headers).toEqual(["tool", "guidance"]); + } + }); + + it("F-05 IDENTITY.md sections resolvable by slug", () => { + const raw = load("IDENTITY.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const trust = resolveOcPath(ast, { - file: 'IDENTITY.md', - section: 'trust-level', + file: "IDENTITY.md", + section: "trust-level", }); - expect(trust?.kind).toBe('block'); + expect(trust?.kind).toBe("block"); }); - it('F-06 USER.md Preferences items extracted', () => { - const raw = load('USER.md'); + it("F-06 USER.md Preferences items extracted", () => { + const raw = load("USER.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const prefs = resolveOcPath(ast, { - file: 'USER.md', - section: 'preferences', + file: "USER.md", + section: "preferences", }); - expect(prefs?.kind).toBe('block'); - if (prefs?.kind === 'block') { + expect(prefs?.kind).toBe("block"); + if (prefs?.kind === "block") { expect(prefs.node.items.length).toBeGreaterThan(0); } }); - it('F-07 HEARTBEAT.md schedules — H2 sections as triggers', () => { - const raw = load('HEARTBEAT.md'); + it("F-07 HEARTBEAT.md schedules — H2 sections as triggers", () => { + const raw = load("HEARTBEAT.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); expect(ast.blocks.length).toBeGreaterThanOrEqual(3); const slugs = ast.blocks.map((b) => b.slug); - expect(slugs).toContain('every-30m-wake'); - expect(slugs).toContain('every-4h-wake'); + expect(slugs).toContain("every-30m-wake"); + expect(slugs).toContain("every-4h-wake"); }); - it('F-08 SKILL.md frontmatter has name + description + tier', () => { - const raw = load('SKILL.md'); + it("F-08 SKILL.md frontmatter has name + description + tier", () => { + const raw = load("SKILL.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); const fmKeys = ast.frontmatter.map((e) => e.key); - expect(fmKeys).toContain('name'); - expect(fmKeys).toContain('description'); - expect(fmKeys).toContain('tier'); + expect(fmKeys).toContain("name"); + expect(fmKeys).toContain("description"); + expect(fmKeys).toContain("tier"); }); - it('F-09 BOOTSTRAP.md round-trips', () => { - const raw = load('BOOTSTRAP.md'); + it("F-09 BOOTSTRAP.md round-trips", () => { + const raw = load("BOOTSTRAP.md"); const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); }); - it('F-10 all 8 fixtures combined round-trip-clean (sanity)', () => { + it("F-10 all 8 fixtures combined round-trip-clean (sanity)", () => { const names = [ - 'SOUL.md', - 'AGENTS.md', - 'MEMORY.md', - 'TOOLS.md', - 'IDENTITY.md', - 'USER.md', - 'HEARTBEAT.md', - 'SKILL.md', - 'BOOTSTRAP.md', + "SOUL.md", + "AGENTS.md", + "MEMORY.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "SKILL.md", + "BOOTSTRAP.md", ]; for (const name of names) { const raw = load(name); diff --git a/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts b/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts index 5c247efbbd5..18b0d9cfb5b 100644 --- a/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts +++ b/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts @@ -10,22 +10,19 @@ * pre-existing-byte detection (e.g., LKG fingerprint verification) * opt in via `acceptPreExistingSentinel: false`. */ -import { describe, expect, it } from 'vitest'; -import { setJsoncOcPath } from '../../jsonc/edit.js'; -import { emitMd } from '../../emit.js'; -import { emitJsonc } from '../../jsonc/emit.js'; -import { parseJsonc } from '../../jsonc/parse.js'; -import { emitJsonl } from '../../jsonl/emit.js'; -import { parseJsonl } from '../../jsonl/parse.js'; -import { parseOcPath } from '../../oc-path.js'; -import { parseMd } from '../../parse.js'; -import { - OcEmitSentinelError, - REDACTED_SENTINEL, -} from '../../sentinel.js'; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { setJsoncOcPath } from "../../jsonc/edit.js"; +import { emitJsonc } from "../../jsonc/emit.js"; +import { parseJsonc } from "../../jsonc/parse.js"; +import { emitJsonl } from "../../jsonl/emit.js"; +import { parseJsonl } from "../../jsonl/parse.js"; +import { parseOcPath } from "../../oc-path.js"; +import { parseMd } from "../../parse.js"; +import { OcEmitSentinelError, REDACTED_SENTINEL } from "../../sentinel.js"; -describe('wave-21 sentinel guard cross-kind', () => { - it('S-01 jsonc round-trip echoes safely when raw contains pre-existing sentinel', () => { +describe("wave-21 sentinel guard cross-kind", () => { + it("S-01 jsonc round-trip echoes safely when raw contains pre-existing sentinel", () => { // Pre-existing sentinel bytes are trusted — see emit-policy comment // in jsonc/emit.ts. The strict mode below is the opt-in path for // callers who want LKG-style fingerprint verification. @@ -34,91 +31,81 @@ describe('wave-21 sentinel guard cross-kind', () => { expect(emitJsonc(ast)).toBe(raw); // Strict mode still rejects pre-existing sentinel for callers who // explicitly opt in. - expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-02 jsonl round-trip echoes safely; strict mode rejects', () => { + it("S-02 jsonl round-trip echoes safely; strict mode rejects", () => { const raw = `{"x":"${REDACTED_SENTINEL}"}\n`; const ast = parseJsonl(raw).ast; expect(emitJsonl(ast)).toBe(raw); - expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-03 md round-trip echoes safely; strict mode rejects', () => { + it("S-03 md round-trip echoes safely; strict mode rejects", () => { const raw = `## Body\n\n- ${REDACTED_SENTINEL}\n`; const ast = parseMd(raw).ast; expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-04 jsonc render mode walks every leaf for sentinel', () => { + it("S-04 jsonc render mode walks every leaf for sentinel", () => { const ast = parseJsonc('{ "x": "ok" }').ast; const tampered = { ...ast, root: { - kind: 'object' as const, + kind: "object" as const, entries: [ { - key: 'x', + key: "x", line: 1, - value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + value: { kind: "string" as const, value: REDACTED_SENTINEL }, }, ], }, }; - expect(() => emitJsonc(tampered, { mode: 'render' })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonc(tampered, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-05 jsonl render mode walks every value-line leaf', () => { + it("S-05 jsonl render mode walks every value-line leaf", () => { const ast = parseJsonl('{"a":"ok"}\n').ast; const tampered = { ...ast, lines: [ { - kind: 'value' as const, + kind: "value" as const, line: 1, raw: '{"a":"ok"}', value: { - kind: 'object' as const, + kind: "object" as const, entries: [ { - key: 'a', + key: "a", line: 1, - value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + value: { kind: "string" as const, value: REDACTED_SENTINEL }, }, ], }, }, ], }; - expect(() => emitJsonl(tampered, { mode: 'render' })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonl(tampered, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-06 setJsoncOcPath itself throws when the new value contains the sentinel', () => { + it("S-06 setJsoncOcPath itself throws when the new value contains the sentinel", () => { // The substrate guard fires at write-time: setJsoncOcPath rebuilds // raw via render mode emit, which scans every leaf. Defense-in-depth // — even if a caller forgets to call emit afterward, the sentinel // can't make it into an in-memory AST that pretends to be valid. const ast = parseJsonc('{ "x": "ok" }').ast; expect(() => - setJsoncOcPath(ast, parseOcPath('oc://config/x'), { - kind: 'string', + setJsoncOcPath(ast, parseOcPath("oc://config/x"), { + kind: "string", value: REDACTED_SENTINEL, }), ).toThrow(OcEmitSentinelError); }); - it('S-07 sentinel embedded in deep nesting — render mode catches the leaf', () => { + it("S-07 sentinel embedded in deep nesting — render mode catches the leaf", () => { // Round-trip echoes the pre-existing bytes (the workspace contract: // a parsed file containing the sentinel as data is not "writing" it // on emit). Render mode walks every leaf and rejects this caller- @@ -126,52 +113,48 @@ describe('wave-21 sentinel guard cross-kind', () => { const raw = JSON.stringify({ a: { b: { c: REDACTED_SENTINEL } } }); const ast = parseJsonc(raw).ast; expect(emitJsonc(ast)).toBe(raw); // round-trip echo - expect(() => emitJsonc(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitJsonc(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-08 sentinel inside an array element triggers guard in render mode', () => { - const raw = JSON.stringify({ arr: ['ok', REDACTED_SENTINEL, 'ok'] }); + it("S-08 sentinel inside an array element triggers guard in render mode", () => { + const raw = JSON.stringify({ arr: ["ok", REDACTED_SENTINEL, "ok"] }); const ast = parseJsonc(raw).ast; - expect(() => emitJsonc(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitJsonc(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-09 sentinel as object key in raw — strict mode catches it', () => { + it("S-09 sentinel as object key in raw — strict mode catches it", () => { const raw = `{ "${REDACTED_SENTINEL}": 1 }`; const ast = parseJsonc(raw).ast; expect(emitJsonc(ast)).toBe(raw); // default-mode echo - expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-10 sentinel in jsonl malformed line — strict mode catches it', () => { + it("S-10 sentinel in jsonl malformed line — strict mode catches it", () => { const raw = `${REDACTED_SENTINEL}\n`; const ast = parseJsonl(raw).ast; expect(emitJsonl(ast)).toBe(raw); // round-trip echoes verbatim - expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-11 partial sentinel substring does NOT trigger guard', () => { + it("S-11 partial sentinel substring does NOT trigger guard", () => { const raw = '{ "x": "OPENCLAW_REDACTED" }'; const ast = parseJsonc(raw).ast; - expect(() => emitJsonc(ast)).not.toThrow(); + expect(emitJsonc(ast)).toBe(raw); }); - it('S-12 sentinel guard error message includes the OcPath context (render mode)', () => { + it("S-12 sentinel guard error message includes the OcPath context (render mode)", () => { // Render mode is the path that actually rejects caller-injected // sentinel — round-trip just echoes, so the error context surfaces // when render walks the offending leaf and constructs the path. const raw = `{ "secret": "${REDACTED_SENTINEL}" }`; const ast = parseJsonc(raw).ast; try { - emitJsonc(ast, { mode: 'render', fileNameForGuard: 'config' }); - expect.fail('should have thrown'); + emitJsonc(ast, { mode: "render", fileNameForGuard: "config" }); + expect.fail("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(OcEmitSentinelError); - expect(String(e)).toContain('oc://'); - expect(String(e)).toContain('config'); + expect(String(e)).toContain("oc://"); + expect(String(e)).toContain("config"); } }); }); diff --git a/src/oc-path/tests/scenarios/sentinel-guard.test.ts b/src/oc-path/tests/scenarios/sentinel-guard.test.ts index b0865574518..e637b5c05ac 100644 --- a/src/oc-path/tests/scenarios/sentinel-guard.test.ts +++ b/src/oc-path/tests/scenarios/sentinel-guard.test.ts @@ -5,104 +5,98 @@ * emitted bytes throws `OcEmitSentinelError`. Round-trip mode catches * sentinels in `raw`; render mode walks every leaf. */ -import { describe, expect, it } from 'vitest'; -import { emitMd } from '../../emit.js'; -import { parseMd } from '../../parse.js'; -import { - OcEmitSentinelError, - REDACTED_SENTINEL, - guardSentinel, -} from '../../sentinel.js'; +import { describe, expect, it } from "vitest"; +import { emitMd } from "../../emit.js"; +import { parseMd } from "../../parse.js"; +import { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "../../sentinel.js"; -describe('wave-09 sentinel-guard', () => { - it('S-01 sentinel constant matches the literal', () => { - expect(REDACTED_SENTINEL).toBe('__OPENCLAW_REDACTED__'); +describe("wave-09 sentinel-guard", () => { + it("S-01 sentinel constant matches the literal", () => { + expect(REDACTED_SENTINEL).toBe("__OPENCLAW_REDACTED__"); }); - it('S-02 guardSentinel passes normal strings', () => { - expect(() => guardSentinel('safe', 'oc://X.md')).not.toThrow(); + it("S-02 guardSentinel passes normal strings", () => { + expect(guardSentinel("safe", "oc://X.md")).toBeUndefined(); }); - it('S-03 guardSentinel passes non-string types', () => { - expect(() => guardSentinel(42, 'oc://X.md')).not.toThrow(); - expect(() => guardSentinel(null, 'oc://X.md')).not.toThrow(); - expect(() => guardSentinel(undefined, 'oc://X.md')).not.toThrow(); - expect(() => guardSentinel({}, 'oc://X.md')).not.toThrow(); + it("S-03 guardSentinel passes non-string types", () => { + expect(guardSentinel(42, "oc://X.md")).toBeUndefined(); + expect(guardSentinel(null, "oc://X.md")).toBeUndefined(); + expect(guardSentinel(undefined, "oc://X.md")).toBeUndefined(); + expect(guardSentinel({}, "oc://X.md")).toBeUndefined(); }); - it('S-04 guardSentinel throws on exact match', () => { - expect(() => guardSentinel(REDACTED_SENTINEL, 'oc://X.md')).toThrow(OcEmitSentinelError); + it("S-04 guardSentinel throws on exact match", () => { + expect(() => guardSentinel(REDACTED_SENTINEL, "oc://X.md")).toThrow(OcEmitSentinelError); }); - it('S-05 guardSentinel throws on substring matches (sentinel embedded in larger string)', () => { + it("S-05 guardSentinel throws on substring matches (sentinel embedded in larger string)", () => { // Substring scan — the sentinel anywhere in the value is a leak, // not just exact equality. A hostile caller smuggling // `prefix__OPENCLAW_REDACTED__suffix` would have bypassed the old // equality check; substring scan closes the gap. - expect(() => guardSentinel(`prefix${REDACTED_SENTINEL}suffix`, 'oc://X.md')).toThrow( + expect(() => guardSentinel(`prefix${REDACTED_SENTINEL}suffix`, "oc://X.md")).toThrow( OcEmitSentinelError, ); }); - it('S-06 error attaches the OcPath context', () => { + it("S-06 error attaches the OcPath context", () => { try { - guardSentinel(REDACTED_SENTINEL, 'oc://config/plugins.entries.foo.token'); - expect.fail('should have thrown'); + guardSentinel(REDACTED_SENTINEL, "oc://config/plugins.entries.foo.token"); + expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(OcEmitSentinelError); const e = err as OcEmitSentinelError; - expect(e.path).toBe('oc://config/plugins.entries.foo.token'); - expect(e.code).toBe('OC_EMIT_SENTINEL'); + expect(e.path).toBe("oc://config/plugins.entries.foo.token"); + expect(e.code).toBe("OC_EMIT_SENTINEL"); } }); - it('S-07 round-trip echoes pre-existing sentinel; strict mode rejects', () => { - const raw = '## Section\n\n- token: __OPENCLAW_REDACTED__\n'; + it("S-07 round-trip echoes pre-existing sentinel; strict mode rejects", () => { + const raw = "## Section\n\n- token: __OPENCLAW_REDACTED__\n"; const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-08 round-trip emit allows sentinel-free content', () => { - const raw = '## Section\n\n- token: redacted-but-not-sentinel\n'; + it("S-08 round-trip emit allows sentinel-free content", () => { + const raw = "## Section\n\n- token: redacted-but-not-sentinel\n"; const { ast } = parseMd(raw); - expect(() => emitMd(ast)).not.toThrow(); + expect(emitMd(ast)).toBe(raw); }); - it('S-09 render mode catches sentinel in frontmatter', () => { + it("S-09 render mode catches sentinel in frontmatter", () => { const ast = { kind: "md" as const, - raw: '', - frontmatter: [{ key: 'token', value: REDACTED_SENTINEL, line: 2 }], - preamble: '', + raw: "", + frontmatter: [{ key: "token", value: REDACTED_SENTINEL, line: 2 }], + preamble: "", blocks: [], }; - expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-10 render mode catches sentinel in preamble', () => { + it("S-10 render mode catches sentinel in preamble", () => { const ast = { kind: "md" as const, - raw: '', + raw: "", frontmatter: [], preamble: REDACTED_SENTINEL, blocks: [], }; - expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-11 render mode catches sentinel in block bodyText', () => { + it("S-11 render mode catches sentinel in block bodyText", () => { const ast = { kind: "md" as const, - raw: '', + raw: "", frontmatter: [], - preamble: '', + preamble: "", blocks: [ { - heading: 'Sec', - slug: 'sec', + heading: "Sec", + slug: "sec", line: 1, bodyText: REDACTED_SENTINEL, items: [], @@ -111,27 +105,27 @@ describe('wave-09 sentinel-guard', () => { }, ], }; - expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError); }); - it('S-12 render mode catches sentinel in item kv.value', () => { + it("S-12 render mode catches sentinel in item kv.value", () => { const ast = { kind: "md" as const, - raw: '', + raw: "", frontmatter: [], - preamble: '', + preamble: "", blocks: [ { - heading: 'S', - slug: 's', + heading: "S", + slug: "s", line: 1, - bodyText: '- t: x', + bodyText: "- t: x", items: [ { - text: 't: x', - slug: 't', + text: "t: x", + slug: "t", line: 2, - kv: { key: 't', value: REDACTED_SENTINEL }, + kv: { key: "t", value: REDACTED_SENTINEL }, }, ], tables: [], @@ -139,42 +133,38 @@ describe('wave-09 sentinel-guard', () => { }, ], }; - expect(() => emitMd(ast, { mode: 'render', fileNameForGuard: 'AGENTS.md' })).toThrow( + expect(() => emitMd(ast, { mode: "render", fileNameForGuard: "AGENTS.md" })).toThrow( OcEmitSentinelError, ); }); - it('S-13 sentinel-as-substring in raw — strict mode catches it', () => { + it("S-13 sentinel-as-substring in raw — strict mode catches it", () => { const raw = `Some prose ${REDACTED_SENTINEL} more prose.\n`; const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-14 multiple sentinel occurrences in raw — strict mode catches them', () => { + it("S-14 multiple sentinel occurrences in raw — strict mode catches them", () => { const raw = `## A\n${REDACTED_SENTINEL}\n${REDACTED_SENTINEL}\n`; const { ast } = parseMd(raw); expect(emitMd(ast)).toBe(raw); - expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( - OcEmitSentinelError, - ); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError); }); - it('S-15 fileNameForGuard appears in the error path', () => { + it("S-15 fileNameForGuard appears in the error path", () => { const ast = { kind: "md" as const, - raw: '', - frontmatter: [{ key: 'token', value: REDACTED_SENTINEL, line: 2 }], - preamble: '', + raw: "", + frontmatter: [{ key: "token", value: REDACTED_SENTINEL, line: 2 }], + preamble: "", blocks: [], }; try { - emitMd(ast, { mode: 'render', fileNameForGuard: 'config' }); - expect.fail('should have thrown'); + emitMd(ast, { mode: "render", fileNameForGuard: "config" }); + expect.fail("should have thrown"); } catch (err) { - expect((err as OcEmitSentinelError).path).toContain('config'); + expect((err as OcEmitSentinelError).path).toContain("config"); } }); }); diff --git a/src/oc-path/tests/sentinel.test.ts b/src/oc-path/tests/sentinel.test.ts index 980527ac1fe..229d1dac6f6 100644 --- a/src/oc-path/tests/sentinel.test.ts +++ b/src/oc-path/tests/sentinel.test.ts @@ -1,36 +1,32 @@ -import { describe, expect, it } from 'vitest'; -import { - OcEmitSentinelError, - REDACTED_SENTINEL, - guardSentinel, -} from '../sentinel.js'; +import { describe, expect, it } from "vitest"; +import { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "../sentinel.js"; -describe('guardSentinel', () => { - it('passes through normal strings', () => { - expect(() => guardSentinel('normal value', 'oc://SOUL.md')).not.toThrow(); +describe("guardSentinel", () => { + it("passes through normal strings", () => { + expect(guardSentinel("normal value", "oc://SOUL.md")).toBeUndefined(); }); - it('passes through non-string values', () => { - expect(() => guardSentinel(42, 'oc://SOUL.md')).not.toThrow(); - expect(() => guardSentinel(null, 'oc://SOUL.md')).not.toThrow(); - expect(() => guardSentinel(undefined, 'oc://SOUL.md')).not.toThrow(); + it("passes through non-string values", () => { + expect(guardSentinel(42, "oc://SOUL.md")).toBeUndefined(); + expect(guardSentinel(null, "oc://SOUL.md")).toBeUndefined(); + expect(guardSentinel(undefined, "oc://SOUL.md")).toBeUndefined(); }); - it('throws on the sentinel literal', () => { - expect(() => guardSentinel(REDACTED_SENTINEL, 'oc://SOUL.md/[fm]/token')).toThrow( + it("throws on the sentinel literal", () => { + expect(() => guardSentinel(REDACTED_SENTINEL, "oc://SOUL.md/[fm]/token")).toThrow( OcEmitSentinelError, ); }); - it('attaches the OcPath in the error', () => { + it("attaches the OcPath in the error", () => { try { - guardSentinel(REDACTED_SENTINEL, 'oc://config/plugins.entries.foo.token'); - expect.fail('should have thrown'); + guardSentinel(REDACTED_SENTINEL, "oc://config/plugins.entries.foo.token"); + expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(OcEmitSentinelError); const e = err as OcEmitSentinelError; - expect(e.path).toBe('oc://config/plugins.entries.foo.token'); - expect(e.code).toBe('OC_EMIT_SENTINEL'); + expect(e.path).toBe("oc://config/plugins.entries.foo.token"); + expect(e.code).toBe("OC_EMIT_SENTINEL"); } }); }); diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index eccddd61157..c130dcbf2b1 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -78,6 +78,17 @@ function setDefaultRandomIntMock() { }); } +function requireFirstPairingRequest( + requests: Awaited>, +) { + expect(requests).toHaveLength(1); + const [request] = requests; + if (!request) { + throw new Error("expected pairing request"); + } + return request; +} + async function withTempStateDir(fn: (stateDir: string) => Promise) { const dir = path.join(fixtureRoot, `case-${caseId++}`); fsSync.mkdirSync(dir, { recursive: true }); @@ -199,7 +210,13 @@ async function expectAllowFromCacheInvalidationWithReadSpy(params: { } function countFileReads(spy: { mock: { calls: unknown[][] } }, filePath: string): number { - return spy.mock.calls.filter(([candidate]) => candidate === filePath).length; + let count = 0; + for (const [candidate] of spy.mock.calls) { + if (candidate === filePath) { + count++; + } + } + return count; } async function seedDefaultAccountAllowFromFixture(stateDir: string) { @@ -277,10 +294,8 @@ async function expectPendingPairingRequestsIsolatedByAccount(params: { process.env, params.secondAccountId, ); - expect(firstList).toHaveLength(1); - expect(secondList).toHaveLength(1); - expect(firstList[0]?.code).toBe(first.code); - expect(secondList[0]?.code).toBe(second.code); + expect(requireFirstPairingRequest(firstList).code).toBe(first.code); + expect(requireFirstPairingRequest(secondList).code).toBe(second.code); } describe("pairing store", () => { @@ -300,8 +315,7 @@ describe("pairing store", () => { expect(second.created).toBe(false); expect(second.code).toBe(first.code); const reusedList = await listChannelPairingRequests("demo-pairing-a"); - expect(reusedList).toHaveLength(1); - expect(reusedList[0]?.code).toBe(first.code); + expect(requireFirstPairingRequest(reusedList).code).toBe(first.code); const created = await upsertChannelPairingRequest({ channel: "demo-pairing-b", @@ -435,7 +449,7 @@ describe("pairing store", () => { channel: "telegram", code: created.code, }); - expect(approved?.id).toBe("67890"); + expect(approved).toMatchObject({ id: "67890" }); await expectAccountScopedEntryIsolated("67890"); const filtered = await createTelegramPairingRequest("yy", "filtered"); diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index 851d3168777..0fd22d52b9f 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -39,6 +39,7 @@ const loadPluginManifestRegistryForPluginRegistry = vi.hoisted(() => google: { aliases: { "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", }, }, xai: { @@ -139,6 +140,10 @@ describe("plugin activation boundary", () => { provider: "google", model: "gemini-3.1-pro-preview", }); + expect(normalizeModelRef("google", "gemini-3-pro-preview", staticNormalize)).toEqual({ + provider: "google", + model: "gemini-3.1-pro-preview", + }); expect(normalizeModelRef("xai", "grok-4-fast-reasoning", staticNormalize)).toEqual({ provider: "xai", model: "grok-4-fast", diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index cf253e1b252..dfb86348f31 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -166,7 +166,8 @@ describe("channel-streaming", () => { }); it("uses auto progress labels when no explicit label is configured", () => { - expect(DEFAULT_PROGRESS_DRAFT_LABELS.every((label) => label.endsWith("..."))).toBe(true); + const invalidLabels = DEFAULT_PROGRESS_DRAFT_LABELS.filter((label) => !label.endsWith("...")); + expect(invalidLabels).toEqual([]); expect(resolveChannelProgressDraftLabel({ random: () => 0 })).toBe( DEFAULT_PROGRESS_DRAFT_LABELS[0], ); @@ -210,16 +211,16 @@ describe("channel-streaming", () => { lines: [" tool: read ", "patch applied", "tests done"], formatLine: (line) => `\`${line}\``, }), - ).toBe("Shelling\n• `patch applied`\n• `tests done`"); + ).toBe("• `patch applied`\n• `tests done`"); expect( formatChannelProgressDraftText({ entry, lines: ["🛠️ Exec", "plain update"], }), - ).toBe("Shelling\n🛠️ Exec\n• plain update"); + ).toBe("🛠️ Exec\n• plain update"); }); - it("preserves progress labels above rolling lines", () => { + it("renders progress labels as rolling lines", () => { const entry = { streaming: { progress: { label: "Shelling", maxLines: 3 } } }; expect( @@ -227,7 +228,7 @@ describe("channel-streaming", () => { entry, lines: ["🛠️ Exec", "📖 Read", "🩹 Patch"], }), - ).toBe("Shelling\n🛠️ Exec\n📖 Read\n🩹 Patch"); + ).toBe("🛠️ Exec\n📖 Read\n🩹 Patch"); }); it("renders structured progress lines with compact details", () => { diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index a18c583940c..e8d01ae3394 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -792,21 +792,25 @@ export function formatChannelProgressDraftText(params: { const maxLines = resolveChannelProgressDraftMaxLines(params.entry); const formatLine = params.formatLine ?? ((line: string) => line); const bullet = params.bullet ?? "•"; - const progressLines = params.lines + const rawLines: Array = label + ? [{ draftLabel: label }, ...params.lines] + : params.lines; + const lines = rawLines .map((line) => { - const rawText = typeof line === "string" ? line : getProgressDraftLineText(line); + const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line; + const rawText = isLabelLine + ? line.draftLabel + : typeof line === "string" + ? line + : getProgressDraftLineText(line); const text = compactChannelProgressDraftLine(rawText, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS); - return text ? { text, isLabelLine: false } : undefined; + return text ? { text, isLabelLine } : undefined; }) .filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line)) .slice(-maxLines) - .map(({ text }) => { - const formatted = formatLine(text); - return shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; + .map(({ text, isLabelLine }) => { + const formatted = isLabelLine ? text : formatLine(text); + return !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; }); - const labelLine = label - ? compactChannelProgressDraftLine(label, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS) - : ""; - const lines = [...(labelLine ? [labelLine] : []), ...progressLines]; return lines.filter((line): line is string => Boolean(line)).join("\n"); } diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index ed239ea3904..db8cfd590aa 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -242,7 +242,8 @@ describe("plugin-sdk facade runtime", () => { expect(loaded.marker).toBe("post-load-ok"); expect(reentryMarkers.length).toBeGreaterThan(0); - expect(reentryMarkers.every((marker) => marker === "post-load-ok")).toBe(true); + const unexpectedReentryMarkers = reentryMarkers.filter((marker) => marker !== "post-load-ok"); + expect(unexpectedReentryMarkers).toEqual([]); expect(listImportedBundledPluginFacadeIds()).toEqual(["demo"]); expect(loader).toHaveBeenCalledTimes(1); }); diff --git a/src/plugin-sdk/keyed-async-queue.test.ts b/src/plugin-sdk/keyed-async-queue.test.ts index d68cc22a8b9..7047c8240f2 100644 --- a/src/plugin-sdk/keyed-async-queue.test.ts +++ b/src/plugin-sdk/keyed-async-queue.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js"; function deferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/plugin-sdk/memory-host-events.test.ts b/src/plugin-sdk/memory-host-events.test.ts index 92fa5c6844b..fa2f8269c74 100644 --- a/src/plugin-sdk/memory-host-events.test.ts +++ b/src/plugin-sdk/memory-host-events.test.ts @@ -135,8 +135,10 @@ describe("createClaimableDedupe", () => { await expect(dedupe.claim("line:evt-1")).resolves.toEqual({ kind: "duplicate" }); const claims = await Promise.all([dedupe.claim("line:race-1"), dedupe.claim("line:race-1")]); - expect(claims.filter((claim) => claim.kind === "claimed")).toHaveLength(1); - expect(claims.filter((claim) => claim.kind === "inflight")).toHaveLength(1); + const countClaimKind = (kind: (typeof claims)[number]["kind"]) => + claims.reduce((count, claim) => count + (claim.kind === kind ? 1 : 0), 0); + expect(countClaimKind("claimed")).toBe(1); + expect(countClaimKind("inflight")).toBe(1); const waitingClaim = claims.find((claim) => claim.kind === "inflight"); await expect(dedupe.commit("line:race-1")).resolves.toBe(true); diff --git a/src/plugin-sdk/provider-catalog-shared.test.ts b/src/plugin-sdk/provider-catalog-shared.test.ts index 1a39fcfc56a..1eb68331314 100644 --- a/src/plugin-sdk/provider-catalog-shared.test.ts +++ b/src/plugin-sdk/provider-catalog-shared.test.ts @@ -59,7 +59,7 @@ describe("provider-catalog-shared native streaming usage compat", () => { }); describe("provider-catalog-shared configured catalog entries", () => { - it("preserves configured audio and video input modalities", () => { + it("preserves configured audio and video input modalities while normalizing nested Gemini ids", () => { expect( readConfiguredProviderCatalogEntries({ providerId: "kilocode", @@ -88,7 +88,7 @@ describe("provider-catalog-shared configured catalog entries", () => { ).toEqual([ { provider: "kilocode", - id: "google/gemini-3-pro-preview", + id: "google/gemini-3.1-pro-preview", name: "Gemini 3 Pro Preview", input: ["text", "image", "video", "audio"], reasoning: true, diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index 850178720e6..47c1bc121c8 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -13,6 +13,7 @@ import type { ModelCatalogModel, ModelCatalogTieredCost, } from "../model-catalog/types.js"; +import { normalizeGooglePreviewModelId } from "./provider-model-id-normalize.js"; import type { ModelProviderConfig } from "./provider-model-shared.js"; export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js"; @@ -158,6 +159,17 @@ function resolveConfiguredProviderModels( return Array.isArray(providerConfig.models) ? providerConfig.models : []; } +function normalizeConfiguredProviderCatalogModelId(id: string): string { + const trimmed = id.trim(); + const googlePrefix = "google/"; + if (!trimmed.startsWith(googlePrefix)) { + return trimmed; + } + const modelId = trimmed.slice(googlePrefix.length); + const normalizedModelId = normalizeGooglePreviewModelId(modelId); + return normalizedModelId === modelId ? trimmed : `${googlePrefix}${normalizedModelId}`; +} + export function readConfiguredProviderCatalogEntries(params: { config?: OpenClawConfig; providerId: string; @@ -174,7 +186,9 @@ export function readConfiguredProviderCatalogEntries(params: { if (!id) { continue; } - const name = (typeof model.name === "string" ? model.name : id).trim() || id; + const normalizedId = normalizeConfiguredProviderCatalogModelId(id); + const name = + (typeof model.name === "string" ? model.name : normalizedId).trim() || normalizedId; const contextWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow @@ -183,7 +197,7 @@ export function readConfiguredProviderCatalogEntries(params: { const input = normalizeConfiguredCatalogModelInput(model.input); entries.push({ provider, - id, + id: normalizedId, name, ...(contextWindow ? { contextWindow } : {}), ...(reasoning !== undefined ? { reasoning } : {}), diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index 3e4323d7709..1c0623cb843 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -270,6 +270,9 @@ describe("resolveClaudeThinkingProfile", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect(profile.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + const fixedBudgetLevels = profile.levels.filter( + (level) => level.id === "xhigh" || level.id === "max", + ); + expect(fixedBudgetLevels).toEqual([]); }); }); diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index 962565049a1..34c2e1a0ec2 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -1,5 +1,6 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; +import { VERSION } from "../version.js"; import { composeProviderStreamWrappers as composeProviderStreamWrappersShared, createMoonshotThinkingWrapper as createMoonshotThinkingWrapperShared, @@ -239,7 +240,11 @@ describe("buildProviderStreamFamilyHooks", () => { config: { thinkingConfig: { thinkingBudget: -1 } }, service_tier: "flex", }); - expect(capturedHeaders).toEqual(expect.any(Object)); + expect(capturedHeaders).toEqual({ + "User-Agent": `openclaw/${VERSION}`, + originator: "openclaw", + version: VERSION, + }); const openRouterHooks = OPENROUTER_THINKING_STREAM_HOOKS; void requireStreamFn( diff --git a/src/plugin-sdk/realtime-voice.ts b/src/plugin-sdk/realtime-voice.ts index f74feb68287..0d6b9053dff 100644 --- a/src/plugin-sdk/realtime-voice.ts +++ b/src/plugin-sdk/realtime-voice.ts @@ -52,6 +52,7 @@ export { } from "../talk/talk-session-controller.js"; export { buildRealtimeVoiceAgentConsultChatMessage, + buildRealtimeVoiceAgentConsultPolicyInstructions, buildRealtimeVoiceAgentConsultPrompt, buildRealtimeVoiceAgentConsultWorkingResponse, collectRealtimeVoiceAgentConsultVisibleText, diff --git a/src/plugin-state/plugin-state-store.e2e.test.ts b/src/plugin-state/plugin-state-store.e2e.test.ts index d5dcb1d9a1d..80b14d8c026 100644 --- a/src/plugin-state/plugin-state-store.e2e.test.ts +++ b/src/plugin-state/plugin-state-store.e2e.test.ts @@ -264,7 +264,8 @@ describe("failure safety", () => { expect(result.ok).toBe(true); expect(result.dbPath).toContain("state.sqlite"); expect(result.steps.length).toBeGreaterThanOrEqual(4); - expect(result.steps.every((s) => s.ok)).toBe(true); + const failedSteps = result.steps.filter((step) => !step.ok); + expect(failedSteps).toEqual([]); // The probe's temporary stored value must not leak into the result. const serialised = JSON.stringify(result); diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index 301058a55a9..cab16747406 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -150,7 +150,7 @@ describe("plugin state keyed store", () => { ), ); - expect(attempts.filter(Boolean)).toHaveLength(1); + expect(attempts.reduce((count, attempt) => count + (attempt ? 1 : 0), 0)).toBe(1); const stored = await store.lookup("claim"); if (stored === undefined) { throw new Error("expected winning plugin-state claim"); @@ -465,7 +465,8 @@ describe("plugin state keyed store", () => { await withOpenClawTestState({ label: "plugin-state-probe" }, async () => { const result = probePluginStateStore(); expect(result.ok).toBe(true); - expect(result.steps.every((step) => step.ok)).toBe(true); + const failedSteps = result.steps.filter((step) => !step.ok); + expect(failedSteps).toEqual([]); expect(JSON.stringify(result)).not.toContain("probe-value"); }); }); diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index d29ad1e2469..b159df20a73 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -157,7 +157,8 @@ describe("loadEnabledClaudeBundleCommands", () => { promptTemplate: "Review the code. $ARGUMENTS", }, ]); - expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false); + const rawNames = commands.map((entry) => entry.rawName); + expect(rawNames).not.toContain("disabled"); } finally { env.restore(); } diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index cd3c092a0ce..330bf146413 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -455,6 +455,7 @@ describe("bundled plugin metadata", () => { it("keeps config schemas on all bundled plugin manifests", () => { for (const entry of listRepoBundledPluginMetadata()) { expect(entry.manifest.configSchema).toEqual(expect.any(Object)); + expect(Array.isArray(entry.manifest.configSchema)).toBe(false); } }); diff --git a/src/plugins/channel-catalog-registry.test.ts b/src/plugins/channel-catalog-registry.test.ts index 3ab563afd42..4d0427f5b0e 100644 --- a/src/plugins/channel-catalog-registry.test.ts +++ b/src/plugins/channel-catalog-registry.test.ts @@ -111,7 +111,7 @@ describe("listChannelCatalogEntries", () => { }, }); - expect(() => module.listChannelCatalogEntries({ env: ENV })).not.toThrow(); + expect(module.listChannelCatalogEntries({ env: ENV })).toEqual([]); expect(loadRecordsSpy).toHaveBeenCalledTimes(1); expect(discoverSpy).toHaveBeenCalledTimes(1); diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index 6fa47fe70a1..8b336e2ea3d 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,5 +1,6 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import { discoverOpenClawPlugins } from "./discovery.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { loadPluginManifest, @@ -45,7 +46,14 @@ export function listChannelCatalogEntries( if (!channel?.id) { return []; } - const manifest = loadPluginManifest(candidate.rootDir, candidate.origin !== "bundled"); + const manifest = loadPluginManifest( + candidate.rootDir, + shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env: params.env, + }), + ); if (!manifest.ok) { return []; } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index ad6725b7ee6..f8ff986565b 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -1391,12 +1391,32 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); - it("includes required agent harness owner plugins when the default runtime is forced", () => { + it("ignores legacy default agent runtime during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ agentRuntimeId: "codex", enabledPluginIds: ["codex"], }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }); + + it("includes required agent harness owner plugins for model runtime policy", () => { + expectStartupPluginIdsCase({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + plugins: { + entries: { + codex: { enabled: true }, + }, + }, + } as OpenClawConfig, expected: ["demo-channel", "browser", "codex", "memory-core"], }); }); @@ -1411,57 +1431,94 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); - it("includes required agent harness owner plugins when an agent override forces the runtime", () => { + it("ignores legacy per-agent runtime during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ agentRuntimeIds: ["codex"], enabledPluginIds: ["codex"], }), - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "memory-core"], }); }); - it("includes required agent harness owner plugins when env forces the runtime", () => { + it("ignores env runtime overrides during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ enabledPluginIds: ["codex"], }), env: { OPENCLAW_AGENT_RUNTIME: "codex" }, - expected: ["demo-channel", "browser", "codex", "memory-core"], + expected: ["demo-channel", "browser", "memory-core"], }); }); - it("includes required CLI backend owner plugins when the default runtime is forced", () => { + it("ignores legacy CLI backend runtime during startup planning", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ agentRuntimeId: "demo-cli", enabledPluginIds: ["demo-provider-plugin"], }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }); + + it("includes required CLI backend owner plugins for provider runtime policy", () => { + expectStartupPluginIdsCase({ + config: { + models: { + providers: { + "demo-provider": { + baseUrl: "https://example.com", + models: [], + agentRuntime: { id: "demo-cli" }, + }, + }, + }, + plugins: { + entries: { + "demo-provider-plugin": { enabled: true }, + }, + }, + } as OpenClawConfig, expected: ["demo-channel", "browser", "demo-provider-plugin", "memory-core"], }); }); - it.each([ - ["claude-cli", "anthropic"], - ["codex-cli", "openai"], - ["google-gemini-cli", "google"], - ] as const)("includes the bundled %s CLI backend owner at startup", (runtime, pluginId) => { - expectStartupPluginIdsCase({ - config: createStartupConfig({ - agentRuntimeId: runtime, - }), - expected: ["demo-channel", "browser", pluginId, "memory-core"], - }); - }); - - it("does not include required CLI backend owner plugins when they are explicitly disabled", () => { + it("includes required CLI backend owner plugins for model runtime policy", () => { expectStartupPluginIdsCase({ config: { agents: { defaults: { - agentRuntime: { - id: "demo-cli", - fallback: "none", + models: { + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + } as OpenClawConfig, + expected: ["demo-channel", "browser", "anthropic", "memory-core"], + }); + }); + + it.each(["claude-cli", "codex-cli", "google-gemini-cli"] as const)( + "ignores legacy bundled %s runtime at startup", + (runtime) => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + agentRuntimeId: runtime, + }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }, + ); + + it("does not include required CLI backend owner plugins when they are explicitly disabled", () => { + expectStartupPluginIdsCase({ + config: { + models: { + providers: { + "demo-provider": { + baseUrl: "https://example.com", + models: [], + agentRuntime: { id: "demo-cli" }, }, }, }, @@ -1482,9 +1539,8 @@ describe("resolveGatewayStartupPluginIds", () => { config: { agents: { defaults: { - agentRuntime: { - id: "codex", - fallback: "none", + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, }, }, }, diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 8217025062c..fa6167be265 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -397,7 +397,9 @@ describe("registerPluginCliCommands", () => { primary: "memory", }); - expect(program.commands.filter((command) => command.name() === "memory")).toHaveLength(1); + expect( + program.commands.reduce((count, command) => count + (command.name() === "memory" ? 1 : 0), 0), + ).toBe(1); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ onlyPluginIds: ["memory-core"], diff --git a/src/plugins/compaction-provider.test.ts b/src/plugins/compaction-provider.test.ts index a4de69a107a..e9ac9175415 100644 --- a/src/plugins/compaction-provider.test.ts +++ b/src/plugins/compaction-provider.test.ts @@ -28,6 +28,14 @@ function makeProvider(id: string, label?: string): CompactionProvider { }; } +function requireCompactionProvider(id: string): CompactionProvider { + const provider = getCompactionProvider(id); + if (!provider) { + throw new Error(`Expected compaction provider ${id}`); + } + return provider; +} + describe("compaction provider registry", () => { it("starts empty", () => { expect(listCompactionProviderIds()).toEqual([]); @@ -88,8 +96,8 @@ describe("compaction provider registry", () => { it("calls summarize and returns expected result", async () => { registerCompactionProvider(makeProvider("my-compactor")); - const provider = getCompactionProvider("my-compactor"); - const result = await provider!.summarize({ messages: [] }); + const provider = requireCompactionProvider("my-compactor"); + const result = await provider.summarize({ messages: [] }); expect(result).toBe("summary-from-my-compactor"); }); diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 6369d227d7d..7736267adb6 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -53,7 +53,8 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] { } function asRecord(value: unknown): Record { - expect(value && typeof value === "object" && !Array.isArray(value)).toBe(true); + expect(value).toEqual(expect.any(Object)); + expect(Array.isArray(value)).toBe(false); return value as Record; } diff --git a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts index f530a32e93a..16b0e1027d4 100644 --- a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts +++ b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts @@ -228,7 +228,8 @@ describe("extension runtime dependency manifests", () => { it("keeps json5 in memory-core for packaged runtime config parsing", () => { const manifest = readPackageManifest("extensions/memory-core/package.json"); - expect(manifest.dependencies?.json5).toEqual(expect.any(String)); + expect(manifest.dependencies?.json5).toBeTypeOf("string"); + expect(manifest.dependencies?.json5).not.toBe(""); }); for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) { diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index cbb6e60693d..4b5cbdb21f3 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -1976,7 +1976,7 @@ describe("host-hook fixture plugin contract", () => { it("does not let stale scheduler cleanup delete a newer job generation", async () => { let releaseCleanup: (() => void) | undefined; - let markCleanupStarted!: () => void; + let markCleanupStarted: (() => void) | undefined; const cleanupStartedPromise = new Promise((resolve) => { markCleanupStarted = resolve; }); @@ -1994,6 +1994,9 @@ describe("host-hook fixture plugin contract", () => { sessionKey: "agent:main:main", kind: "monitor", cleanup: async () => { + if (!markCleanupStarted) { + throw new Error("Expected scheduler cleanup start callback to be initialized"); + } markCleanupStarted(); await new Promise((resolve) => { releaseCleanup = resolve; @@ -2027,7 +2030,10 @@ describe("host-hook fixture plugin contract", () => { }, }); - releaseCleanup?.(); + if (!releaseCleanup) { + throw new Error("Expected scheduler cleanup release callback to be initialized"); + } + releaseCleanup(); await expect(cleanupPromise).resolves.toMatchObject({ failures: [] }); expect(listPluginSessionSchedulerJobs("scheduler-race")).toEqual([ { diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index fe04e913cfc..82f70f9e0ac 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -659,7 +659,8 @@ describe("plugin-sdk package contract guardrails", () => { "fake-indexeddb", "matrix-js-sdk", ]) { - expect(matrixRuntimeDeps.get(dep)).toEqual(expect.any(String)); + expect(matrixRuntimeDeps.get(dep)).toBeTypeOf("string"); + expect(matrixRuntimeDeps.get(dep)).not.toBe(""); expect(rootRuntimeDeps.has(dep)).toBe(false); } expect(rootRuntimeDeps.has("@openclaw/plugin-package-contract")).toBe(false); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 96f1ee1eb40..91fffd3e56c 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -28,6 +28,16 @@ function makeTempDir() { const mkdirSafe = mkdirSafeDir; +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + function withOpenClawPackageArgv(packageRoot: string, fn: () => T): T { mkdirSafe(path.join(packageRoot, "bin")); fs.writeFileSync(path.join(packageRoot, "package.json"), '{"name":"openclaw"}\n', "utf-8"); @@ -263,8 +273,8 @@ function expectCandidateSource( } function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) { - expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe( - true, + expect(diagnostics.map((entry) => entry.message)).toEqual( + expect.arrayContaining([expect.stringContaining("escapes package directory")]), ); } @@ -591,9 +601,10 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(candidates.filter((candidate) => candidate.idHint === "feishu")).toEqual([ + expect(candidates.find((candidate) => candidate.idHint === "feishu")).toEqual( expect.objectContaining({ origin: "bundled" }), - ]); + ); + expect(countMatching(candidates, (candidate) => candidate.idHint === "feishu")).toBe(1); expect(diagnostics).toEqual([ expect.objectContaining({ level: "warn", @@ -628,9 +639,10 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(candidates.filter((candidate) => candidate.idHint === "telegram")).toEqual([ + expect(candidates.find((candidate) => candidate.idHint === "telegram")).toEqual( expect.objectContaining({ origin: "bundled" }), - ]); + ); + expect(countMatching(candidates, (candidate) => candidate.idHint === "telegram")).toBe(1); expect(diagnostics).toEqual([ expect.objectContaining({ level: "warn", @@ -725,13 +737,14 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(candidates.filter((candidate) => candidate.idHint === "synology-chat")).toEqual([ + expect(candidates.find((candidate) => candidate.idHint === "synology-chat")).toEqual( expect.objectContaining({ origin: "bundled", rootDir: fs.realpathSync(bundledPluginDir), source: fs.realpathSync(bundledEntryPath), }), - ]); + ); + expect(countMatching(candidates, (candidate) => candidate.idHint === "synology-chat")).toBe(1); expect(diagnostics).toEqual([]); }); @@ -1657,7 +1670,7 @@ describe("discoverOpenClawPlugins", () => { const { candidates } = await discoverWithStateDir(stateDir, {}); - expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false); + expect(candidates.map((candidate) => candidate.idHint)).not.toContain("pack"); }); it.runIf(process.platform !== "win32")("blocks world-writable plugin paths", async () => { @@ -1671,8 +1684,8 @@ describe("discoverOpenClawPlugins", () => { const result = await discoverWithStateDir(stateDir, {}); expect(result.candidates).toHaveLength(0); - expect(result.diagnostics.some((diag) => diag.message.includes("world-writable path"))).toBe( - true, + expect(result.diagnostics.map((diag) => diag.message)).toEqual( + expect.arrayContaining([expect.stringContaining("world-writable path")]), ); }); @@ -1693,12 +1706,15 @@ describe("discoverOpenClawPlugins", () => { }), ); - expect(result.candidates.some((candidate) => candidate.idHint === "demo-pack")).toBe(true); - expect( - result.diagnostics.some( - (diag) => diag.source === packDir && diag.message.includes("world-writable path"), - ), - ).toBe(false); + expect(result.candidates.map((candidate) => candidate.idHint)).toContain("demo-pack"); + expect(result.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: packDir, + message: expect.stringContaining("world-writable path"), + }), + ]), + ); expect(fs.statSync(packDir).mode & 0o777).toBe(0o755); }, ); @@ -1719,8 +1735,10 @@ describe("discoverOpenClawPlugins", () => { const result = await discoverWithStateDir(stateDir, { ownershipUid: actualUid + 1 }); const shouldBlockForMismatch = actualUid !== 0; expect(result.candidates).toHaveLength(shouldBlockForMismatch ? 0 : 1); - expect(result.diagnostics.some((diag) => diag.message.includes("suspicious ownership"))).toBe( - shouldBlockForMismatch, + expect(result.diagnostics.map((diag) => diag.message)).toEqual( + shouldBlockForMismatch + ? expect.arrayContaining([expect.stringContaining("suspicious ownership")]) + : expect.not.arrayContaining([expect.stringContaining("suspicious ownership")]), ); if (shouldBlockForMismatch) { expect(result.diagnostics).toContainEqual( @@ -1799,12 +1817,12 @@ describe("discoverOpenClawPlugins", () => { const env = buildDiscoveryEnvWithOverrides(stateDir); const first = discoverWithEnv({ env }); - expect(first.candidates.some((candidate) => candidate.idHint === "fresh")).toBe(true); + expect(first.candidates.map((candidate) => candidate.idHint)).toContain("fresh"); fs.rmSync(pluginPath, { force: true }); const second = discoverWithEnv({ env }); - expect(second.candidates.some((candidate) => candidate.idHint === "fresh")).toBe(false); + expect(second.candidates.map((candidate) => candidate.idHint)).not.toContain("fresh"); }); it("discovers bundled and global plugins for each workspace-specific scan", () => { diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 11e5091ad2b..c01d73b1fd6 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -29,6 +29,7 @@ import { resolvePackageRuntimeExtensionSources, resolvePackageSetupSource, } from "./package-entry-resolution.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -571,6 +572,7 @@ function addCandidate(params: { function discoverBundleInRoot(params: { rootDir: string; origin: PluginOrigin; + env: NodeJS.ProcessEnv; ownershipUid?: number | null; workspaceDir?: string; manifest?: PackageManifest | null; @@ -584,11 +586,17 @@ function discoverBundleInRoot(params: { return "none"; } const rootRealPath = safeRealpathSync(params.rootDir, params.realpathCache) ?? undefined; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: params.origin, + rootDir: params.rootDir, + env: params.env, + realpathCache: params.realpathCache, + }); const bundleManifest = loadBundleManifest({ rootDir: params.rootDir, ...(rootRealPath !== undefined ? { rootRealPath } : {}), bundleFormat, - rejectHardlinks: params.origin !== "bundled", + rejectHardlinks, }); if (!bundleManifest.ok) { params.diagnostics.push({ @@ -620,6 +628,7 @@ function discoverBundleInRoot(params: { function discoverInDirectory(params: { dir: string; origin: PluginOrigin; + env: NodeJS.ProcessEnv; ownershipUid?: number | null; workspaceDir?: string; candidates: PluginCandidate[]; @@ -684,8 +693,13 @@ function discoverInDirectory(params: { continue; } - const rejectHardlinks = params.origin !== "bundled"; const fullPathRealPath = safeRealpathSync(fullPath, params.realpathCache) ?? undefined; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: params.origin, + rootDir: fullPath, + env: params.env, + realpathCache: params.realpathCache, + }); const manifest = readCandidatePackageManifest({ dir: fullPath, origin: params.origin, @@ -745,6 +759,7 @@ function discoverInDirectory(params: { const bundleDiscovery = discoverBundleInRoot({ rootDir: fullPath, origin: params.origin, + env: params.env, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, @@ -890,8 +905,13 @@ function discoverFromPath(params: { } if (stat.isDirectory()) { - const rejectHardlinks = params.origin !== "bundled"; const resolvedRealPath = safeRealpathSync(resolved, params.realpathCache) ?? undefined; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: params.origin, + rootDir: resolved, + env: params.env, + realpathCache: params.realpathCache, + }); const manifest = readCandidatePackageManifest({ dir: resolved, origin: params.origin, @@ -951,6 +971,7 @@ function discoverFromPath(params: { const bundleDiscovery = discoverBundleInRoot({ rootDir: resolved, origin: params.origin, + env: params.env, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, manifest, @@ -989,6 +1010,7 @@ function discoverFromPath(params: { discoverInDirectory({ dir: resolved, origin: params.origin, + env: params.env, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, candidates: params.candidates, @@ -1062,6 +1084,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: roots.workspace, origin: "workspace", + env, ownershipUid: params.ownershipUid, workspaceDir: workspaceRoot, candidates: result.candidates, @@ -1114,6 +1137,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: roots.stock, origin: "bundled", + env, ownershipUid: params.ownershipUid, candidates: result.candidates, diagnostics: result.diagnostics, @@ -1131,6 +1155,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: sourceCheckoutExtensionsDir, origin: "bundled", + env, ownershipUid: params.ownershipUid, candidates: result.candidates, diagnostics: result.diagnostics, @@ -1157,6 +1182,7 @@ export function discoverOpenClawPlugins(params: { discoverInDirectory({ dir: roots.global, origin: "global", + env, ownershipUid: params.ownershipUid, candidates: result.candidates, diagnostics: result.diagnostics, diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 8beaf901f68..6f4421b3216 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -629,7 +629,10 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { rootConfig: activationSourceConfig, }; const requiredAgentHarnessRuntimes = new Set( - collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env), + collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env, { + includeEnvRuntime: false, + includeLegacyAgentRuntimes: false, + }), ); const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); const manifestLookup = createManifestRegistryLookup(params.manifestRegistry); diff --git a/src/plugins/hardlink-policy.test.ts b/src/plugins/hardlink-policy.test.ts new file mode 100644 index 00000000000..cf90a2e4172 --- /dev/null +++ b/src/plugins/hardlink-policy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { isNixStorePluginRoot, shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; + +const nixEnv: NodeJS.ProcessEnv = { OPENCLAW_NIX_MODE: "1" }; + +describe("plugin hardlink policy", () => { + it("does not reject bundled plugin files", () => { + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "bundled", + rootDir: "/tmp/plugin", + env: {}, + }), + ).toBe(false); + }); + + it("rejects hardlinked external plugin files by default", () => { + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "config", + rootDir: "/tmp/plugin", + env: {}, + }), + ).toBe(true); + }); + + it("does not treat OPENCLAW_NIX_MODE as enough by itself", () => { + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "config", + rootDir: "/tmp/plugin", + env: nixEnv, + }), + ).toBe(true); + }); + + it.runIf(process.platform !== "win32")( + "does not reject hardlinked external plugin files when Nix mode loads from the Nix store", + () => { + expect(isNixStorePluginRoot("/nix/store/abc-openclaw-plugin")).toBe(true); + expect(isNixStorePluginRoot("/tmp/nix/store/abc-openclaw-plugin")).toBe(false); + expect( + shouldRejectHardlinkedPluginFiles({ + origin: "config", + rootDir: "/nix/store/abc-openclaw-plugin", + env: nixEnv, + }), + ).toBe(false); + }, + ); +}); diff --git a/src/plugins/hardlink-policy.ts b/src/plugins/hardlink-policy.ts new file mode 100644 index 00000000000..c1f7b72561b --- /dev/null +++ b/src/plugins/hardlink-policy.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +import { resolveIsNixMode } from "../config/paths.js"; +import { safeRealpathSync } from "./path-safety.js"; +import type { PluginOrigin } from "./plugin-origin.types.js"; + +const NIX_STORE_ROOT = "/nix/store"; + +// Hardlinks are rejected for user/config/workspace plugin roots by default. A +// hardlinked file can appear to live under a plugin root while sharing an inode +// with a file created elsewhere, which weakens the root-boundary checks used +// before loading plugin code. +// +// Two roots are allowed: +// - bundled: plugins shipped with OpenClaw itself, not user-installed code. +// - /nix/store in OPENCLAW_NIX_MODE: immutable Nix package outputs, where +// hardlinked files are normal package-store layout rather than user mutation. +export function isNixStorePluginRoot( + rootDir: string, + realpathCache?: Map, +): boolean { + const rootRealPath = safeRealpathSync(rootDir, realpathCache) ?? path.resolve(rootDir); + return rootRealPath === NIX_STORE_ROOT || rootRealPath.startsWith(`${NIX_STORE_ROOT}/`); +} + +export function shouldRejectHardlinkedPluginFiles(params: { + origin: PluginOrigin; + rootDir: string; + env?: NodeJS.ProcessEnv; + realpathCache?: Map; +}): boolean { + if (params.origin === "bundled") { + return false; + } + if (resolveIsNixMode(params.env) && isNixStorePluginRoot(params.rootDir, params.realpathCache)) { + return false; + } + return true; +} diff --git a/src/plugins/hook-runner-global.test.ts b/src/plugins/hook-runner-global.test.ts index 826fd6f4a0f..ebc8b87ed5b 100644 --- a/src/plugins/hook-runner-global.test.ts +++ b/src/plugins/hook-runner-global.test.ts @@ -5,6 +5,19 @@ async function importHookRunnerGlobalModule() { return import("./hook-runner-global.js"); } +type HookRunnerGlobalModule = Awaited>; +type HookRunner = NonNullable>; + +function expectGlobalHookRunner( + runner: ReturnType, +): HookRunner { + expect(runner).toEqual(expect.objectContaining({ hasHooks: expect.any(Function) })); + if (runner === null) { + throw new Error("Expected global hook runner"); + } + return runner; +} + async function expectGlobalRunnerState(expected: { hasRunner: boolean; registry?: unknown }) { const mod = await importHookRunnerGlobalModule(); expect(mod.getGlobalHookRunner() === null).toBe(!expected.hasRunner); @@ -29,13 +42,16 @@ describe("hook-runner-global", () => { it("preserves the initialized runner across module reloads", async () => { const { modA, registry } = await createInitializedModule(); - expect(modA.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + expect(expectGlobalHookRunner(modA.getGlobalHookRunner()).hasHooks("message_received")).toBe( + true, + ); vi.resetModules(); const modB = await expectGlobalRunnerState({ hasRunner: true, registry }); - expect(modB.getGlobalHookRunner()).not.toBeNull(); - expect(modB.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + expect(expectGlobalHookRunner(modB.getGlobalHookRunner()).hasHooks("message_received")).toBe( + true, + ); }); it("clears the shared state across module reloads", async () => { diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index a6e30ea3320..6a1ef6e2cd6 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -121,7 +121,7 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(0); expect(logs).toEqual(['plugin: webhook path missing for account "default"']); - expect(() => unregister()).not.toThrow(); + unregister(); }); it("replaces stale route on same path when replaceExisting=true", () => { diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index af81bb80920..eff92925f69 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -1072,7 +1072,9 @@ describe("installPluginFromNpmSpec", () => { return; } expect(result.pluginId).toBe(pluginId); - expect(warnings.some((warning) => warning.includes("installation blocked"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("installation blocked")]), + ); expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, diff --git a/src/plugins/install.path.test.ts b/src/plugins/install.path.test.ts index 2a2445d101a..0399ab887e6 100644 --- a/src/plugins/install.path.test.ts +++ b/src/plugins/install.path.test.ts @@ -232,7 +232,9 @@ describe("installPluginFromPath", () => { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain('Plugin file "payload" installation blocked'); } - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("allows plain file installs with dangerous code patterns when forced unsafe install is set", async () => { diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index cab2ade8109..00d3cadcb7b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -301,7 +301,7 @@ function expectFailedInstallResult< if (params.code) { expect(params.result.code).toBe(params.code); } - expect(params.result.error).toEqual(expect.any(String)); + expect(params.result.error).toBeTypeOf("string"); params.messageIncludes.forEach((fragment) => { expect(params.result.error).toContain(fragment); }); @@ -1142,7 +1142,9 @@ describe("installPluginFromArchive", () => { expect(result.error).toContain('Plugin "dangerous-plugin" installation blocked'); expect(result.error).toContain("dangerous code patterns detected"); } - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("allows package installs when dangerous scanner patterns are only in tests", async () => { @@ -1166,7 +1168,9 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("still scans declared package entrypoints when they live under test-looking paths", async () => { @@ -2068,7 +2072,9 @@ describe("installPluginFromArchive", () => { expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); expect(result.error).toContain('Bundle "dangerous-bundle" installation blocked'); } - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("allows bundle installs when dangerous scanner patterns are only in tests", async () => { @@ -2086,7 +2092,9 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("blocks bundle installs when a vendored manifest declares a blocked dependency", async () => { @@ -2452,7 +2460,9 @@ describe("installPluginFromArchive", () => { extensions: ["index.js"], }, }); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); expect( warnings.some((w) => w.includes("blocked by plugin hook: Blocked by enterprise policy")), ).toBe(true); @@ -2594,8 +2604,12 @@ describe("installPluginFromArchive", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(false); - expect(warnings.some((w) => w.includes("hidden/node_modules path"))).toBe(true); - expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("hidden/node_modules path")]), + ); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerous code pattern")]), + ); }); it("blocks install when scanner throws", async () => { @@ -2809,7 +2823,9 @@ describe("installPluginFromDir", () => { }); expectInstalledWithPluginId(res, extensionsDir, "matrix"); - expect(infoMessages.some((msg) => msg.includes("differs from npm package name"))).toBe(false); + expect(infoMessages).not.toEqual( + expect.arrayContaining([expect.stringContaining("differs from npm package name")]), + ); }); it.each([ @@ -3062,6 +3078,8 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); expect(result.ok).toBe(true); - expect(warnings.some((w) => w.includes("Could not locate openclaw package root"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("Could not locate openclaw package root")]), + ); }); }); diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 8eb2eff706a..6783078b9c0 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -486,7 +486,7 @@ describe("plugin interactive handlers", () => { }; try { - expect(() => clearPluginInteractiveHandlers()).not.toThrow(); + clearPluginInteractiveHandlers(); const hydrated = globalStore[stateKey] as { interactiveHandlers?: Map; callbackDedupe?: { clear: () => void }; @@ -496,7 +496,7 @@ describe("plugin interactive handlers", () => { if (!hydrated.callbackDedupe) { throw new Error("expected hydrated callback dedupe"); } - expect(() => hydrated.callbackDedupe?.clear()).not.toThrow(); + hydrated.callbackDedupe.clear(); expect(hydrated.inflightCallbackDedupe).toBeInstanceOf(Set); const handler = vi.fn(async () => ({ handled: true })); @@ -836,10 +836,13 @@ describe("plugin interactive handlers", () => { }); it("dedupes concurrent interactive dispatches while a handler is still running", async () => { - let releaseHandler!: () => void; + let releaseHandler: (() => void) | undefined; const handlerGate = new Promise((resolve) => { releaseHandler = resolve; }); + if (!releaseHandler) { + throw new Error("Expected handler release callback to be initialized"); + } const handler = vi.fn(async () => { await handlerGate; return { handled: true }; @@ -880,10 +883,13 @@ describe("plugin interactive handlers", () => { }); it("releases inflight interactive dedupe keys after a handler failure", async () => { - let rejectHandler!: (error: Error) => void; + let rejectHandler: ((error: Error) => void) | undefined; const handlerGate = new Promise((_, reject) => { rejectHandler = reject; }); + if (!rejectHandler) { + throw new Error("Expected handler reject callback to be initialized"); + } const handler = vi .fn(async () => ({ handled: true })) .mockImplementationOnce(async () => await handlerGate) diff --git a/src/plugins/lazy-service-module.test.ts b/src/plugins/lazy-service-module.test.ts index f7419a8c06f..0d1cd9c2c9c 100644 --- a/src/plugins/lazy-service-module.test.ts +++ b/src/plugins/lazy-service-module.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultLoadOverrideModule, startLazyPluginServiceModule } from "./lazy-service-module.js"; +type LazyPluginServiceHandle = NonNullable< + Awaited> +>; + function createAsyncHookMock() { return vi.fn(async () => {}); } @@ -38,6 +42,16 @@ async function expectLifecycleStarted(params: { }); } +function expectLazyServiceHandle( + handle: Awaited>, +): LazyPluginServiceHandle { + expect(handle).toEqual(expect.objectContaining({ stop: expect.any(Function) })); + if (handle === null) { + throw new Error("Expected lazy plugin service handle"); + } + return handle; +} + describe("startLazyPluginServiceModule", () => { afterEach(() => { delete process.env.OPENCLAW_LAZY_SERVICE_SKIP; @@ -54,8 +68,7 @@ describe("startLazyPluginServiceModule", () => { }); expect(lifecycle.start).toHaveBeenCalledTimes(1); - expect(handle).not.toBeNull(); - await handle?.stop(); + await expectLazyServiceHandle(handle).stop(); expect(lifecycle.stop).toHaveBeenCalledTimes(1); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 49750ee7cdc..72b1134fb20 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -94,6 +94,26 @@ import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; +type GlobalHookRunner = NonNullable>; + +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + +function expectGlobalHookRunner(runner: ReturnType): GlobalHookRunner { + expect(runner).toEqual(expect.objectContaining({ hasHooks: expect.any(Function) })); + if (runner === null) { + throw new Error("Expected global hook runner"); + } + return runner; +} + function createDetachedTaskRuntimeStub(id: string): DetachedTaskLifecycleRuntime { const fail = (name: string): never => { throw new Error(`detached runtime ${id} should not execute ${name} in this test`); @@ -283,7 +303,7 @@ function setupBundledTelegramPlugin() { function expectTelegramLoaded(registry: ReturnType) { const telegram = registry.plugins.find((entry) => entry.id === "telegram"); expect(telegram?.status).toBe("loaded"); - expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true); + expect(registry.channels.map((entry) => entry.plugin.id)).toContain("telegram"); } function loadRegistryFromSinglePlugin(params: { @@ -2159,7 +2179,7 @@ module.exports = { id: "throws-after-import", register() {} };`, const event = createInternalHookEvent("gateway", "startup", "gateway:startup"); await triggerInternalHook(event); - expect(event.messages.filter((message) => message === "reload-hook-fired")).toHaveLength(1); + expect(countMatching(event.messages, (message) => message === "reload-hook-fired")).toBe(1); clearInternalHooks(); }); @@ -3274,14 +3294,14 @@ module.exports = { id: "throws-after-import", register() {} };`, }; const first = loadOpenClawPlugins(options); - expect(getGlobalHookRunner()).not.toBeNull(); + expectGlobalHookRunner(getGlobalHookRunner()); resetGlobalHookRunner(); expect(getGlobalHookRunner()).toBeNull(); const second = loadOpenClawPlugins(options); expect(second).toBe(first); - expect(getGlobalHookRunner()).not.toBeNull(); + expectGlobalHookRunner(getGlobalHookRunner()); resetGlobalHookRunner(); }); @@ -3322,7 +3342,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }, }); expect(getGlobalPluginRegistry()).toBe(gatewayRegistry); - expect(getGlobalHookRunner()?.hasHooks("subagent_ended")).toBe(true); + expect(expectGlobalHookRunner(getGlobalHookRunner()).hasHooks("subagent_ended")).toBe(true); const defaultRegistry = loadOpenClawPlugins({ workspaceDir: defaultPlugin.dir, @@ -3342,8 +3362,9 @@ module.exports = { id: "throws-after-import", register() {} };`, expect(getActivePluginRegistry()).toBe(defaultRegistry); expect(getGlobalPluginRegistry()).toBe(gatewayRegistry); - expect(getGlobalHookRunner()?.hasHooks("subagent_ended")).toBe(true); - expect(getGlobalHookRunner()?.hasHooks("message_sent")).toBe(false); + const globalHookRunner = expectGlobalHookRunner(getGlobalHookRunner()); + expect(globalHookRunner.hasHooks("subagent_ended")).toBe(true); + expect(globalHookRunner.hasHooks("message_sent")).toBe(false); }); it.each([ @@ -3997,7 +4018,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }); } };`, assert: (registry: ReturnType) => { - expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); + expect(countMatching(registry.channels, (entry) => entry.plugin.id === "demo")).toBe(1); expect( registry.channels.find((entry) => entry.plugin.id === "demo")?.plugin.meta?.label, ).toBe("Demo Duplicate"); @@ -4156,7 +4177,7 @@ module.exports = { id: "throws-after-import", register() {} };`, api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); } };`, selectCount: (registry: ReturnType) => - registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook").length, + countMatching(registry.hooks, (entry) => entry.entry.hook.name === "shared-hook"), duplicateMessage: "hook already registered: shared-hook (hook-owner-a)", assert: expectDuplicateRegistrationResult, }, @@ -4168,7 +4189,7 @@ module.exports = { id: "throws-after-import", register() {} };`, api.registerService({ id: "shared-service", start() {} }); } };`, selectCount: (registry: ReturnType) => - registry.services.filter((entry) => entry.service.id === "shared-service").length, + countMatching(registry.services, (entry) => entry.service.id === "shared-service"), duplicateMessage: "service already registered: shared-service (service-owner-a)", assert: expectDuplicateRegistrationResult, }, @@ -4261,7 +4282,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }, }); - expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength( + expect(countMatching(registry.services, (entry) => entry.service.id === "shared-service")).toBe( 1, ); expectNoDiagnosticContaining({ @@ -4326,8 +4347,8 @@ module.exports = { id: "throws-after-import", register() {} };`, registry, message: "api.registerHttpHandler(...) was removed", }); - expect(errors.some((entry) => entry.includes("api.registerHttpHandler(...) was removed"))).toBe( - true, + expect(errors).toEqual( + expect.arrayContaining([expect.stringContaining("api.registerHttpHandler(...) was removed")]), ); }); @@ -4588,9 +4609,7 @@ module.exports = { id: "throws-after-import", register() {} };`, expect(registry.plugins.find((entry) => entry.id === "nested-default-channel")?.status).toBe( "loaded", ); - expect(registry.channels.some((entry) => entry.plugin.id === "nested-default-channel")).toBe( - true, - ); + expect(registry.channels.map((entry) => entry.plugin.id)).toContain("nested-default-channel"); }); it("does not treat manifest channel ids as scoped plugin id matches", () => { @@ -6609,7 +6628,9 @@ module.exports = { status: "disabled", error: "not in allowlist", }); - expect(warnings.some((message) => message.includes("plugins.allow is empty"))).toBe(false); + expect(warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining("plugins.allow is empty")]), + ); expect( warnings.some( (message) => diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3edcdf65cdc..7b90f48367c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -49,6 +49,7 @@ import { } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js"; import { toSafeImportPath } from "./import-specifier.js"; import { collectPluginManifestCompatCodes } from "./installed-plugin-index-record-builder.js"; @@ -2002,11 +2003,16 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi : runtimeCandidateEntry; const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadEntry.source); const moduleRoot = resolveCanonicalDistRuntimeSource(loadEntry.rootDir); + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env, + }); const opened = openRootFileSync({ absolutePath: moduleLoadSource, rootPath: moduleRoot, boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", + rejectHardlinks, skipLexicalRootCheck: true, }); if (!opened.ok) { @@ -2097,7 +2103,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi absolutePath: runtimeModuleSource, rootPath: runtimeModuleRoot, boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", + rejectHardlinks, skipLexicalRootCheck: true, }); if (!runtimeOpened.ok) { @@ -2678,7 +2684,11 @@ export async function loadOpenClawPluginCliRegistry( absolutePath: sourceForCliMetadata, rootPath: pluginRoot, boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", + rejectHardlinks: shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env, + }), skipLexicalRootCheck: true, }); if (!opened.ok) { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 26f01a92928..9a519bc1236 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -119,7 +119,9 @@ function expectRegistryDiagnosticContains( registry: ReturnType, fragment: string, ) { - expect(registry.diagnostics.some((diag) => diag.message.includes(fragment))).toBe(true); + expect(registry.diagnostics.map((diag) => diag.message)).toEqual( + expect.arrayContaining([expect.stringContaining(fragment)]), + ); } function prepareLinkedManifestFixture(params: { id: string; mode: "symlink" | "hardlink" }): { @@ -453,21 +455,17 @@ describe("loadPluginManifestRegistry", () => { config: { plugins: { entries: { "external-chat": { enabled: false } } } }, candidates: [candidate], }); - expect( - disabledRegistry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(disabledRegistry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); const allowlistRegistry = loadPluginManifestRegistry({ config: { plugins: { allow: ["other-plugin"] } }, candidates: [candidate], }); - expect( - allowlistRegistry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(allowlistRegistry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); }); it("suppresses duplicate warnings for explicit installed globals overriding bundled plugins", () => { @@ -1214,11 +1212,9 @@ describe("loadPluginManifestRegistry", () => { type: "object", additionalProperties: false, }); - expect( - registry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(registry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); }); it("hydrates supplemental official external catalog contracts for lagging npm manifests", () => { @@ -1247,11 +1243,9 @@ describe("loadPluginManifestRegistry", () => { }), }), ); - expect( - registry.diagnostics.some((diagnostic) => - diagnostic.message.includes("without channelConfigs metadata"), - ), - ).toBe(false); + expect(registry.diagnostics.map((diagnostic) => diagnostic.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("without channelConfigs metadata")]), + ); }); it("fills missing official external catalog descriptors for partial npm channel configs", () => { @@ -1886,7 +1880,7 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins).toEqual([]); expectRegistryDiagnosticContains(registry, expectedMessage); if (expectWarn) { - expect(registry.diagnostics.some((diag) => diag.level === "warn")).toBe(true); + expect(registry.diagnostics.map((diag) => diag.level)).toContain("warn"); } }); @@ -1918,11 +1912,9 @@ describe("loadPluginManifestRegistry", () => { }); expect(registry.plugins.map((plugin) => plugin.id)).toEqual(["codex"]); - expect( - registry.diagnostics.some((diag) => - diag.message.includes("openclaw.install.minHostVersion must use"), - ), - ).toBe(false); + expect(registry.diagnostics.map((diag) => diag.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("openclaw.install.minHostVersion must use")]), + ); }); it("does not runtime-gate bundled source plugins by install minHostVersion", () => { @@ -1947,9 +1939,9 @@ describe("loadPluginManifestRegistry", () => { env: { OPENCLAW_VERSION: "2026.4.30" } as NodeJS.ProcessEnv, }); - expect(registry.plugins.some((plugin) => plugin.id === "codex")).toBe(true); - expect(registry.diagnostics.some((diag) => diag.message.includes("requires OpenClaw"))).toBe( - false, + expect(registry.plugins.map((plugin) => plugin.id)).toContain("codex"); + expect(registry.diagnostics.map((diag) => diag.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("requires OpenClaw")]), ); }); @@ -2235,6 +2227,31 @@ describe("loadPluginManifestRegistry", () => { expectUnsafeWorkspaceManifestRejected({ id: "unsafe-hardlink", mode: "hardlink" }); }); + it("still rejects config manifest hardlinks outside the Nix store in Nix mode", () => { + if (process.platform === "win32") { + return; + } + const fixture = prepareLinkedManifestFixture({ + id: "unsafe-config-hardlink", + mode: "hardlink", + }); + if (!fixture.linked) { + return; + } + const registry = loadPluginManifestRegistry({ + env: hermeticEnv({ OPENCLAW_NIX_MODE: "1" }), + candidates: [ + createPluginCandidate({ + idHint: "unsafe-config-hardlink", + rootDir: fixture.rootDir, + origin: "config", + }), + ], + }); + expect(registry.plugins).toHaveLength(0); + expect(hasUnsafeManifestDiagnostic(registry)).toBe(true); + }); + it("allows bundled manifest paths that are hardlinked aliases", () => { if (process.platform === "win32") { return; @@ -2249,7 +2266,7 @@ describe("loadPluginManifestRegistry", () => { rootDir: fixture.rootDir, origin: "bundled", }); - expect(registry.plugins.some((entry) => entry.id === "bundled-hardlink")).toBe(true); + expect(registry.plugins.map((entry) => entry.id)).toContain("bundled-hardlink"); expect(hasUnsafeManifestDiagnostic(registry)).toBe(false); }); @@ -2336,12 +2353,12 @@ describe("loadPluginManifestRegistry", () => { }); expect(olderHost.plugins).toEqual([]); - expect( - olderHost.diagnostics.some((diag) => diag.message.includes("this host is 2026.3.21")), - ).toBe(true); - expect(newerHost.plugins.some((plugin) => plugin.id === "synology-chat")).toBe(true); - expect( - newerHost.diagnostics.some((diag) => diag.message.includes("this host is 2026.3.21")), - ).toBe(false); + expect(olderHost.diagnostics.map((diag) => diag.message)).toEqual( + expect.arrayContaining([expect.stringContaining("this host is 2026.3.21")]), + ); + expect(newerHost.plugins.map((plugin) => plugin.id)).toContain("synology-chat"); + expect(newerHost.diagnostics.map((diag) => diag.message)).not.toEqual( + expect.arrayContaining([expect.stringContaining("this host is 2026.3.21")]), + ); }); }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 08aadabc5ee..0ed2bad75ee 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -11,6 +11,7 @@ import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfigWithResolver } from "./config-policy.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; import type { @@ -843,7 +844,12 @@ export function loadPluginManifestRegistry( const currentHostVersion = resolveCompatibilityHostVersion(env); for (const candidate of candidates) { - const rejectHardlinks = candidate.origin !== "bundled"; + const rejectHardlinks = shouldRejectHardlinkedPluginFiles({ + origin: candidate.origin, + rootDir: candidate.rootDir, + env, + realpathCache, + }); const isBundleRecord = (candidate.format ?? "openclaw") === "bundle"; const manifestRes: | ReturnType diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 9089883172a..43541be6fa9 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -1,39 +1,47 @@ import { describe, expect, it } from "vitest"; import { + type OfficialExternalPluginCatalogEntry, getOfficialExternalPluginCatalogEntry, listOfficialExternalPluginCatalogEntries, resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; +function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry { + const entry = getOfficialExternalPluginCatalogEntry(id); + if (entry === undefined) { + throw new Error(`Expected external plugin catalog entry for ${id}`); + } + return entry; +} + describe("official external plugin catalog", () => { it("resolves third-party channel lookup aliases to published plugin ids", () => { - const wecomByChannel = getOfficialExternalPluginCatalogEntry("wecom"); - const wecomByPlugin = getOfficialExternalPluginCatalogEntry("wecom-openclaw-plugin"); - const yuanbaoByChannel = getOfficialExternalPluginCatalogEntry("yuanbao"); + const wecomByChannel = expectCatalogEntry("wecom"); + const wecomByPlugin = expectCatalogEntry("wecom-openclaw-plugin"); + const yuanbaoByChannel = expectCatalogEntry("yuanbao"); - expect(resolveOfficialExternalPluginId(wecomByChannel!)).toBe("wecom-openclaw-plugin"); - expect(resolveOfficialExternalPluginId(wecomByPlugin!)).toBe("wecom-openclaw-plugin"); - expect(resolveOfficialExternalPluginInstall(wecomByChannel!)?.npmSpec).toBe( + expect(resolveOfficialExternalPluginId(wecomByChannel)).toBe("wecom-openclaw-plugin"); + expect(resolveOfficialExternalPluginId(wecomByPlugin)).toBe("wecom-openclaw-plugin"); + expect(resolveOfficialExternalPluginInstall(wecomByChannel)?.npmSpec).toBe( "@wecom/wecom-openclaw-plugin@2026.4.23", ); - expect(resolveOfficialExternalPluginId(yuanbaoByChannel!)).toBe("openclaw-plugin-yuanbao"); - expect(resolveOfficialExternalPluginInstall(yuanbaoByChannel!)?.npmSpec).toBe( + expect(resolveOfficialExternalPluginId(yuanbaoByChannel)).toBe("openclaw-plugin-yuanbao"); + expect(resolveOfficialExternalPluginInstall(yuanbaoByChannel)?.npmSpec).toBe( "openclaw-plugin-yuanbao@2.11.0", ); }); it("keeps official launch package specs on the production package names", () => { - expect( - resolveOfficialExternalPluginInstall(getOfficialExternalPluginCatalogEntry("acpx")!)?.npmSpec, - ).toBe("@openclaw/acpx"); - expect( - resolveOfficialExternalPluginInstall(getOfficialExternalPluginCatalogEntry("googlechat")!) - ?.npmSpec, - ).toBe("@openclaw/googlechat"); - expect( - resolveOfficialExternalPluginInstall(getOfficialExternalPluginCatalogEntry("line")!)?.npmSpec, - ).toBe("@openclaw/line"); + expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("acpx"))?.npmSpec).toBe( + "@openclaw/acpx", + ); + expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("googlechat"))?.npmSpec).toBe( + "@openclaw/googlechat", + ); + expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("line"))?.npmSpec).toBe( + "@openclaw/line", + ); }); it("keeps Matrix and Mattermost out of the external catalog until cutover", () => { diff --git a/src/plugins/pi-package-graph.test.ts b/src/plugins/pi-package-graph.test.ts index 07fb8147f54..04e92e5d806 100644 --- a/src/plugins/pi-package-graph.test.ts +++ b/src/plugins/pi-package-graph.test.ts @@ -37,6 +37,16 @@ function readPiDependencySpecs() { })); } +function collectMissingSpecNames(specs: Array<{ name: string; spec?: string }>): string[] { + const names: string[] = []; + for (const entry of specs) { + if (!entry.spec) { + names.push(entry.name); + } + } + return names; +} + function expectNoGraphViolations(violations: string[], message: string) { expect(violations, message).toEqual([]); } @@ -45,7 +55,7 @@ describe("pi package graph guardrails", () => { it("keeps root Pi packages aligned to the same exact version", () => { const specs = readPiDependencySpecs(); - const missing = specs.filter((entry) => !entry.spec).map((entry) => entry.name); + const missing = collectMissingSpecNames(specs); expectNoGraphViolations( missing, `Missing required root Pi dependencies: ${missing.join(", ") || ""}. Mixed or incomplete Pi root dependencies create an unsupported package graph.`, diff --git a/src/plugins/plugin-graceful-init-failure.test.ts b/src/plugins/plugin-graceful-init-failure.test.ts index d336ea50c62..6db0220560a 100644 --- a/src/plugins/plugin-graceful-init-failure.test.ts +++ b/src/plugins/plugin-graceful-init-failure.test.ts @@ -89,7 +89,7 @@ function requireWarning(warnings: string[], text: string): string { } describe("graceful plugin initialization failure", () => { - it("does not crash when register throws", async () => { + it("marks plugin entry errored when register throws", async () => { const plugin = writePlugin({ id: "throws-on-register", body: `module.exports = { id: "throws-on-register", register() { throw new Error("config schema mismatch"); } };`, diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index e58e6061bf5..77fb3d1c5ae 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -101,6 +101,59 @@ describe("applyProviderAuthConfigPatch", () => { }, }); }); + + it("normalizes retired Google Gemini model refs from provider config patches", () => { + const patch = { + agents: { + defaults: { + model: { + primary: "google/gemini-3-pro-preview", + fallbacks: ["google/gemini-3-pro-preview", "openai/gpt-5.5"], + }, + models: { + "google/gemini-3-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + "google/gemini-3.1-pro-preview": { + params: { maxTokens: 12_000 }, + }, + }, + }, + }, + }; + + const next = applyProviderAuthConfigPatch({}, patch); + + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + fallbacks: ["google/gemini-3.1-pro-preview", "openai/gpt-5.5"], + }); + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { + alias: "gemini", + params: { thinking: "high", maxTokens: 12_000 }, + }, + }); + }); + + it("normalizes retired Google Gemini keys when replacing provider model maps", () => { + const patch = { + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": {}, + }, + }, + }, + }; + + const next = applyProviderAuthConfigPatch(base, patch, { replaceDefaultModels: true }); + + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": {}, + }); + }); }); describe("applyDefaultModel", () => { @@ -203,6 +256,30 @@ describe("applyDefaultModel", () => { }); }); + it("normalizes existing retired Google Gemini model keys before writing defaults", () => { + const config = { + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }, + }, + }, + } as OpenClawConfig; + + const next = applyDefaultModel(config, "google/gemini-3.1-pro-preview"); + + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }); + }); + it("normalizes retired Google Gemini fallbacks when writing config", () => { const config = { agents: { diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 02e8ba27591..4bca4ab2134 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -1,5 +1,8 @@ import { normalizeProviderId } from "../agents/model-selection.js"; -import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelRefForConfig, +} from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty, @@ -89,12 +92,64 @@ function mergeConfigPatch(base: T, patch: unknown): T { return next as T; } +function normalizeAgentModelConfigForWrite(value: unknown): unknown { + if (typeof value === "string") { + return normalizeAgentModelRefForConfig(value); + } + if (!isPlainRecord(value)) { + return value; + } + + const next: Record = { ...value }; + if (typeof next.primary === "string") { + next.primary = normalizeAgentModelRefForConfig(next.primary); + } + if (Array.isArray(next.fallbacks)) { + next.fallbacks = next.fallbacks.map((fallback) => + typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback, + ); + } + return next; +} + +function normalizeAgentModelMapForWrite(value: unknown): unknown { + if (!isPlainRecord(value)) { + return value; + } + return normalizeAgentModelMapForConfig(value); +} + +function normalizeConfigModelRefsForWrite(cfg: OpenClawConfig): OpenClawConfig { + const defaults = cfg.agents?.defaults; + if (!defaults) { + return cfg; + } + + const nextDefaults: NonNullable["defaults"]> = { + ...defaults, + }; + if (defaults.model !== undefined) { + nextDefaults.model = normalizeAgentModelConfigForWrite(defaults.model) as typeof defaults.model; + } + if (defaults.models !== undefined) { + nextDefaults.models = normalizeAgentModelMapForWrite(defaults.models) as typeof defaults.models; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: nextDefaults, + }, + }; +} + export function applyProviderAuthConfigPatch( cfg: OpenClawConfig, patch: unknown, options?: { replaceDefaultModels?: boolean }, ): OpenClawConfig { - const merged = mergeConfigPatch(cfg, patch); + const merged = normalizeConfigModelRefsForWrite(mergeConfigPatch(cfg, patch)); if (!options?.replaceDefaultModels || !isPlainRecord(patch)) { return merged; } @@ -105,7 +160,7 @@ export function applyProviderAuthConfigPatch( return merged; } - return { + return normalizeConfigModelRefsForWrite({ ...merged, agents: { ...merged.agents, @@ -117,7 +172,7 @@ export function applyProviderAuthConfigPatch( >["models"], }, }, - }; + }); } export function applyDefaultModel( @@ -126,7 +181,9 @@ export function applyDefaultModel( opts?: { preserveExistingPrimary?: boolean }, ): OpenClawConfig { const normalizedModel = normalizeAgentModelRefForConfig(model); - const models = { ...cfg.agents?.defaults?.models }; + const models = { + ...normalizeAgentModelMapForConfig(cfg.agents?.defaults?.models ?? {}), + }; models[normalizedModel] = models[normalizedModel] ?? {}; const existingModel = cfg.agents?.defaults?.model; diff --git a/src/plugins/provider-model-primary.test.ts b/src/plugins/provider-model-primary.test.ts new file mode 100644 index 00000000000..8addfc0aa0f --- /dev/null +++ b/src/plugins/provider-model-primary.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { applyPrimaryModel } from "./provider-model-primary.js"; + +describe("applyPrimaryModel", () => { + it("normalizes retired Gemini allowlist keys before writing the primary", () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3-pro-preview"], + }, + models: { + "google/gemini-3-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }, + }, + }, + } as OpenClawConfig; + + const next = applyPrimaryModel(cfg, "google/gemini-3-pro-preview"); + + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + fallbacks: ["google/gemini-3.1-pro-preview"], + }); + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { + alias: "gemini", + params: { thinking: "high" }, + }, + }); + }); +}); diff --git a/src/plugins/provider-model-primary.ts b/src/plugins/provider-model-primary.ts index a310942b951..199669cc8be 100644 --- a/src/plugins/provider-model-primary.ts +++ b/src/plugins/provider-model-primary.ts @@ -1,4 +1,7 @@ -import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; +import { + normalizeAgentModelMapForConfig, + normalizeAgentModelRefForConfig, +} from "../config/model-input.js"; import type { AgentModelListConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -50,7 +53,7 @@ export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawC const normalizedModel = normalizeAgentModelRefForConfig(model); const defaults = cfg.agents?.defaults; const existingModel = defaults?.model; - const existingModels = defaults?.models; + const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {}); const fallbacks = typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel ? (existingModel as { fallbacks?: string[] }).fallbacks?.map((fallback) => diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index 79db5ed32dc..e43feb19b32 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -233,14 +233,19 @@ describe("setActivePluginRegistry", () => { }, ] as const)("continues cleanup when the $name", async ({ refresh }) => { let releaseFirstCleanup: (() => void) | undefined; - let markFirstCleanupStarted!: () => void; - let markSecondCleanupCalled!: () => void; + let markFirstCleanupStarted: (() => void) | undefined; + let markSecondCleanupCalled: (() => void) | undefined; const firstCleanupStarted = new Promise((resolve) => { markFirstCleanupStarted = resolve; }); const secondCleanupCalled = new Promise((resolve) => { markSecondCleanupCalled = resolve; }); + if (!markFirstCleanupStarted || !markSecondCleanupCalled) { + throw new Error("Expected cleanup signal callbacks to be initialized"); + } + const notifyFirstCleanupStarted = markFirstCleanupStarted; + const notifySecondCleanupCalled = markSecondCleanupCalled; const previous = createEmptyPluginRegistry(); previous.plugins.push( createPluginRecord({ @@ -256,7 +261,7 @@ describe("setActivePluginRegistry", () => { lifecycle: { id: "first-cleanup", async cleanup() { - markFirstCleanupStarted(); + notifyFirstCleanupStarted(); await new Promise((resolve) => { releaseFirstCleanup = resolve; }); @@ -271,7 +276,7 @@ describe("setActivePluginRegistry", () => { lifecycle: { id: "second-cleanup", cleanup() { - markSecondCleanupCalled(); + notifySecondCleanupCalled(); }, }, source: "/virtual/cleanup-refresh-race/index.ts", @@ -285,7 +290,10 @@ describe("setActivePluginRegistry", () => { await waitForCleanupSignal(firstCleanupStarted, "first cleanup start"); refresh(next); - releaseFirstCleanup?.(); + if (!releaseFirstCleanup) { + throw new Error("Expected first cleanup release callback to be initialized"); + } + releaseFirstCleanup(); await waitForCleanupSignal(secondCleanupCalled, "second cleanup"); }); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index b8d01ab3179..a92c189b1df 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -331,12 +331,14 @@ describe("stageBundledPluginRuntime", () => { ]); const match = commandsModule.matchPluginCommand("/pair now"); - expect(match).not.toBeNull(); - expect(match?.args).toBe("now"); + expect(match).toEqual(expect.objectContaining({ args: "now" })); + if (match === null) { + throw new Error("Expected plugin command match"); + } await expect( commandsModule.executePluginCommand({ - command: match!.command, - args: match?.args, + command: match.command, + args: match.args, }), ).resolves.toEqual({ text: "paired:now" }); }); @@ -518,7 +520,7 @@ describe("stageBundledPluginRuntime", () => { return realSymlinkSync(String(target), linkPath, type); }) as typeof fs.symlinkSync); - expect(() => stageBundledPluginRuntime({ repoRoot })).not.toThrow(); + stageBundledPluginRuntime({ repoRoot }); const runtimeAssetPath = path.join( repoRoot, diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 06f6f3dbfa9..028834e0425 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -149,10 +149,10 @@ function createInstalledPluginIndexSnapshot( function expectInspectReport( pluginId: string, + options: Omit[0], "id"> = {}, ): NonNullable> { - const inspect = buildPluginInspectReport({ id: pluginId }); - expect(inspect).not.toBeNull(); - if (!inspect) { + const inspect = buildPluginInspectReport({ id: pluginId, ...options }); + if (inspect === null) { throw new Error(`expected inspect report for ${pluginId}`); } return inspect; @@ -569,10 +569,9 @@ describe("plugin status reports", () => { }), ); - const inspect = buildPluginInspectReport({ id: "demo", config: rawConfig }); + const inspect = expectInspectReport("demo", { config: rawConfig }); - expect(inspect).not.toBeNull(); - expectInspectPolicy(inspect!, { + expectInspectPolicy(inspect, { allowPromptInjection: undefined, allowConversationAccess: undefined, hookTimeoutMs: undefined, @@ -720,19 +719,18 @@ describe("plugin status reports", () => { typedHooks: [createTypedHook({ pluginId: "google", hookName: "before_agent_start" })], }); - const inspect = buildPluginInspectReport({ id: "google" }); + const inspect = expectInspectReport("google"); - expect(inspect).not.toBeNull(); - expectInspectShape(inspect!, { + expectInspectShape(inspect, { shape: "hybrid-capability", capabilityMode: "hybrid", capabilityKinds: ["text-inference", "media-understanding", "image-generation", "web-search"], }); - expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); - expect(inspect?.compatibility).toEqual([ + expect(inspect.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect.compatibility).toEqual([ createCompatibilityNotice({ pluginId: "google", code: "legacy-before-agent-start" }), ]); - expectInspectPolicy(inspect!, { + expectInspectPolicy(inspect, { allowPromptInjection: false, allowConversationAccess: true, hookTimeoutMs: undefined, @@ -741,7 +739,7 @@ describe("plugin status reports", () => { allowedModels: ["openai/gpt-5.5"], hasAllowedModelsConfig: true, }); - expect(inspect?.diagnostics).toEqual([ + expect(inspect.diagnostics).toEqual([ { level: "warn", pluginId: "google", message: "watch this surface" }, ]); }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index dd1b944290c..da38aff9ac8 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -12,6 +12,24 @@ function appBundledPluginRoot(pluginId: string): string { return bundledPluginRootAt(APP_ROOT, pluginId); } +function requireExpectedPluginId(params: { expectedPluginId?: string }): string { + if (!params.expectedPluginId) { + throw new Error("Expected npm install params to include expectedPluginId"); + } + return params.expectedPluginId; +} + +function requirePluginPackageName( + plugins: Array<{ pluginId: string; packageName: string }>, + pluginId: string, +): string { + const plugin = plugins.find((candidate) => candidate.pluginId === pluginId); + if (!plugin) { + throw new Error(`Expected plugin fixture ${pluginId}`); + } + return plugin.packageName; +} + const installPluginFromNpmSpecMock = vi.fn(); const installPluginFromMarketplaceMock = vi.fn(); const installPluginFromClawHubMock = vi.fn(); @@ -872,12 +890,12 @@ describe("updateNpmInstalledPlugins", () => { } installPluginFromNpmSpecMock.mockImplementation( (params: { expectedPluginId?: string; spec: string }) => { - const pluginId = params.expectedPluginId!; + const pluginId = requireExpectedPluginId(params); for (const { pluginId: installedPluginId } of plugins) { fs.rmSync(peerLinkPath(installedPluginId), { recursive: true, force: true }); } linkPeer(pluginId); - const packageName = plugins.find((plugin) => plugin.pluginId === pluginId)!.packageName; + const packageName = requirePluginPackageName(plugins, pluginId); return Promise.resolve( createSuccessfulNpmUpdateResult({ pluginId, diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 2baf806deaa..0e67659b9e2 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -77,6 +77,15 @@ import { resolveBundledWebSearchProvidersFromPublicArtifacts, } from "./web-provider-public-artifacts.js"; +function expectSingleProvider(providers: T[] | null | undefined): T { + expect(providers).toHaveLength(1); + const provider = providers?.[0]; + if (provider === undefined) { + throw new Error("Expected one web provider"); + } + return provider; +} + describe("web provider public artifacts explicit fast path", () => { beforeEach(() => { loadPluginManifestRegistryMock.mockClear(); @@ -84,13 +93,15 @@ describe("web provider public artifacts explicit fast path", () => { }); it("resolves bundled web search providers by explicit plugin id without manifest scans", () => { - const provider = resolveBundledWebSearchProvidersFromPublicArtifacts({ - bundledAllowlistCompat: true, - onlyPluginIds: ["brave"], - })?.[0]; + const provider = expectSingleProvider( + resolveBundledWebSearchProvidersFromPublicArtifacts({ + bundledAllowlistCompat: true, + onlyPluginIds: ["brave"], + }), + ); - expect(provider?.pluginId).toBe("brave"); - expect(provider?.createTool({ config: {} as never })).toBeNull(); + expect(provider.pluginId).toBe("brave"); + expect(provider.createTool({ config: {} as never })).toBeNull(); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "brave", artifactBasename: "web-search-contract-api.js", @@ -99,12 +110,17 @@ describe("web provider public artifacts explicit fast path", () => { }); it("resolves bundled runtime web search providers by explicit plugin id", () => { - const provider = resolveExplicitRuntimeWebSearchProviders({ - onlyPluginIds: ["google"], - })?.[0]; + const provider = expectSingleProvider( + resolveExplicitRuntimeWebSearchProviders({ + onlyPluginIds: ["google"], + }), + ); - expect(provider?.pluginId).toBe("google"); - expect(provider?.createTool({ config: {} as never })).not.toBeNull(); + expect(provider.pluginId).toBe("google"); + expect(provider.createTool({ config: {} as never })).toEqual({ + description: "fixture", + parameters: {}, + }); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "google", artifactBasename: "web-search-provider.js", @@ -113,13 +129,15 @@ describe("web provider public artifacts explicit fast path", () => { }); it("resolves bundled web fetch providers by explicit plugin id without manifest scans", () => { - const provider = resolveBundledWebFetchProvidersFromPublicArtifacts({ - bundledAllowlistCompat: true, - onlyPluginIds: ["firecrawl"], - })?.[0]; + const provider = expectSingleProvider( + resolveBundledWebFetchProvidersFromPublicArtifacts({ + bundledAllowlistCompat: true, + onlyPluginIds: ["firecrawl"], + }), + ); - expect(provider?.pluginId).toBe("firecrawl"); - expect(provider?.createTool({ config: {} as never })).toBeNull(); + expect(provider.pluginId).toBe("firecrawl"); + expect(provider.createTool({ config: {} as never })).toBeNull(); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "firecrawl", artifactBasename: "web-fetch-contract-api.js", diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index bffc84fc075..1b68aed7059 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -101,7 +101,10 @@ describe("compaction hook wiring", () => { }) { expect(params.call.event).toEqual(expect.objectContaining(params.expectedEvent)); if (params.expectedSessionKey !== undefined) { - expect(params.call.hookCtx?.sessionKey).toBe(params.expectedSessionKey); + if (!params.call.hookCtx) { + throw new Error("Expected compaction hook context"); + } + expect(params.call.hookCtx.sessionKey).toBe(params.expectedSessionKey); } } diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 3115917119e..f842fd53747 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -38,10 +38,13 @@ let setCommandLaneConcurrency: CommandQueueModule["setCommandLaneConcurrency"]; let waitForActiveTasks: CommandQueueModule["waitForActiveTasks"]; function createDeferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; + let resolve: (() => void) | undefined; const promise = new Promise((r) => { resolve = r; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } @@ -100,7 +103,7 @@ describe("command queue", () => { it("resetAllLanes is safe when no lanes have been created", () => { expect(getActiveTaskCount()).toBe(0); - expect(() => resetAllLanes()).not.toThrow(); + resetAllLanes(); expect(getActiveTaskCount()).toBe(0); }); @@ -145,12 +148,9 @@ describe("command queue", () => { vi.useFakeTimers(); try { - let releaseFirst!: () => void; - const blocker = new Promise((resolve) => { - releaseFirst = resolve; - }); + const blocker = createDeferred(); const first = enqueueCommand(async () => { - await blocker; + await blocker.promise; }); const second = enqueueCommand(async () => {}, { @@ -162,11 +162,11 @@ describe("command queue", () => { }); await vi.advanceTimersByTimeAsync(6); - releaseFirst(); + blocker.resolve(); await Promise.all([first, second]); - expect(waited).not.toBeNull(); - expect(waited as unknown as number).toBeGreaterThanOrEqual(5); + expect(typeof waited).toBe("number"); + expect(waited).toBeGreaterThanOrEqual(5); expect(queuedAhead).toBe(0); } finally { vi.useRealTimers(); @@ -255,14 +255,11 @@ describe("command queue", () => { const lane = `reset-test-${Date.now()}-${Math.random().toString(16).slice(2)}`; setCommandLaneConcurrency(lane, 1); - let resolve1!: () => void; - const blocker = new Promise((r) => { - resolve1 = r; - }); + const blocker = createDeferred(); // Start a task that blocks the lane const task1 = enqueueCommandInLane(lane, async () => { - await blocker; + await blocker.promise; }); expect(getActiveTaskCount()).toBeGreaterThanOrEqual(1); @@ -282,7 +279,7 @@ describe("command queue", () => { // Complete the stale in-flight task; generation mismatch makes its // completion path a no-op for queue bookkeeping. - resolve1(); + blocker.resolve(); await task1; // task2 should have been pumped by resetAllLanes's drain pass. @@ -454,34 +451,28 @@ describe("command queue", () => { const lane = `drain-snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`; setCommandLaneConcurrency(lane, 2); - let resolve1!: () => void; - const blocker1 = new Promise((r) => { - resolve1 = r; - }); - let resolve2!: () => void; - const blocker2 = new Promise((r) => { - resolve2 = r; - }); + const blocker1 = createDeferred(); + const blocker2 = createDeferred(); const firstStarted = createDeferred(); const first = enqueueCommandInLane(lane, async () => { firstStarted.resolve(); - await blocker1; + await blocker1.promise; }); await firstStarted.promise; const drainPromise = waitForActiveTasks(2000); // Starts after waitForActiveTasks snapshot and should not block drain completion. const second = enqueueCommandInLane(lane, async () => { - await blocker2; + await blocker2.promise; }); expect(getActiveTaskCount()).toBeGreaterThanOrEqual(2); - resolve1(); + blocker1.resolve(); const { drained } = await drainPromise; expect(drained).toBe(true); - resolve2(); + blocker2.resolve(); await Promise.all([first, second]); }); @@ -567,7 +558,7 @@ describe("command queue", () => { // resetAllLanes calls notifyActiveTaskWaiters → Array.from(state.activeTaskWaiters). // Without the migration this would throw: // TypeError: undefined is not iterable - expect(() => resetAllLanes()).not.toThrow(); + resetAllLanes(); // waitForActiveTasks also accesses activeTaskWaiters. await expect(waitForActiveTasks(0)).resolves.toEqual({ drained: true }); @@ -593,27 +584,24 @@ describe("command queue", () => { ); const lane = `shared-state-${Date.now()}-${Math.random().toString(16).slice(2)}`; - let release!: () => void; - const blocker = new Promise((resolve) => { - release = resolve; - }); + const blocker = createDeferred(); commandQueueA.resetAllLanes(); try { const task = commandQueueA.enqueueCommandInLane(lane, async () => { - await blocker; + await blocker.promise; return "done"; }); expect(commandQueueB.getQueueSize(lane)).toBe(1); expect(commandQueueB.getActiveTaskCount()).toBe(1); - release(); + blocker.resolve(); await expect(task).resolves.toBe("done"); expect(commandQueueB.getQueueSize(lane)).toBe(0); } finally { - release(); + blocker.resolve(); commandQueueA.resetAllLanes(); } }); diff --git a/src/process/supervisor/registry.test.ts b/src/process/supervisor/registry.test.ts index 27206374a74..59bea5a8b8f 100644 --- a/src/process/supervisor/registry.test.ts +++ b/src/process/supervisor/registry.test.ts @@ -42,17 +42,23 @@ describe("process supervisor run registry", () => { exitSignal: null, }); - expect(first).not.toBeNull(); - expect(first?.firstFinalize).toBe(true); - expect(first?.record.terminationReason).toBe("overall-timeout"); - expect(first?.record.exitCode).toBeNull(); - expect(first?.record.exitSignal).toBe("SIGKILL"); + expect(first).toEqual({ + firstFinalize: true, + record: expect.objectContaining({ + terminationReason: "overall-timeout", + exitCode: null, + exitSignal: "SIGKILL", + }), + }); - expect(second).not.toBeNull(); - expect(second?.firstFinalize).toBe(false); - expect(second?.record.terminationReason).toBe("overall-timeout"); - expect(second?.record.exitCode).toBeNull(); - expect(second?.record.exitSignal).toBe("SIGKILL"); + expect(second).toEqual({ + firstFinalize: false, + record: expect.objectContaining({ + terminationReason: "overall-timeout", + exitCode: null, + exitSignal: "SIGKILL", + }), + }); }); it("prunes oldest exited records once retention cap is exceeded", () => { diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index 2f72685b3df..fd4ce94799c 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -55,9 +55,7 @@ function createStubChildAdapter(options?: { ); const killMock = vi.fn(); const disposeMock = vi.fn(); - let adapter!: StubChildAdapter; - - adapter = { + const adapter: StubChildAdapter = { pid: options?.pid ?? 1234, stdin: undefined, onStdout: (listener) => { @@ -200,7 +198,7 @@ describe("process supervisor", () => { const firstExit = await firstRun.wait(); const secondExit = await secondRun.wait(); expect(first.killMock).toHaveBeenCalledWith("SIGKILL"); - expect(firstExit.reason === "manual-cancel" || firstExit.reason === "signal").toBe(true); + expect(["manual-cancel", "signal"]).toContain(firstExit.reason); expect(secondExit.reason).toBe("exit"); expect(secondExit.stdout).toBe("new"); }); diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 84e1017ecc6..ebef558a6b5 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -54,7 +54,7 @@ describe("DebugProxyCaptureStore", () => { const store = makeStore(); store.close(); - expect(() => store.close()).not.toThrow(); + store.close(); expect(store.isClosed).toBe(true); }); diff --git a/src/realtime-transcription/websocket-session.test.ts b/src/realtime-transcription/websocket-session.test.ts index 5607cb15754..496e6e07a54 100644 --- a/src/realtime-transcription/websocket-session.test.ts +++ b/src/realtime-transcription/websocket-session.test.ts @@ -60,18 +60,26 @@ async function createRealtimeServer(params?: { return { url: `ws://127.0.0.1:${port}` }; } +function createSignal() { + let resolve: (() => void) | undefined; + const promise = new Promise((next) => { + resolve = next; + }); + if (!resolve) { + throw new Error("Expected frame signal resolver to be initialized"); + } + return { promise, resolve }; +} + describe("createRealtimeTranscriptionWebSocketSession", () => { it("flushes queued binary audio after an open-ready connection", async () => { const frames: Buffer[] = []; - let resolveFrames!: () => void; - const framesReady = new Promise((resolve) => { - resolveFrames = resolve; - }); + const framesReady = createSignal(); const server = await createRealtimeServer({ onBinary: (payload) => { frames.push(payload); if (Buffer.concat(frames).toString() === "queuedafter") { - resolveFrames(); + framesReady.resolve(); } }, }); @@ -88,7 +96,7 @@ describe("createRealtimeTranscriptionWebSocketSession", () => { session.sendAudio(Buffer.from("queued")); await session.connect(); session.sendAudio(Buffer.from("after")); - await framesReady; + await framesReady.promise; expect(Buffer.concat(frames).toString()).toBe("queuedafter"); expect(session.isConnected()).toBe(true); session.close(); @@ -96,16 +104,13 @@ describe("createRealtimeTranscriptionWebSocketSession", () => { it("lets providers mark ready after a JSON handshake", async () => { const frames: unknown[] = []; - let resolveFrames!: () => void; - const framesReady = new Promise((resolve) => { - resolveFrames = resolve; - }); + const framesReady = createSignal(); const server = await createRealtimeServer({ initialEvent: { type: "session.created" }, onText: (payload) => { frames.push(payload); if (frames.length === 2) { - resolveFrames(); + framesReady.resolve(); } }, }); @@ -126,7 +131,7 @@ describe("createRealtimeTranscriptionWebSocketSession", () => { session.sendAudio(Buffer.from("queued")); await session.connect(); - await framesReady; + await framesReady.promise; expect(frames).toEqual([ { type: "session.update" }, { type: "input_audio.append", audio: Buffer.from("queued").toString("base64") }, diff --git a/src/scripts/docs-link-audit.test.ts b/src/scripts/docs-link-audit.test.ts index d3127e7e2b2..9b9be50daca 100644 --- a/src/scripts/docs-link-audit.test.ts +++ b/src/scripts/docs-link-audit.test.ts @@ -211,7 +211,8 @@ describe("docs-link-audit", () => { expect(exitCode).toBe(0); expect(invocations).toHaveLength(2); - expect(invocations[0]).toMatchObject({ + const [versionCheck, linkCheck] = invocations; + expect(versionCheck).toMatchObject({ command: "fnm", args: [ "exec", @@ -222,13 +223,13 @@ describe("docs-link-audit", () => { ], options: { stdio: "ignore" }, }); - expect(invocations[1]).toMatchObject({ + expect(linkCheck).toMatchObject({ command: "fnm", args: ["exec", "--using=22", "pnpm", "dlx", "mint", "broken-links", "--check-anchors"], options: { stdio: "inherit" }, }); - expect(invocations[0]?.options.cwd).toBe(anchorDocsDir); - expect(invocations[1]?.options.cwd).toBe(anchorDocsDir); + expect(versionCheck.options.cwd).toBe(anchorDocsDir); + expect(linkCheck.options.cwd).toBe(anchorDocsDir); expect(cleanedDir).toBe(anchorDocsDir); }); }); diff --git a/src/scripts/test-live-media.test.ts b/src/scripts/test-live-media.test.ts index 4adbab23c62..ad2399bb5fd 100644 --- a/src/scripts/test-live-media.test.ts +++ b/src/scripts/test-live-media.test.ts @@ -13,6 +13,17 @@ vi.mock("../../src/agents/live-auth-keys.js", () => ({ collectProviderApiKeys: collectProviderApiKeysMock, })); +function requirePlanEntry( + plan: ReturnType, + suiteId: string, +) { + const entry = plan.find((candidate) => candidate.suite.id === suiteId); + if (!entry) { + throw new Error(`expected ${suiteId} run plan entry`); + } + return entry; +} + describe("test-live-media", () => { afterEach(() => { collectProviderApiKeysMock.mockClear(); @@ -31,18 +42,15 @@ describe("test-live-media", () => { const plan = buildRunPlan(parseArgs([])); expect(plan.map((entry) => entry.suite.id)).toEqual(["image", "music", "video"]); - expect(plan.find((entry) => entry.suite.id === "image")?.providers).toEqual([ + expect(requirePlanEntry(plan, "image").providers).toEqual([ "fal", "google", "minimax", "openai", "vydra", ]); - expect(plan.find((entry) => entry.suite.id === "music")?.providers).toEqual([ - "google", - "minimax", - ]); - expect(plan.find((entry) => entry.suite.id === "video")?.providers).toEqual([ + expect(requirePlanEntry(plan, "music").providers).toEqual(["google", "minimax"]); + expect(requirePlanEntry(plan, "video").providers).toEqual([ "google", "minimax", "openai", @@ -57,8 +65,11 @@ describe("test-live-media", () => { ); expect(plan).toHaveLength(1); - expect(plan[0]?.suite.id).toBe("video"); - expect(plan[0]?.providers).toEqual(["fal", "openai", "runway"]); + const [entry] = plan; + expect(entry).toMatchObject({ + suite: { id: "video" }, + providers: ["fal", "openai", "runway"], + }); }); it("forwards quiet flags separately from passthrough args", async () => { diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 2ec760ed4b4..b7355d02148 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -385,7 +385,7 @@ describe("secrets apply", () => { expect(dryRunAllowed.mode).toBe("dry-run"); expect(dryRunAllowed.skippedExecRefs).toBe(0); const callLog = await fs.readFile(execLogPath, "utf8"); - expect(callLog.split("\n").filter((line) => line.trim().length > 0).length).toBeGreaterThan(0); + expect(callLog.split("\n").some((line) => line.trim().length > 0)).toBe(true); }); it("ignores unrelated auth-profile store refs during allowExec dry-run preflight", async () => { diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index fd38e7cecc3..b2b69b0cffa 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -18,6 +18,16 @@ type AuditFixture = { const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024; +function countNonEmptyLines(value: string): number { + let count = 0; + for (const line of value.split("\n")) { + if (line.trim().length > 0) { + count += 1; + } + } + return count; +} + async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } @@ -220,8 +230,12 @@ describe("secrets audit", () => { expect(report.status).toBe("findings"); expect(report.summary.plaintextCount).toBeGreaterThan(0); expect(report.summary.shadowedRefCount).toBeGreaterThan(0); - expect(hasFinding(report, (entry) => entry.code === "REF_SHADOWED")).toBe(true); - expect(hasFinding(report, (entry) => entry.code === "PLAINTEXT_FOUND")).toBe(true); + expect(report.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "REF_SHADOWED" }), + expect.objectContaining({ code: "PLAINTEXT_FOUND" }), + ]), + ); }); it("does not mutate legacy auth.json during audit", async () => { @@ -234,7 +248,9 @@ describe("secrets audit", () => { }); const report = await runSecretsAudit({ env: fixture.env }); - expect(hasFinding(report, (entry) => entry.code === "LEGACY_RESIDUE")).toBe(true); + expect(report.findings).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "LEGACY_RESIDUE" })]), + ); const authJsonStat = await fs.stat(fixture.authJsonPath); expect(authJsonStat.isFile()).toBe(true); await expect(fs.stat(fixture.authStorePath)).rejects.toMatchObject({ code: "ENOENT" }); @@ -245,9 +261,13 @@ describe("secrets audit", () => { await fs.writeFile(fixture.authJsonPath, "{invalid-json", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); - expect(hasFinding(report, (entry) => entry.file === fixture.authStorePath)).toBe(true); - expect(hasFinding(report, (entry) => entry.file === fixture.authJsonPath)).toBe(true); - expect(hasFinding(report, (entry) => entry.code === "REF_UNRESOLVED")).toBe(true); + expect(report.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: fixture.authStorePath }), + expect.objectContaining({ file: fixture.authJsonPath }), + expect.objectContaining({ code: "REF_UNRESOLVED" }), + ]), + ); }); it("skips exec ref resolution during audit unless explicitly allowed", async () => { @@ -324,7 +344,7 @@ describe("secrets audit", () => { expect(report.summary.unresolvedRefCount).toBe(0); const callLog = await fs.readFile(execLogPath, "utf8"); - const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; + const callCount = countNonEmptyLines(callLog); expect(callCount).toBe(1); }); @@ -388,7 +408,7 @@ describe("secrets audit", () => { expect(report.summary.unresolvedRefCount).toBeGreaterThanOrEqual(2); const callLog = await fs.readFile(execLogPath, "utf8"); - const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; + const callCount = countNonEmptyLines(callLog); expect(callCount).toBe(1); }); diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index 8fff506a62c..6145725dedf 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -5,15 +5,19 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-help const tempDirs: string[] = []; -const { loadPluginMetadataSnapshotMock, loadBundledPluginPublicArtifactModuleSyncMock } = - vi.hoisted(() => ({ - loadPluginMetadataSnapshotMock: vi.fn(), - loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => { - throw new Error( - "Unable to resolve bundled plugin public surface discord/secret-contract-api.js", - ); - }), - })); +const { + loadPluginMetadataSnapshotMock, + loadBundledPluginPublicArtifactModuleSyncMock, + shouldRejectHardlinkedPluginFilesMock, +} = vi.hoisted(() => ({ + loadPluginMetadataSnapshotMock: vi.fn(), + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => { + throw new Error( + "Unable to resolve bundled plugin public surface discord/secret-contract-api.js", + ); + }), + shouldRejectHardlinkedPluginFilesMock: vi.fn(() => true), +})); vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, @@ -23,20 +27,32 @@ vi.mock("../plugins/public-surface-loader.js", () => ({ loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, })); +vi.mock("../plugins/hardlink-policy.js", () => ({ + shouldRejectHardlinkedPluginFiles: shouldRejectHardlinkedPluginFilesMock, +})); + import { loadChannelSecretContractApi } from "./channel-contract-api.js"; -function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) { - const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs); - fs.writeFileSync( - path.join(rootDir, "secret-contract-api.cjs"), - ` +type ChannelSecretContractApi = NonNullable>; + +function requireChannelSecretContractApi( + api: ReturnType, +): ChannelSecretContractApi { + if (!api) { + throw new Error("expected channel secret contract API"); + } + return api; +} + +function channelSecretContractModuleSource(channelId: string) { + return ` module.exports = { secretTargetRegistryEntries: [ { - id: "channels.${params.channelId}.token", - targetType: "channels.${params.channelId}.token", + id: "channels.${channelId}.token", + targetType: "channels.${channelId}.token", configFile: "openclaw.json", - pathPattern: "channels.${params.channelId}.token", + pathPattern: "channels.${channelId}.token", secretShape: "secret_input", expectedResolvedValue: "string", includeInPlan: true, @@ -46,14 +62,21 @@ module.exports = { ], collectRuntimeConfigAssignments(params) { params.context.assignments.push({ - path: "channels.${params.channelId}.token", + path: "channels.${channelId}.token", ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, expected: "string", apply() {} }); } }; -`, +`; +} + +function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs); + fs.writeFileSync( + path.join(rootDir, "secret-contract-api.cjs"), + channelSecretContractModuleSource(params.channelId), "utf8", ); return { @@ -69,6 +92,8 @@ describe("external channel secret contract api", () => { beforeEach(() => { loadPluginMetadataSnapshotMock.mockReset(); loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + shouldRejectHardlinkedPluginFilesMock.mockReset(); + shouldRejectHardlinkedPluginFilesMock.mockReturnValue(true); }); afterEach(() => { @@ -88,14 +113,15 @@ describe("external channel secret contract api", () => { loadablePluginOrigins: new Map([["discord", "global"]]), }); - expect(api?.secretTargetRegistryEntries).toEqual( + const contractApi = requireChannelSecretContractApi(api); + expect(contractApi.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "channels.discord.token", }), ]), ); - expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + expect(contractApi.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); it("loads dist/ secret-contract-api sidecars for compiled npm-published external channel plugins", () => { @@ -103,31 +129,7 @@ describe("external channel secret contract api", () => { fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true }); fs.writeFileSync( path.join(rootDir, "dist", "secret-contract-api.cjs"), - ` -module.exports = { - secretTargetRegistryEntries: [ - { - id: "channels.discord.token", - targetType: "channels.discord.token", - configFile: "openclaw.json", - pathPattern: "channels.discord.token", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true - } - ], - collectRuntimeConfigAssignments(params) { - params.context.assignments.push({ - path: "channels.discord.token", - ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - expected: "string", - apply() {} - }); - } -}; -`, + channelSecretContractModuleSource("discord"), "utf8", ); const record = { @@ -148,16 +150,65 @@ module.exports = { loadablePluginOrigins: new Map([["discord", "global"]]), }); - expect(api?.secretTargetRegistryEntries).toEqual( + const contractApi = requireChannelSecretContractApi(api); + expect(contractApi.secretTargetRegistryEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "channels.discord.token", }), ]), ); - expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + expect(contractApi.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); + it.runIf(process.platform !== "win32")( + "loads hardlinked external channel contracts when the plugin hardlink policy allows them", + () => { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract-hardlink", tempDirs); + const outsideDir = makeTrackedTempDir( + "openclaw-channel-secret-contract-hardlink-outside", + tempDirs, + ); + const outsideContractPath = path.join(outsideDir, "secret-contract-api.cjs"); + fs.writeFileSync(outsideContractPath, channelSecretContractModuleSource("discord"), "utf8"); + fs.linkSync(outsideContractPath, path.join(rootDir, "secret-contract-api.cjs")); + shouldRejectHardlinkedPluginFilesMock.mockReturnValue(false); + + const record = { + id: "discord", + origin: "global", + channels: ["discord"], + channelConfigs: {}, + rootDir, + }; + const env = { OPENCLAW_NIX_MODE: "1" }; + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [record], + }); + + const api = loadChannelSecretContractApi({ + channelId: "discord", + config: { channels: { discord: {} } }, + env, + loadablePluginOrigins: new Map([["discord", "global"]]), + }); + + expect(shouldRejectHardlinkedPluginFilesMock).toHaveBeenCalledWith({ + origin: "global", + rootDir, + env, + }); + const contractApi = requireChannelSecretContractApi(api); + expect(contractApi.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "channels.discord.token", + }), + ]), + ); + }, + ); + it("skips external channel records outside the loadable plugin origin set", () => { const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" }); loadPluginMetadataSnapshotMock.mockReturnValue({ diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 58741519fff..a47f000b515 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFileSync } from "../infra/boundary-file-read.js"; +import { shouldRejectHardlinkedPluginFiles } from "../plugins/hardlink-policy.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { @@ -117,6 +118,7 @@ function loadPluginContractModule(modulePath: string): BundledChannelContractApi function loadExternalChannelSecretContractFromRecord( record: PluginManifestRecord, + env: NodeJS.ProcessEnv = process.env, ): BundledChannelSecretContractApi | undefined { const contractPath = resolvePluginContractApiPath(record.rootDir); if (!contractPath) { @@ -126,7 +128,11 @@ function loadExternalChannelSecretContractFromRecord( absolutePath: contractPath, rootPath: record.rootDir, boundaryLabel: "plugin root", - rejectHardlinks: record.origin !== "bundled", + rejectHardlinks: shouldRejectHardlinkedPluginFiles({ + origin: record.origin, + rootDir: record.rootDir, + env, + }), skipLexicalRootCheck: true, }); if (!opened.ok) { @@ -209,7 +215,7 @@ export function loadChannelSecretContractApi(params: { env, loadablePluginOrigins: params.loadablePluginOrigins, })) { - const contract = loadExternalChannelSecretContractFromRecord(record); + const contract = loadExternalChannelSecretContractFromRecord(record, env); if (contract) { return contract; } diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index d74fca86bec..6795d1a3b79 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -10,6 +10,17 @@ import { } from "../test-utils/talk-test-provider.js"; import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js"; +type ValidatedPlanTarget = NonNullable>; + +function requireValidatedPlanTarget( + resolved: ReturnType, +): ValidatedPlanTarget { + if (!resolved) { + throw new Error("expected validated secrets plan target"); + } + return resolved; +} + describe("secrets plan validation", () => { it("accepts legacy provider target types", () => { const resolved = resolveValidatedPlanTarget({ @@ -18,7 +29,12 @@ describe("secrets plan validation", () => { pathSegments: ["models", "providers", "openai", "apiKey"], providerId: "openai", }); - expect(resolved?.pathSegments).toEqual(["models", "providers", "openai", "apiKey"]); + expect(requireValidatedPlanTarget(resolved).pathSegments).toEqual([ + "models", + "providers", + "openai", + "apiKey", + ]); }); it("accepts expanded target types beyond legacy surface", () => { @@ -27,7 +43,11 @@ describe("secrets plan validation", () => { path: "channels.telegram.botToken", pathSegments: ["channels", "telegram", "botToken"], }); - expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]); + expect(requireValidatedPlanTarget(resolved).pathSegments).toEqual([ + "channels", + "telegram", + "botToken", + ]); }); it("accepts model provider header targets with wildcard-backed paths", () => { @@ -37,7 +57,7 @@ describe("secrets plan validation", () => { pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], providerId: "openai", }); - expect(resolved?.pathSegments).toEqual([ + expect(requireValidatedPlanTarget(resolved).pathSegments).toEqual([ "models", "providers", "openai", diff --git a/src/secrets/provider-env-vars.dynamic.test.ts b/src/secrets/provider-env-vars.dynamic.test.ts index 3454625dd0b..e401457fd21 100644 --- a/src/secrets/provider-env-vars.dynamic.test.ts +++ b/src/secrets/provider-env-vars.dynamic.test.ts @@ -63,6 +63,14 @@ const pluginRegistryMocks = vi.hoisted(() => { }; }); +function requireLastMetadataSnapshotCall(): unknown[] { + const call = pluginRegistryMocks.loadPluginMetadataSnapshot.mock.calls.at(-1); + if (!call) { + throw new Error("expected plugin metadata snapshot call"); + } + return call; +} + vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: pluginRegistryMocks.getCurrentPluginMetadataSnapshot, })); @@ -180,7 +188,7 @@ describe("provider env vars dynamic manifest metadata", () => { source: "external cloud credentials", }, ]); - expect(pluginRegistryMocks.loadPluginMetadataSnapshot.mock.calls.at(-1)?.[0]).toMatchObject({ + expect(requireLastMetadataSnapshotCall()[0]).toMatchObject({ preferPersisted: false, }); }); diff --git a/src/secrets/runtime-auth-refresh-failure.test.ts b/src/secrets/runtime-auth-refresh-failure.test.ts index 3e119233c27..793e6a2a063 100644 --- a/src/secrets/runtime-auth-refresh-failure.test.ts +++ b/src/secrets/runtime-auth-refresh-failure.test.ts @@ -20,6 +20,16 @@ import { vi.unmock("../version.js"); +function expectActiveSecretsRuntimeSnapshot(): NonNullable< + ReturnType +> { + const snapshot = getActiveSecretsRuntimeSnapshot(); + if (snapshot === null) { + throw new Error("Expected active secrets runtime snapshot"); + } + return snapshot; +} + describe("secrets runtime snapshot auth refresh failure", () => { let envSnapshot: SecretsRuntimeEnvSnapshot; @@ -75,10 +85,9 @@ describe("secrets runtime snapshot auth refresh failure", () => { }), ).rejects.toThrow(/simulated secrets runtime refresh failure/i); - const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); - expect(activeAfterFailure).not.toBeNull(); + const activeAfterFailure = expectActiveSecretsRuntimeSnapshot(); expectResolvedOpenAIRuntime(agentDir); - expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual( + expect(activeAfterFailure.sourceConfig.models?.providers?.openai?.apiKey).toEqual( OPENAI_FILE_KEY_REF, ); }); diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index 34a6d29d5b3..5c23d6fcccb 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -40,6 +40,16 @@ function loadablePluginOrigins(entries: Array<[string, PluginOrigin]>) { return new Map(entries); } +type RuntimeConfigAssignment = ResolverContext["assignments"][number]; + +function requireAssignment(context: ResolverContext, index: number): RuntimeConfigAssignment { + const assignment = context.assignments[index]; + if (!assignment) { + throw new Error(`expected runtime config assignment ${index}`); + } + return assignment; +} + function createAcpxMcpSecretConfig(params: { plugins?: Record; entry?: Record; @@ -141,10 +151,9 @@ describe("collectPluginConfigAssignments", () => { }); expect(context.assignments).toHaveLength(1); - expect(context.assignments[0]?.path).toBe( - "plugins.entries.acpx.config.mcpServers.github.env.GITHUB_TOKEN", - ); - expect(context.assignments[0]?.expected).toBe("string"); + const assignment = requireAssignment(context, 0); + expect(assignment.path).toBe("plugins.entries.acpx.config.mcpServers.github.env.GITHUB_TOKEN"); + expect(assignment.expected).toBe("string"); }); it("resolves assignments via apply callback", () => { @@ -177,7 +186,7 @@ describe("collectPluginConfigAssignments", () => { }); expect(context.assignments).toHaveLength(1); - context.assignments[0]?.apply("resolved-key-value"); + requireAssignment(context, 0).apply("resolved-key-value"); const entries = config.plugins?.entries as Record>; const mcpServers = (entries?.acpx?.config as Record)?.mcpServers as Record< @@ -185,7 +194,10 @@ describe("collectPluginConfigAssignments", () => { Record >; const env = mcpServers?.mcp1?.env as Record; - expect(env?.API_KEY).toBe("resolved-key-value"); + if (!env) { + throw new Error("expected acpx mcp env config"); + } + expect(env.API_KEY).toBe("resolved-key-value"); }); it("collects across multiple acpx servers only", () => { @@ -382,10 +394,10 @@ describe("collectPluginConfigAssignments", () => { }); expect(context.assignments).toHaveLength(2); - expect(context.assignments[0]?.path).toBe( + expect(requireAssignment(context, 0).path).toBe( "plugins.entries.acpx.config.mcpServers.s1.env.INLINE", ); - expect(context.assignments[1]?.path).toBe( + expect(requireAssignment(context, 1).path).toBe( "plugins.entries.acpx.config.mcpServers.s1.env.SECOND", ); }); diff --git a/src/secrets/runtime-external-channel-origin-discovery.test.ts b/src/secrets/runtime-external-channel-origin-discovery.test.ts index dd5eda45c3e..82e3f1c8610 100644 --- a/src/secrets/runtime-external-channel-origin-discovery.test.ts +++ b/src/secrets/runtime-external-channel-origin-discovery.test.ts @@ -20,6 +20,14 @@ import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-s const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireDiscordConfig(snapshot: Awaited>) { + const config = snapshot.config.channels?.discord; + if (!config) { + throw new Error("expected Discord runtime config"); + } + return config; +} + describe("secrets runtime external channel origin discovery", () => { it("discovers loadable plugins for channel SecretRefs when plugins.entries is absent", async () => { loadPluginMetadataSnapshotMock.mockReturnValue({ @@ -68,7 +76,7 @@ describe("secrets runtime external channel origin discovery", () => { includeAuthStoreRefs: false, }); - expect(snapshot.config.channels?.discord?.token).toBe("resolved-discord-token"); + expect(requireDiscordConfig(snapshot).token).toBe("resolved-discord-token"); expect(loadPluginMetadataSnapshotMock).toHaveBeenCalled(); expect(loadChannelSecretContractApiMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/secrets/runtime-inactive-telegram-surfaces.test.ts b/src/secrets/runtime-inactive-telegram-surfaces.test.ts index 97139b50c1e..de428a7ddd9 100644 --- a/src/secrets/runtime-inactive-telegram-surfaces.test.ts +++ b/src/secrets/runtime-inactive-telegram-surfaces.test.ts @@ -4,6 +4,16 @@ import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-s const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireTelegramConfig( + snapshot: Awaited>, +) { + const config = snapshot.config.channels?.telegram; + if (!config) { + throw new Error("expected Telegram runtime config"); + } + return config; +} + describe("secrets runtime snapshot inactive telegram surfaces", () => { it("skips inactive Telegram refs and emits diagnostics", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -29,7 +39,7 @@ describe("secrets runtime snapshot inactive telegram surfaces", () => { loadablePluginOrigins: new Map(), }); - expect(snapshot.config.channels?.telegram?.botToken).toEqual({ + expect(requireTelegramConfig(snapshot).botToken).toEqual({ source: "env", provider: "default", id: "DISABLED_TELEGRAM_BASE_TOKEN", diff --git a/src/secrets/runtime-matrix-shadowing.test.ts b/src/secrets/runtime-matrix-shadowing.test.ts index b1944a69649..d34728dc0ef 100644 --- a/src/secrets/runtime-matrix-shadowing.test.ts +++ b/src/secrets/runtime-matrix-shadowing.test.ts @@ -8,6 +8,14 @@ import { const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireMatrixConfig(snapshot: Awaited>) { + const config = snapshot.config.channels?.matrix; + if (!config) { + throw new Error("expected Matrix runtime config"); + } + return config; +} + describe("secrets runtime snapshot matrix shadowing", () => { it("ignores Matrix password refs that are shadowed by scoped env access tokens", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -121,7 +129,7 @@ describe("secrets runtime snapshot matrix shadowing", () => { loadAuthStore: () => loadAuthStoreWithProfiles({}), }); - expect(snapshot.config.channels?.matrix?.password).toEqual({ + expect(requireMatrixConfig(snapshot).password).toEqual({ source: "env", provider: "default", id: "MATRIX_PASSWORD", diff --git a/src/secrets/runtime-web-tools-state.test.ts b/src/secrets/runtime-web-tools-state.test.ts index 6cd11c946c9..6cfe2f41875 100644 --- a/src/secrets/runtime-web-tools-state.test.ts +++ b/src/secrets/runtime-web-tools-state.test.ts @@ -27,17 +27,20 @@ describe("runtime web tools state", () => { }); const first = getActiveRuntimeWebToolsMetadata(); - expect(first?.search.providerConfigured).toBe("gemini"); - expect(first?.search.selectedProvider).toBe("gemini"); - expect(first?.search.selectedProviderKeySource).toBe("secretRef"); if (!first) { throw new Error("missing runtime web tools metadata"); } + expect(first.search.providerConfigured).toBe("gemini"); + expect(first.search.selectedProvider).toBe("gemini"); + expect(first.search.selectedProviderKeySource).toBe("secretRef"); first.search.providerConfigured = "brave"; first.search.selectedProvider = "brave"; const second = getActiveRuntimeWebToolsMetadata(); - expect(second?.search.providerConfigured).toBe("gemini"); - expect(second?.search.selectedProvider).toBe("gemini"); + if (!second) { + throw new Error("missing cloned runtime web tools metadata"); + } + expect(second.search.providerConfigured).toBe("gemini"); + expect(second.search.selectedProvider).toBe("gemini"); }); }); diff --git a/src/secrets/runtime-zalo-token-activity.test.ts b/src/secrets/runtime-zalo-token-activity.test.ts index 2658979468c..4f35033eba0 100644 --- a/src/secrets/runtime-zalo-token-activity.test.ts +++ b/src/secrets/runtime-zalo-token-activity.test.ts @@ -8,6 +8,14 @@ import { const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); +function requireZaloConfig(snapshot: Awaited>) { + const config = snapshot.config.channels?.zalo; + if (!config) { + throw new Error("expected Zalo runtime config"); + } + return config; +} + describe("secrets runtime snapshot zalo token activity", () => { it("treats top-level Zalo botToken refs as active even when tokenFile is configured", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -26,7 +34,7 @@ describe("secrets runtime snapshot zalo token activity", () => { loadAuthStore: () => loadAuthStoreWithProfiles({}), }); - expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-token"); + expect(requireZaloConfig(snapshot).botToken).toBe("resolved-zalo-token"); expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( "channels.zalo.botToken", ); @@ -83,7 +91,7 @@ describe("secrets runtime snapshot zalo token activity", () => { loadAuthStore: () => loadAuthStoreWithProfiles({}), }); - expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-top-level-token"); + expect(requireZaloConfig(snapshot).botToken).toBe("resolved-zalo-top-level-token"); expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( "channels.zalo.botToken", ); diff --git a/src/secrets/runtime.fast-path.test.ts b/src/secrets/runtime.fast-path.test.ts index b8fae79cc30..253307cb95f 100644 --- a/src/secrets/runtime.fast-path.test.ts +++ b/src/secrets/runtime.fast-path.test.ts @@ -38,6 +38,16 @@ function emptyAuthStore(): AuthProfileStore { return { version: 1, profiles: {} }; } +function requireGatewayAuth( + snapshot: Awaited>, +) { + const auth = snapshot.config.gateway?.auth; + if (!auth) { + throw new Error("expected gateway auth config"); + } + return auth; +} + describe("secrets runtime fast path", () => { afterEach(() => { runtimePrepareImportMock.mockClear(); @@ -67,7 +77,7 @@ describe("secrets runtime fast path", () => { }); expect(runtimePrepareImportMock).not.toHaveBeenCalled(); - expect(snapshot.config.gateway?.auth?.token).toBe("plain-startup-token"); + expect(requireGatewayAuth(snapshot).token).toBe("plain-startup-token"); expect(snapshot.authStores).toEqual([ { agentDir: "/tmp/openclaw-agent-main", diff --git a/src/secrets/runtime.gateway-auth.integration.test.ts b/src/secrets/runtime.gateway-auth.integration.test.ts index ec222597bc8..751009d403e 100644 --- a/src/secrets/runtime.gateway-auth.integration.test.ts +++ b/src/secrets/runtime.gateway-auth.integration.test.ts @@ -112,9 +112,11 @@ describe("secrets runtime snapshot gateway-auth integration", () => { ).rejects.toThrow(/runtime snapshot refresh failed: .*MISSING_GATEWAY_AUTH_TOKEN/i); const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); - expect(activeAfterFailure).not.toBeNull(); + if (activeAfterFailure === null) { + throw new Error("Expected active secrets runtime snapshot"); + } expect(getRuntimeConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); - expect(activeAfterFailure?.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); + expect(activeAfterFailure.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); const persistedConfig = JSON.parse( await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 98f1567087c..4bc098f1970 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -53,9 +53,11 @@ describe("secret target registry fast path", () => { it("resolves bundled channel targets by explicit channel id without manifest scans", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); - expect(target).not.toBeNull(); - expect(target?.entry.id).toBe("channels.googlechat.serviceAccount"); - expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); + if (!target) { + throw new Error("expected googlechat service account target"); + } + expect(target.entry.id).toBe("channels.googlechat.serviceAccount"); + expect(target.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "googlechat", artifactBasename: "secret-contract-api.js", diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 2802a2bcad1..61c6915157c 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -33,7 +33,6 @@ describe("secret target registry", () => { it("resolves config targets by exact path including sibling ref metadata", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); - expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("channels.googlechat.serviceAccount"); expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); }); @@ -58,7 +57,6 @@ describe("secret target registry", () => { "apiKey", ]); - expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("plugins.entries.exa.config.webSearch.apiKey"); const fetchTarget = resolveConfigSecretTargetByPath([ @@ -69,7 +67,6 @@ describe("secret target registry", () => { "webFetch", "apiKey", ]); - expect(fetchTarget).not.toBeNull(); expect(fetchTarget?.entry?.id).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); }); @@ -88,7 +85,6 @@ describe("secret target registry", () => { "apiKey", ]); - expect(target).not.toBeNull(); expect(target?.entry?.id).toBe("plugins.entries.voice-call.config.tts.providers.*.apiKey"); }); }); diff --git a/src/security/audit-channel-account-metadata.test.ts b/src/security/audit-channel-account-metadata.test.ts index 65835722586..7d9026763c8 100644 --- a/src/security/audit-channel-account-metadata.test.ts +++ b/src/security/audit-channel-account-metadata.test.ts @@ -37,6 +37,21 @@ function stubChannelPlugin(): ChannelPlugin { }; } +function requireDangerousMatchingFinding( + findings: Awaited>, +) { + const finding = findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", + ); + expect(finding).toMatchObject({ + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + }); + if (!finding) { + throw new Error("Expected dangerous name matching finding"); + } + return finding; +} + describe("security audit channel account metadata", () => { it("does not treat prototype properties as explicit account config paths", async () => { const cfg: OpenClawConfig = { @@ -55,12 +70,7 @@ describe("security audit channel account metadata", () => { plugins: [stubChannelPlugin()], }); - const dangerousMatchingFinding = findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", - ); - expect(dangerousMatchingFinding).toMatchObject({ - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - }); - expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)"); + const dangerousMatchingFinding = requireDangerousMatchingFinding(findings); + expect(dangerousMatchingFinding.title).not.toContain("(account: toString)"); }); }); diff --git a/src/security/audit-channel-readonly-resolution.test.ts b/src/security/audit-channel-readonly-resolution.test.ts index a53ab13a546..7bb19383db5 100644 --- a/src/security/audit-channel-readonly-resolution.test.ts +++ b/src/security/audit-channel-readonly-resolution.test.ts @@ -31,6 +31,18 @@ function stubChannelPlugin(params: { }; } +function requireReadOnlyResolutionFinding( + findings: Awaited>, +) { + const finding = findings.find( + (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", + ); + if (!finding) { + throw new Error("Expected Zalo read-only resolution warning"); + } + return finding; +} + describe("security audit channel read-only resolution", () => { it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { const plugin = stubChannelPlugin({ @@ -54,12 +66,10 @@ describe("security audit channel read-only resolution", () => { plugins: [plugin], }); - const finding = findings.find( - (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.title).toContain("could not be fully resolved"); - expect(finding?.detail).toContain("zalouser:default: failed to resolve account"); - expect(finding?.detail).toContain("missing SecretRef"); + const finding = requireReadOnlyResolutionFinding(findings); + expect(finding.severity).toBe("warn"); + expect(finding.title).toContain("could not be fully resolved"); + expect(finding.detail).toContain("zalouser:default: failed to resolve account"); + expect(finding.detail).toContain("missing SecretRef"); }); }); diff --git a/src/security/audit-exec-safe-bins.test.ts b/src/security/audit-exec-safe-bins.test.ts index caf61f246d7..a3b87010f18 100644 --- a/src/security/audit-exec-safe-bins.test.ts +++ b/src/security/audit-exec-safe-bins.test.ts @@ -12,6 +12,17 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === "warn"); } +function requireFinding( + checkId: "tools.exec.safe_bin_trusted_dirs_risky", + findings: ReturnType, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + describe("security audit exec safe-bin findings", () => { it.each([ { @@ -136,13 +147,11 @@ describe("security audit exec safe-bin findings", () => { }, } satisfies OpenClawConfig); - const riskyFinding = findings.find( - (finding) => finding.checkId === "tools.exec.safe_bin_trusted_dirs_risky", - ); - expect(riskyFinding?.severity).toBe("warn"); - expect(riskyFinding?.detail).toContain(riskyGlobalTrustedDirs[0]); - expect(riskyFinding?.detail).toContain(riskyGlobalTrustedDirs[1]); - expect(riskyFinding?.detail).toContain("agents.list.ops.tools.exec"); + const riskyFinding = requireFinding("tools.exec.safe_bin_trusted_dirs_risky", findings); + expect(riskyFinding.severity).toBe("warn"); + expect(riskyFinding.detail).toContain(riskyGlobalTrustedDirs[0]); + expect(riskyFinding.detail).toContain(riskyGlobalTrustedDirs[1]); + expect(riskyFinding.detail).toContain("agents.list.ops.tools.exec"); }); it("ignores non-risky absolute dirs", () => { diff --git a/src/security/audit-exec-surface.test.ts b/src/security/audit-exec-surface.test.ts index ecfcab73b7d..a47ac7a95a2 100644 --- a/src/security/audit-exec-surface.test.ts +++ b/src/security/audit-exec-surface.test.ts @@ -16,6 +16,17 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === severity); } +function requireFinding( + checkId: "tools.exec.fs_tools_disabled_but_exec_enabled", + findings: ReturnType, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + afterEach(() => { saveExecApprovals({ version: 1, agents: {} }); }); @@ -132,13 +143,11 @@ describe("security audit exec surface findings", () => { }, } satisfies OpenClawConfig); - const finding = findings.find( - (entry) => entry.checkId === "tools.exec.fs_tools_disabled_but_exec_enabled", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("tools"); - expect(finding?.detail).toContain("runtime=[exec, process]"); - expect(finding?.remediation).toContain("deny exec and process"); + const finding = requireFinding("tools.exec.fs_tools_disabled_but_exec_enabled", findings); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain("tools"); + expect(finding.detail).toContain("runtime=[exec, process]"); + expect(finding.remediation).toContain("deny exec and process"); }); it("does not warn when sandbox filesystem policy constrains exec", () => { diff --git a/src/security/audit-extra.async.test.ts b/src/security/audit-extra.async.test.ts index 30d8db58066..b855a18e059 100644 --- a/src/security/audit-extra.async.test.ts +++ b/src/security/audit-extra.async.test.ts @@ -205,8 +205,12 @@ description: test skill const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); expect(scanSpy.mock.calls.map(([dirPath]) => path.basename(dirPath))).toEqual(["demo"]); - const codeSafetyFinding = findings.find((f) => f.checkId === "plugins.code_safety"); - expect(codeSafetyFinding?.title).toContain('Plugin "demo"'); + const codeSafetyFinding = requireFinding( + findings, + (finding) => finding.checkId === "plugins.code_safety", + "plugin code-safety", + ); + expect(codeSafetyFinding.title).toContain('Plugin "demo"'); expect(findings.map((f) => f.title).join("\n")).not.toContain(".openclaw-install-backups"); } finally { scanSpy.mockRestore(); diff --git a/src/security/audit-extra.sync.test.ts b/src/security/audit-extra.sync.test.ts index 8d872eb32fb..6c5f0b2a980 100644 --- a/src/security/audit-extra.sync.test.ts +++ b/src/security/audit-extra.sync.test.ts @@ -10,6 +10,14 @@ vi.mock("../plugins/web-search-credential-presence.js", () => ({ hasConfiguredWebSearchCredential: () => false, })); +function requireFirstFinding(findings: readonly T[], label: string): T { + const [finding] = findings; + if (!finding) { + throw new Error(`Expected ${label} finding`); + } + return finding; +} + describe("collectAttackSurfaceSummaryFindings", () => { it.each([ { @@ -39,7 +47,10 @@ describe("collectAttackSurfaceSummaryFindings", () => { expectedDetail: ["hooks.internal: disabled"], }, ])("$name", ({ cfg, expectedDetail }) => { - const [finding] = collectAttackSurfaceSummaryFindings(cfg); + const finding = requireFirstFinding( + collectAttackSurfaceSummaryFindings(cfg), + "attack surface summary", + ); expect(finding.checkId).toBe("summary.attack_surface"); for (const snippet of expectedDetail) { expect(finding.detail).toContain(snippet); @@ -89,19 +100,22 @@ describe("collectSmallModelRiskFindings", () => { detailExcludes: ["No web/browser tools detected"], }, ])("$name", ({ cfg, env, detailIncludes, detailExcludes }) => { - const [finding] = collectSmallModelRiskFindings({ - cfg, - env, - }); + const finding = requireFirstFinding( + collectSmallModelRiskFindings({ + cfg, + env, + }), + "small model risk", + ); - expect(finding?.checkId).toBe("models.small_params"); - expect(finding?.severity).toBe("critical"); - expect(finding?.detail).toContain("ollama/mistral-8b"); + expect(finding.checkId).toBe("models.small_params"); + expect(finding.severity).toBe("critical"); + expect(finding.detail).toContain("ollama/mistral-8b"); for (const snippet of detailIncludes) { - expect(finding?.detail).toContain(snippet); + expect(finding.detail).toContain(snippet); } for (const snippet of detailExcludes) { - expect(finding?.detail).not.toContain(snippet); + expect(finding.detail).not.toContain(snippet); } }); }); diff --git a/src/security/audit-gateway-auth-selection.test.ts b/src/security/audit-gateway-auth-selection.test.ts index ca1c139ed6a..2a0c4b59f56 100644 --- a/src/security/audit-gateway-auth-selection.test.ts +++ b/src/security/audit-gateway-auth-selection.test.ts @@ -3,6 +3,16 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayProbeAuthSafe, resolveGatewayProbeTarget } from "../gateway/probe-auth.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; +function requireProbeAuthWarning(findings: ReturnType) { + const warning = findings.find( + (finding) => finding.checkId === "gateway.probe_auth_secretref_unavailable", + ); + if (!warning) { + throw new Error("Expected gateway probe auth SecretRef warning"); + } + return warning; +} + describe("security audit gateway auth selection", () => { it("applies gateway auth precedence across local and remote modes", async () => { const makeProbeEnv = (env?: { token?: string; password?: string }) => { @@ -129,19 +139,21 @@ describe("security audit gateway auth selection", () => { mode: "local", env: {}, }); - const warning = collectDeepProbeFindings({ - deep: { - gateway: { - attempted: true, - url: "ws://127.0.0.1:18789", - ok: true, - error: null, - close: null, + const warning = requireProbeAuthWarning( + collectDeepProbeFindings({ + deep: { + gateway: { + attempted: true, + url: "ws://127.0.0.1:18789", + ok: true, + error: null, + close: null, + }, }, - }, - authWarning: result.warning, - }).find((finding) => finding.checkId === "gateway.probe_auth_secretref_unavailable"); - expect(warning?.severity).toBe("warn"); - expect(warning?.detail).toContain("gateway.auth.token"); + authWarning: result.warning, + }), + ); + expect(warning.severity).toBe("warn"); + expect(warning.detail).toContain("gateway.auth.token"); }); }); diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index 933c7b2bb66..b5f3c7d33ae 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -10,6 +10,20 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === severity); } +function requireDangerousFlagsFinding( + findings: ReturnType, + label: string, +) { + const finding = findings.find((entry) => entry.checkId === "config.insecure_or_dangerous_flags"); + expect(finding, label).toMatchObject({ + checkId: "config.insecure_or_dangerous_flags", + }); + if (!finding) { + throw new Error(`Expected dangerous flags finding for ${label}`); + } + return finding; +} + describe("security audit gateway exposure findings", () => { it("warns on insecure or dangerous flags", () => { const cases = [ @@ -69,15 +83,10 @@ describe("security audit gateway exposure findings", () => { expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), ); } - const finding = findings.find( - (entry) => entry.checkId === "config.insecure_or_dangerous_flags", - ); - expect(finding, testCase.name).toMatchObject({ - checkId: "config.insecure_or_dangerous_flags", - }); - expect(finding?.severity, testCase.name).toBe("warn"); + const finding = requireDangerousFlagsFinding(findings, testCase.name); + expect(finding.severity, testCase.name).toBe("warn"); for (const snippet of testCase.expectedDangerousDetails) { - expect(finding?.detail, `${testCase.name}:${snippet}`).toContain(snippet); + expect(finding.detail, `${testCase.name}:${snippet}`).toContain(snippet); } } }); @@ -150,10 +159,8 @@ describe("security audit gateway exposure findings", () => { expect( findings.some((finding) => finding.checkId === "gateway.control_ui.allowed_origins_required"), ).toBe(false); - const flags = findings.find( - (finding) => finding.checkId === "config.insecure_or_dangerous_flags", - ); - expect(flags?.detail ?? "").toContain( + const flags = requireDangerousFlagsFinding(findings, "host header origin fallback"); + expect(flags.detail).toContain( "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true", ); }); diff --git a/src/security/audit-gateway-http-auth.test.ts b/src/security/audit-gateway-http-auth.test.ts index 9b502cd4e54..3388743fcdb 100644 --- a/src/security/audit-gateway-http-auth.test.ts +++ b/src/security/audit-gateway-http-auth.test.ts @@ -5,6 +5,14 @@ import { collectGatewayHttpSessionKeyOverrideFindings, } from "./audit-extra.sync.js"; +function requireFinding(findings: Array<{ checkId: string; detail: string }>, checkId: string) { + const finding = findings.find((entry) => entry.checkId === checkId); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + describe("security audit gateway HTTP auth findings", () => { it.each([ { @@ -75,9 +83,9 @@ describe("security audit gateway HTTP auth findings", () => { if (expectedFinding) { expect(findings).toEqual(expect.arrayContaining([expect.objectContaining(expectedFinding)])); if (detailIncludes) { - const finding = findings.find((entry) => entry.checkId === expectedFinding.checkId); + const finding = requireFinding(findings, expectedFinding.checkId); for (const text of detailIncludes) { - expect(finding?.detail, `${expectedFinding.checkId}:${text}`).toContain(text); + expect(finding.detail, `${expectedFinding.checkId}:${text}`).toContain(text); } } } diff --git a/src/security/audit-node-command-findings.test.ts b/src/security/audit-node-command-findings.test.ts index 04fc4ab2b73..7247b2608ec 100644 --- a/src/security/audit-node-command-findings.test.ts +++ b/src/security/audit-node-command-findings.test.ts @@ -19,6 +19,20 @@ function expectDetailText(params: { } } +function requireFinding( + findings: ReturnType< + typeof collectNodeDenyCommandPatternFindings | typeof collectNodeDangerousAllowCommandFindings + >, + checkId: string, + label: string, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + if (!finding) { + throw new Error(`Expected ${checkId} finding for ${label}`); + } + return finding; +} + describe("security audit node command findings", () => { it("evaluates ineffective gateway.nodes.denyCommands entries", () => { const cases = [ @@ -72,12 +86,14 @@ describe("security audit node command findings", () => { for (const testCase of cases) { const findings = collectNodeDenyCommandPatternFindings(testCase.cfg); - const finding = findings.find( - (entry) => entry.checkId === "gateway.nodes.deny_commands_ineffective", + const finding = requireFinding( + findings, + "gateway.nodes.deny_commands_ineffective", + testCase.name, ); - expect(finding?.severity, testCase.name).toBe("warn"); + expect(finding.severity, testCase.name).toBe("warn"); expectDetailText({ - detail: finding?.detail, + detail: finding.detail, name: testCase.name, includes: testCase.detailIncludes, excludes: "detailExcludes" in testCase ? testCase.detailExcludes : [], @@ -147,9 +163,14 @@ describe("security audit node command findings", () => { expect(finding, testCase.name).toBeUndefined(); continue; } - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + const dangerousFinding = requireFinding( + findings, + "gateway.nodes.allow_commands_dangerous", + testCase.name, + ); + expect(dangerousFinding.severity, testCase.name).toBe(testCase.expectedSeverity); expectDetailText({ - detail: finding?.detail, + detail: dangerousFinding.detail, name: testCase.name, includes: ["camera.snap", "screen.record"], }); diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index f18c3d41421..2071432700b 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -165,6 +165,17 @@ describe("security audit install metadata findings", () => { return await collectPluginsTrustFindingsForTest({ cfg, stateDir }); }; + const requireInstallFinding = ( + findings: Awaited>, + checkId: string, + ) => { + const finding = findings.find((entry) => entry.checkId === checkId); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; + }; + const writePluginIndexInstallRecords = async ( stateDir: string, records: Record, @@ -362,12 +373,10 @@ describe("security audit install metadata findings", () => { }, reportedStateDir, ); - const phantomFinding = reportedFindings.find( - (finding) => finding.checkId === "plugins.allow_phantom_entries", - ); - expect(phantomFinding?.severity).toBe("warn"); - expect(phantomFinding?.detail).toContain("ghost-plugin-xyz"); - expect(phantomFinding?.detail).not.toContain("installed-plugin"); + const phantomFinding = requireInstallFinding(reportedFindings, "plugins.allow_phantom_entries"); + expect(phantomFinding.severity).toBe("warn"); + expect(phantomFinding.detail).toContain("ghost-plugin-xyz"); + expect(phantomFinding.detail).not.toContain("installed-plugin"); }); it("ignores install backup and debris dirs when auditing installed plugin roots", async () => { @@ -387,15 +396,14 @@ describe("security audit install metadata findings", () => { const findings = await runInstallMetadataAudit({}, stateDir); - const noAllowlist = findings.find( - (finding) => finding.checkId === "plugins.extensions_no_allowlist", - ); - expect(noAllowlist?.detail).toContain("Found 1 extension(s)"); + const noAllowlist = requireInstallFinding(findings, "plugins.extensions_no_allowlist"); + expect(noAllowlist.detail).toContain("Found 1 extension(s)"); - const toolsReachable = findings.find( - (finding) => finding.checkId === "plugins.tools_reachable_permissive_policy", + const toolsReachable = requireInstallFinding( + findings, + "plugins.tools_reachable_permissive_policy", ); - expect(toolsReachable?.detail).toContain("Enabled extension plugins: live-plugin."); + expect(toolsReachable.detail).toContain("Enabled extension plugins: live-plugin."); expect(findings.map((finding) => finding.detail).join("\n")).not.toContain( ".openclaw-install-backups", ); diff --git a/src/security/audit-probe-failure.test.ts b/src/security/audit-probe-failure.test.ts index 814df4a35c6..4fd3e735077 100644 --- a/src/security/audit-probe-failure.test.ts +++ b/src/security/audit-probe-failure.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; +function requireProbeFailure(findings: ReturnType) { + const finding = findings.find((entry) => entry.checkId === "gateway.probe_failed"); + if (!finding) { + throw new Error("Expected gateway probe failure finding"); + } + return finding; +} + describe("security audit deep probe failure", () => { it("adds probe_failed warnings for deep probe failure modes", () => { const cases: Array<{ @@ -14,7 +22,7 @@ describe("security audit deep probe failure", () => { close?: { code: number; reason: string } | null; }; }; - expectedError?: string; + expectedError: string; }> = [ { name: "probe returns failed result", @@ -46,11 +54,8 @@ describe("security audit deep probe failure", () => { for (const testCase of cases) { const findings = collectDeepProbeFindings({ deep: testCase.deep }); - expect( - findings.some((finding) => finding.checkId === "gateway.probe_failed"), - testCase.name, - ).toBe(true); - expect(findings[0]?.detail).toContain(testCase.expectedError!); + const probeFailure = requireProbeFailure(findings); + expect(probeFailure.detail, testCase.name).toContain(testCase.expectedError); } }); }); diff --git a/src/security/audit-sandbox-browser.test.ts b/src/security/audit-sandbox-browser.test.ts index 75e621b9654..27f16f8cb57 100644 --- a/src/security/audit-sandbox-browser.test.ts +++ b/src/security/audit-sandbox-browser.test.ts @@ -14,6 +14,17 @@ function hasFinding( return findings.some((finding) => finding.checkId === checkId && finding.severity === severity); } +function requireFinding( + checkId: "sandbox.browser_container.hash_epoch_stale", + findings: Array<{ checkId: string; severity: string; detail: string }>, +) { + const finding = findings.find((entry) => entry.checkId === checkId); + if (!finding) { + throw new Error(`Expected ${checkId} finding`); + } + return finding; +} + describe("security audit sandbox browser findings", () => { it("warns when sandbox browser containers have missing or stale hash labels", async () => { const findings = await collectSandboxBrowserHashLabelFindings({ @@ -49,10 +60,8 @@ describe("security audit sandbox browser findings", () => { expect(hasFinding("sandbox.browser_container.hash_label_missing", "warn", findings)).toBe(true); expect(hasFinding("sandbox.browser_container.hash_epoch_stale", "warn", findings)).toBe(true); - const staleEpoch = findings.find( - (finding) => finding.checkId === "sandbox.browser_container.hash_epoch_stale", - ); - expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old"); + const staleEpoch = requireFinding("sandbox.browser_container.hash_epoch_stale", findings); + expect(staleEpoch.detail).toContain("openclaw-sbx-browser-old"); }); it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => { diff --git a/src/security/audit-small-model-risk.test.ts b/src/security/audit-small-model-risk.test.ts index 7d788b96b9d..f9bf17f0378 100644 --- a/src/security/audit-small-model-risk.test.ts +++ b/src/security/audit-small-model-risk.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { collectSmallModelRiskFindings } from "./audit-extra.summary.js"; +function requireFirstSmallModelFinding( + findings: ReturnType, + label: string, +) { + const [finding] = findings; + if (!finding) { + throw new Error(`Expected small-model risk finding for ${label}`); + } + return finding; +} + describe("security audit small-model risk findings", () => { it("scores small-model risk by tool/sandbox exposure", () => { const cases: Array<{ @@ -35,37 +46,43 @@ describe("security audit small-model risk findings", () => { ]; for (const testCase of cases) { - const [finding] = collectSmallModelRiskFindings({ - cfg: testCase.cfg, - env: process.env, - }); - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + const finding = requireFirstSmallModelFinding( + collectSmallModelRiskFindings({ + cfg: testCase.cfg, + env: process.env, + }), + testCase.name, + ); + expect(finding.severity, testCase.name).toBe(testCase.expectedSeverity); for (const snippet of testCase.detailIncludes) { - expect(finding?.detail, `${testCase.name}:${snippet}`).toContain(snippet); + expect(finding.detail, `${testCase.name}:${snippet}`).toContain(snippet); } } }); it("resolves configured aliases before parameter-size classification", () => { - const [finding] = collectSmallModelRiskFindings({ - cfg: { - agents: { - defaults: { - model: { primary: "tiny" }, - models: { - "ollama/mistral-8b": { alias: "tiny" }, + const finding = requireFirstSmallModelFinding( + collectSmallModelRiskFindings({ + cfg: { + agents: { + defaults: { + model: { primary: "tiny" }, + models: { + "ollama/mistral-8b": { alias: "tiny" }, + }, }, }, - }, - tools: { web: { search: { enabled: true }, fetch: { enabled: true } } }, - browser: { enabled: true }, - } satisfies OpenClawConfig, - env: {}, - }); + tools: { web: { search: { enabled: true }, fetch: { enabled: true } } }, + browser: { enabled: true }, + } satisfies OpenClawConfig, + env: {}, + }), + "configured alias", + ); - expect(finding?.checkId).toBe("models.small_params"); - expect(finding?.detail).toContain("ollama/mistral-8b"); - expect(finding?.detail).toContain("@ agents.defaults.model.primary"); - expect(finding?.detail).not.toContain("- tiny"); + expect(finding.checkId).toBe("models.small_params"); + expect(finding.detail).toContain("ollama/mistral-8b"); + expect(finding.detail).toContain("@ agents.defaults.model.primary"); + expect(finding.detail).not.toContain("- tiny"); }); }); diff --git a/src/security/audit-summary.test.ts b/src/security/audit-summary.test.ts index b0497bfc7b0..ce65f6ad3bf 100644 --- a/src/security/audit-summary.test.ts +++ b/src/security/audit-summary.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { collectAttackSurfaceSummaryFindings } from "./audit-extra.summary.js"; +function requireAttackSurfaceSummary( + findings: ReturnType, +) { + const summary = findings.find((f) => f.checkId === "summary.attack_surface"); + expect(summary).toEqual( + expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), + ); + if (!summary) { + throw new Error("Expected attack surface summary finding"); + } + return summary; +} + describe("security audit attack surface summary", () => { it("includes an attack surface summary (info)", () => { const cfg: OpenClawConfig = { @@ -12,13 +25,8 @@ describe("security audit attack surface summary", () => { }; const findings = collectAttackSurfaceSummaryFindings(cfg); - const summary = findings.find((f) => f.checkId === "summary.attack_surface"); + const summary = requireAttackSurfaceSummary(findings); - expect(findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), - ]), - ); - expect(summary?.detail).toContain("trust model: personal assistant"); + expect(summary.detail).toContain("trust model: personal assistant"); }); }); diff --git a/src/security/audit-trust-model.test.ts b/src/security/audit-trust-model.test.ts index 84f8ef66df3..afd33d76899 100644 --- a/src/security/audit-trust-model.test.ts +++ b/src/security/audit-trust-model.test.ts @@ -9,6 +9,16 @@ function audit(cfg: OpenClawConfig) { return [...collectExposureMatrixFindings(cfg), ...collectLikelyMultiUserSetupFindings(cfg)]; } +function requireMultiUserHeuristicFinding(findings: ReturnType) { + const finding = findings.find( + (entry) => entry.checkId === "security.trust_model.multi_user_heuristic", + ); + if (!finding) { + throw new Error("Expected multi-user heuristic finding"); + } + return finding; +} + describe("security audit trust model findings", () => { it("evaluates trust-model exposure findings", () => { const cases = [ @@ -108,15 +118,13 @@ describe("security audit trust model findings", () => { } satisfies OpenClawConfig, assert: () => { const findings = audit(cases[4].cfg); - const finding = findings.find( - (entry) => entry.checkId === "security.trust_model.multi_user_heuristic", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain( + const finding = requireMultiUserHeuristicFinding(findings); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain( 'channels.discord.groupPolicy="allowlist" with configured group targets', ); - expect(finding?.detail).toContain("personal-assistant"); - expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); + expect(finding.detail).toContain("personal-assistant"); + expect(finding.remediation).toContain('agents.defaults.sandbox.mode="all"'); }, }, { diff --git a/src/security/audit-workspace-skill-escape.test.ts b/src/security/audit-workspace-skill-escape.test.ts index 28c319dd33e..f708e798670 100644 --- a/src/security/audit-workspace-skill-escape.test.ts +++ b/src/security/audit-workspace-skill-escape.test.ts @@ -47,11 +47,9 @@ describe("security audit workspace skill path escape findings", () => { const findings = await collectWorkspaceSkillSymlinkEscapeFindings({ cfg: { agents: { defaults: { workspace: workspaceDir } } } satisfies OpenClawConfig, }); - const finding = findings.find( - (entry) => entry.checkId === "skills.workspace.symlink_escape", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain(outsideSkillPath); + const finding = requireFinding(findings, "skills.workspace.symlink_escape"); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain(outsideSkillPath); })() : Promise.resolve(), (async () => { diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index b7239835b3b..8d60f0e5f51 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -496,8 +496,10 @@ describe("external-content security", () => { // The malicious tags are contained within the safe boundaries const startMatch = result.match(/<<>>/); - expect(startMatch).not.toBeNull(); - expect(result.indexOf(startMatch![0])).toBeLessThan(result.indexOf("")); + if (startMatch === null) { + throw new Error("Expected external content start marker"); + } + expect(result.indexOf(startMatch[0])).toBeLessThan(result.indexOf("")); }); }); }); diff --git a/src/security/fix.test.ts b/src/security/fix.test.ts index eb33512c097..0f85dce98f0 100644 --- a/src/security/fix.test.ts +++ b/src/security/fix.test.ts @@ -122,7 +122,11 @@ describe("security fix", () => { ) => { const whatsapp = channels.whatsapp; const accounts = whatsapp.accounts as Record>; - expect(accounts[accountId]?.groupPolicy).toBe(expectedPolicy); + const account = accounts[accountId]; + if (!account) { + throw new Error(`Expected WhatsApp account ${accountId}`); + } + expect(account.groupPolicy).toBe(expectedPolicy); return accounts; }; diff --git a/src/security/safe-regex.test.ts b/src/security/safe-regex.test.ts index 1de9f51d977..851d13a55e0 100644 --- a/src/security/safe-regex.test.ts +++ b/src/security/safe-regex.test.ts @@ -6,6 +6,15 @@ import { testRegexWithBoundedInput, } from "./safe-regex.js"; +function expectCompiledRegex(pattern: string, flags?: string): RegExp { + const re = compileSafeRegex(pattern, flags); + expect(re).toBeInstanceOf(RegExp); + if (!re) { + throw new Error(`Expected ${pattern} to compile safely`); + } + return re; +} + describe("safe regex", () => { it.each([ ["(a+)+$", true], @@ -30,16 +39,14 @@ describe("safe regex", () => { }); it("compiles common safe filter regex", () => { - const re = compileSafeRegex("^agent:.*:discord:"); - expect(re).toBeInstanceOf(RegExp); - expect(re?.test("agent:main:discord:channel:123")).toBe(true); - expect(re?.test("agent:main:telegram:channel:123")).toBe(false); + const re = expectCompiledRegex("^agent:.*:discord:"); + expect(re.test("agent:main:discord:channel:123")).toBe(true); + expect(re.test("agent:main:telegram:channel:123")).toBe(false); }); it("supports explicit flags", () => { - const re = compileSafeRegex("token=([A-Za-z0-9]+)", "gi"); - expect(re).toBeInstanceOf(RegExp); - expect("TOKEN=abcd1234".replace(re as RegExp, "***")).toBe("***"); + const re = expectCompiledRegex("token=([A-Za-z0-9]+)", "gi"); + expect("TOKEN=abcd1234".replace(re, "***")).toBe("***"); }); it.each([ diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index a0e5d496bd7..1cdf5096ca8 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -85,6 +85,15 @@ function expectInspectSuccess( expect(result.entries).toHaveLength(expectedEntries); } +function expectIcaclsResetCommand( + result: ReturnType, +): NonNullable> { + if (!result) { + throw new Error("Expected icacls reset command"); + } + return result; +} + function expectSummaryCounts( entries: readonly WindowsAclEntry[], expected: { trusted?: number; untrustedWorld?: number; untrustedGroup?: number }, @@ -758,10 +767,10 @@ Successfully processed 1 files`; isDir: false, env, }); - expect(result).not.toBeNull(); - expect(result?.command).toBe(DEFAULT_ICACLS); - expect(result?.args).toContain("C:\\test\\file.txt"); - expect(result?.args).toContain("/inheritance:r"); + expect(result).toMatchObject({ + command: DEFAULT_ICACLS, + args: expect.arrayContaining(["C:\\test\\file.txt", "/inheritance:r"]), + }); }); it("uses a validated SystemRoot for the structured command executable", () => { @@ -769,8 +778,9 @@ Successfully processed 1 files`; isDir: false, env: { SystemRoot: "D:\\Windows", USERNAME: "TestUser" }, }); + const command = expectIcaclsResetCommand(result); - expect(result?.command).toBe("D:\\Windows\\System32\\icacls.exe"); + expect(command.command).toBe("D:\\Windows\\System32\\icacls.exe"); }); it("returns command with system username when env is empty (falls back to os.userInfo)", () => { @@ -781,9 +791,10 @@ Successfully processed 1 files`; userInfo: mockUserInfo, }); // Should return a valid command using the system username - expect(result).not.toBeNull(); - expect(result?.command).toBe(DEFAULT_ICACLS); - expect(result?.args).toContain(`${MOCK_USERNAME}:F`); + expect(result).toMatchObject({ + command: DEFAULT_ICACLS, + args: expect.arrayContaining([`${MOCK_USERNAME}:F`]), + }); }); it("includes display string matching formatIcaclsResetCommand", () => { @@ -796,7 +807,8 @@ Successfully processed 1 files`; isDir: false, env, }); - expect(result?.display).toBe(expected); + const command = expectIcaclsResetCommand(result); + expect(command.display).toBe(expected); }); it("world SIDs in USERSID env are not added to trusted set", () => { diff --git a/src/sessions/session-lifecycle-events.test.ts b/src/sessions/session-lifecycle-events.test.ts index d8654925405..1af7c67890c 100644 --- a/src/sessions/session-lifecycle-events.test.ts +++ b/src/sessions/session-lifecycle-events.test.ts @@ -48,12 +48,12 @@ describe("session lifecycle events", () => { const unsubscribeNoisy = onSessionLifecycleEvent(noisy.listener); const unsubscribeHealthy = onSessionLifecycleEvent(healthy.listener); - expect(() => + expect( emitSessionLifecycleEvent({ sessionKey: "agent:main:main", reason: "resumed", }), - ).not.toThrow(); + ).toBeUndefined(); expect(noisy.calls).toHaveLength(1); expect(healthy.calls).toHaveLength(1); diff --git a/src/sessions/transcript-events.test.ts b/src/sessions/transcript-events.test.ts index bb7a366f80e..3256793d677 100644 --- a/src/sessions/transcript-events.test.ts +++ b/src/sessions/transcript-events.test.ts @@ -45,7 +45,7 @@ describe("transcript events", () => { cleanup.push(onSessionTranscriptUpdate(first)); cleanup.push(onSessionTranscriptUpdate(second)); - expect(() => emitSessionTranscriptUpdate("/tmp/session.jsonl")).not.toThrow(); + expect(emitSessionTranscriptUpdate("/tmp/session.jsonl")).toBeUndefined(); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); }); diff --git a/src/shared/frontmatter.test.ts b/src/shared/frontmatter.test.ts index 776b9b58194..be98fb5a7ab 100644 --- a/src/shared/frontmatter.test.ts +++ b/src/shared/frontmatter.test.ts @@ -11,6 +11,15 @@ import { resolveOpenClawManifestRequires, } from "./frontmatter.js"; +function expectInstallBase( + parsed: ReturnType, +): NonNullable> { + if (parsed === undefined) { + throw new Error("Expected manifest install base"); + } + return parsed; +} + describe("shared/frontmatter", () => { test("normalizeStringList handles strings, arrays, and non-list values", () => { expect(normalizeStringList("a, b,,c")).toEqual(["a", "b", "c"]); @@ -135,7 +144,7 @@ describe("shared/frontmatter", () => { id?: string; label?: string; bins?: string[]; - }>({ extra: true }, parsed!), + }>({ extra: true }, expectInstallBase(parsed)), ).toEqual({ extra: true, id: "brew.git", diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index dd217ac613a..ac6e10ccba4 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -110,7 +110,11 @@ describe("realtime voice agent consult runtime", () => { }); expect(result).toEqual({ text: "Speak this." }); - expect(sessionStore["voice:15550001234"]?.sessionId).toEqual(expect.stringMatching(/\S/)); + const voiceSession = sessionStore["voice:15550001234"]; + if (!voiceSession) { + throw new Error("Expected voice consult session entry"); + } + expect(voiceSession.sessionId).toEqual(expect.stringMatching(/\S/)); expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "voice:15550001234", diff --git a/src/talk/agent-consult-tool.ts b/src/talk/agent-consult-tool.ts index b379397bea0..6e2861d6b2a 100644 --- a/src/talk/agent-consult-tool.ts +++ b/src/talk/agent-consult-tool.ts @@ -113,6 +113,29 @@ export function resolveRealtimeVoiceAgentConsultToolsAllow( return []; } +export function buildRealtimeVoiceAgentConsultPolicyInstructions(config: { + toolPolicy: RealtimeVoiceAgentConsultToolPolicy; + consultPolicy?: "auto" | "substantive" | "always"; +}): string | undefined { + if (config.toolPolicy === "none" || !config.consultPolicy || config.consultPolicy === "auto") { + return undefined; + } + if (config.consultPolicy === "always") { + return [ + "Consult behavior:", + "- Call openclaw_agent_consult before every substantive answer.", + "- You may answer directly only for greetings, acknowledgements, brief latency tests, or filler while waiting for the consult result.", + "- After the consult result arrives, speak that result concisely.", + ].join("\n"); + } + return [ + "Consult behavior:", + "- Answer directly for greetings, acknowledgements, simple conversational glue, and brief latency tests.", + "- Call openclaw_agent_consult before answering requests that need facts, memory, current information, tools, workspace state, or the user's OpenClaw-specific context.", + "- Keep spoken replies concise and natural.", + ].join("\n"); +} + export function parseRealtimeVoiceAgentConsultArgs(args: unknown): RealtimeVoiceAgentConsultArgs { const question = readConsultStringArg(args, "question") ?? diff --git a/src/talk/agent-talkback-runtime.test.ts b/src/talk/agent-talkback-runtime.test.ts index cea54ed7e66..d5ce9f764fc 100644 --- a/src/talk/agent-talkback-runtime.test.ts +++ b/src/talk/agent-talkback-runtime.test.ts @@ -86,6 +86,59 @@ describe("realtime voice agent talkback queue", () => { vi.useRealTimers(); }); + it("keeps active pending questions split by metadata", async () => { + vi.useFakeTimers(); + const logger = makeLogger(); + const ownerMetadata = { senderIsOwner: true }; + const guestMetadata = { senderIsOwner: false }; + let finishFirst: ((value: { text: string }) => void) | undefined; + const consult = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ text: string }>((resolve) => { + finishFirst = resolve; + }), + ) + .mockResolvedValueOnce({ text: "owner-answer" }) + .mockResolvedValueOnce({ text: "guest-answer" }); + const deliver = vi.fn(); + const queue = createRealtimeVoiceAgentTalkbackQueue({ + debounceMs: 10, + isStopped: () => false, + logger, + logPrefix: "[test]", + responseStyle: "brief", + fallbackText: "fallback", + consult, + deliver, + }); + + queue.enqueue("first"); + await vi.advanceTimersByTimeAsync(10); + queue.enqueue("owner", ownerMetadata); + queue.enqueue("guest", guestMetadata); + await vi.advanceTimersByTimeAsync(10); + finishFirst?.({ text: "first-answer" }); + await vi.runAllTimersAsync(); + + expect(consult).toHaveBeenNthCalledWith(2, { + question: "owner", + metadata: ownerMetadata, + responseStyle: "brief", + signal: expect.any(AbortSignal), + }); + expect(consult).toHaveBeenNthCalledWith(3, { + question: "guest", + metadata: guestMetadata, + responseStyle: "brief", + signal: expect.any(AbortSignal), + }); + expect(deliver).toHaveBeenCalledWith("owner-answer"); + expect(deliver).toHaveBeenCalledWith("guest-answer"); + vi.useRealTimers(); + }); + it("delivers fallback text when consult fails", async () => { vi.useFakeTimers(); const logger = makeLogger(); @@ -165,7 +218,10 @@ describe("realtime voice agent talkback queue", () => { queue.close(); await vi.runAllTimersAsync(); - expect(signal?.aborted).toBe(true); + if (!signal) { + throw new Error("Expected talkback consult abort signal"); + } + expect(signal.aborted).toBe(true); expect(deliver).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); vi.useRealTimers(); diff --git a/src/talk/agent-talkback-runtime.ts b/src/talk/agent-talkback-runtime.ts index 2d2f1399459..f1ba3c4160e 100644 --- a/src/talk/agent-talkback-runtime.ts +++ b/src/talk/agent-talkback-runtime.ts @@ -6,7 +6,7 @@ export type RealtimeVoiceAgentTalkbackResult = { export type RealtimeVoiceAgentTalkbackQueue = { close(): void; - enqueue(question: string): void; + enqueue(question: string, metadata?: unknown): void; }; export type RealtimeVoiceAgentTalkbackQueueParams = { @@ -18,17 +18,23 @@ export type RealtimeVoiceAgentTalkbackQueueParams = { fallbackText: string; consult: (args: { question: string; + metadata?: unknown; responseStyle: string; signal: AbortSignal; }) => Promise; deliver: (text: string) => void; }; +type PendingQuestion = { + question: string; + metadata?: unknown; +}; + export function createRealtimeVoiceAgentTalkbackQueue( params: RealtimeVoiceAgentTalkbackQueueParams, ): RealtimeVoiceAgentTalkbackQueue { let active = false; - let pendingQuestion: string | undefined; + let pendingQuestions: PendingQuestion[] = []; let debounceTimer: ReturnType | undefined; let activeAbortController: AbortController | undefined; @@ -40,29 +46,35 @@ export function createRealtimeVoiceAgentTalkbackQueue( debounceTimer = undefined; }; - const run = async (question: string): Promise => { - const trimmed = question.trim(); + const run = async (pending: PendingQuestion): Promise => { + const trimmed = pending.question.trim(); if (!trimmed || params.isStopped()) { return; } if (active) { - pendingQuestion = appendPendingQuestion(pendingQuestion, trimmed); + appendPendingQuestion(pendingQuestions, { + question: trimmed, + metadata: pending.metadata, + }); return; } active = true; - let nextQuestion: string | undefined = trimmed; + let nextQuestion: PendingQuestion | undefined = { + question: trimmed, + metadata: pending.metadata, + }; try { while (nextQuestion) { if (params.isStopped()) { return; } const currentQuestion = nextQuestion; - pendingQuestion = undefined; - params.logger.info(`${params.logPrefix} consult: chars=${currentQuestion.length}`); + params.logger.info(`${params.logPrefix} consult: chars=${currentQuestion.question.length}`); activeAbortController = new AbortController(); const result = await params.consult({ - question: currentQuestion, + question: currentQuestion.question, + metadata: currentQuestion.metadata, responseStyle: params.responseStyle, signal: activeAbortController.signal, }); @@ -71,7 +83,7 @@ export function createRealtimeVoiceAgentTalkbackQueue( if (!params.isStopped() && text) { params.deliver(text); } - nextQuestion = pendingQuestion; + nextQuestion = pendingQuestions.shift(); } } catch (error) { activeAbortController = undefined; @@ -83,8 +95,7 @@ export function createRealtimeVoiceAgentTalkbackQueue( params.deliver(params.fallbackText); } finally { active = false; - const queuedQuestion = pendingQuestion; - pendingQuestion = undefined; + const queuedQuestion = pendingQuestions.shift(); if (queuedQuestion && !params.isStopped()) { void run(queuedQuestion); } @@ -94,25 +105,24 @@ export function createRealtimeVoiceAgentTalkbackQueue( return { close: () => { clearDebounceTimer(); - pendingQuestion = undefined; + pendingQuestions = []; activeAbortController?.abort(); }, - enqueue: (question) => { + enqueue: (question, metadata) => { const trimmed = question.trim(); if (!trimmed || params.isStopped()) { return; } if (active) { - pendingQuestion = appendPendingQuestion(pendingQuestion, trimmed); + appendPendingQuestion(pendingQuestions, { question: trimmed, metadata }); clearDebounceTimer(); return; } - pendingQuestion = appendPendingQuestion(pendingQuestion, trimmed); + appendPendingQuestion(pendingQuestions, { question: trimmed, metadata }); clearDebounceTimer(); debounceTimer = setTimeout(() => { debounceTimer = undefined; - const queuedQuestion = pendingQuestion; - pendingQuestion = undefined; + const queuedQuestion = pendingQuestions.shift(); if (queuedQuestion && !params.isStopped()) { void run(queuedQuestion); } @@ -122,8 +132,13 @@ export function createRealtimeVoiceAgentTalkbackQueue( }; } -function appendPendingQuestion(current: string | undefined, next: string): string { - return current ? `${current}\n${next}` : next; +function appendPendingQuestion(queue: PendingQuestion[], next: PendingQuestion): void { + const current = queue.at(-1); + if (current && Object.is(current.metadata, next.metadata)) { + current.question = `${current.question}\n${next.question}`; + return; + } + queue.push(next); } function isAbortError(error: unknown): boolean { diff --git a/src/talk/diagnostics.test.ts b/src/talk/diagnostics.test.ts index 9a175e99f1f..16edbfccab8 100644 --- a/src/talk/diagnostics.test.ts +++ b/src/talk/diagnostics.test.ts @@ -57,7 +57,11 @@ describe("talk diagnostics", () => { await new Promise((resolve) => setImmediate(resolve)); expect(diagnostics).toHaveLength(1); - expect(diagnostics[0]).toMatchObject({ + const [diagnostic] = diagnostics; + if (!diagnostic) { + throw new Error("Expected talk diagnostic event"); + } + expect(diagnostic).toMatchObject({ trusted: true, event: { type: "talk.event", @@ -67,6 +71,6 @@ describe("talk diagnostics", () => { byteLength: 320, }, }); - expect(JSON.stringify(diagnostics[0]?.event)).not.toContain("private transcript"); + expect(JSON.stringify(diagnostic.event)).not.toContain("private transcript"); }); }); diff --git a/src/talk/provider-resolver.test.ts b/src/talk/provider-resolver.test.ts index 5ce495229d7..0c38ed1271c 100644 --- a/src/talk/provider-resolver.test.ts +++ b/src/talk/provider-resolver.test.ts @@ -77,6 +77,28 @@ describe("realtime voice provider resolver", () => { }); }); + it("applies caller overrides to the auto-selected realtime voice provider", () => { + const resolution = resolveConfiguredRealtimeVoiceProvider({ + cfg: {}, + defaultModel: "gpt-realtime", + providerConfigOverrides: { + model: "gpt-realtime-2", + voice: "cedar", + }, + providers, + providerConfigs: { + second: { enabled: true, model: "provider-default", voice: "marin" }, + }, + }); + + expect(resolution.providerConfig).toMatchObject({ + enabled: true, + model: "gpt-realtime-2", + voice: "cedar", + resolved: true, + }); + }); + it("throws a caller-specified message when no providers exist", () => { expect(() => resolveConfiguredRealtimeVoiceProvider({ diff --git a/src/talk/provider-resolver.ts b/src/talk/provider-resolver.ts index e749568b364..693c473d8d5 100644 --- a/src/talk/provider-resolver.ts +++ b/src/talk/provider-resolver.ts @@ -12,6 +12,7 @@ export type ResolvedRealtimeVoiceProvider = { export type ResolveConfiguredRealtimeVoiceProviderParams = { configuredProviderId?: string; providerConfigs?: Record | undefined>; + providerConfigOverrides?: Record; cfg?: OpenClawConfig; cfgForResolve?: OpenClawConfig; providers?: RealtimeVoiceProviderPlugin[]; @@ -38,7 +39,14 @@ export function resolveConfiguredRealtimeVoiceProvider( params.defaultModel && rawConfig.model === undefined ? { ...rawConfig, model: params.defaultModel } : rawConfig; - return provider.resolveConfig?.({ cfg, rawConfig: rawConfigWithModel }) ?? rawConfigWithModel; + const rawConfigWithOverrides = { + ...rawConfigWithModel, + ...params.providerConfigOverrides, + }; + return ( + provider.resolveConfig?.({ cfg, rawConfig: rawConfigWithOverrides }) ?? + rawConfigWithOverrides + ); }, isProviderConfigured: ({ provider, cfg, providerConfig }) => provider.isConfigured({ cfg, providerConfig }), diff --git a/src/talk/session-runtime.test.ts b/src/talk/session-runtime.test.ts index db5752e75c5..4123c74e366 100644 --- a/src/talk/session-runtime.test.ts +++ b/src/talk/session-runtime.test.ts @@ -20,6 +20,15 @@ function makeBridge(overrides: Partial = {}): RealtimeVoice }; } +function expectBridgeRequest( + request: Parameters[0] | undefined, +): Parameters[0] { + if (!request) { + throw new Error("Expected realtime voice provider bridge request"); + } + return request; +} + describe("realtime voice bridge session runtime", () => { it("routes provider output through an open audio sink", () => { let callbacks: Parameters[0] | undefined; @@ -76,7 +85,9 @@ describe("realtime voice bridge session runtime", () => { audioSink: { sendAudio: vi.fn() }, }); - expect(request?.audioFormat).toEqual(REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ); + expect(expectBridgeRequest(request).audioFormat).toEqual( + REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, + ); }); it("passes the audio auto-response preference to the provider bridge", () => { @@ -98,7 +109,7 @@ describe("realtime voice bridge session runtime", () => { audioSink: { sendAudio: vi.fn() }, }); - expect(request?.autoRespondToAudio).toBe(false); + expect(expectBridgeRequest(request).autoRespondToAudio).toBe(false); }); it("can acknowledge provider marks without transport mark support", () => { diff --git a/src/tasks/task-executor.test.ts b/src/tasks/task-executor.test.ts index e3bb5bba46c..91b1e419c43 100644 --- a/src/tasks/task-executor.test.ts +++ b/src/tasks/task-executor.test.ts @@ -101,6 +101,25 @@ async function withTaskExecutorStateDir(run: (stateDir: string) => Promise }); } +function expectParentFlowId(task: { parentFlowId?: string }): string { + expect(task.parentFlowId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + if (task.parentFlowId === undefined) { + throw new Error("Expected task parent flow id"); + } + return task.parentFlowId; +} + +function requireCreatedFlowTask( + result: ReturnType, +): NonNullable["task"]> { + if (!result.task) { + throw new Error("Expected TaskFlow child task to be created"); + } + return result.task; +} + function createRunningAcpChildTaskRun( overrides: Partial[0]> = {}, ) { @@ -289,9 +308,9 @@ describe("task-executor", () => { deliveryStatus: "pending", }); - expect(created.parentFlowId).toEqual(expect.any(String)); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + const parentFlowId = expectParentFlowId(created); + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, ownerKey: "agent:main:main", status: "running", goal: "Write summary", @@ -305,8 +324,8 @@ describe("task-executor", () => { terminalSummary: "Done.", }); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, status: "succeeded", endedAt: 40, goal: "Write summary", @@ -364,8 +383,9 @@ describe("task-executor", () => { terminalOutcome: "blocked", terminalSummary: "Writable session required.", }); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + const parentFlowId = expectParentFlowId(created); + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, status: "blocked", blockedTaskId: created.taskId, blockedSummary: "Writable session required.", @@ -373,7 +393,7 @@ describe("task-executor", () => { }); const retried = retryBlockedFlowAsQueuedTaskRun({ - flowId: created.parentFlowId!, + flowId: parentFlowId, runId: "run-executor-retry", childSessionKey: "agent:codex:acp:retry-child", }); @@ -385,17 +405,17 @@ describe("task-executor", () => { taskId: created.taskId, }), task: expect.objectContaining({ - parentFlowId: created.parentFlowId, + parentFlowId, parentTaskId: created.taskId, status: "queued", runId: "run-executor-retry", }), }); - expect(getTaskFlowById(created.parentFlowId!)).toMatchObject({ - flowId: created.parentFlowId, + expect(getTaskFlowById(parentFlowId)).toMatchObject({ + flowId: parentFlowId, status: "queued", }); - expect(findLatestTaskForFlowId(created.parentFlowId!)).toMatchObject({ + expect(findLatestTaskForFlowId(parentFlowId)).toMatchObject({ runId: "run-executor-retry", }); expect(findTaskByRunId("run-executor-blocked")).toMatchObject({ @@ -486,7 +506,8 @@ describe("task-executor", () => { runId: "run-flow-child", }), }); - expect(getTaskById(created.task!.taskId)).toMatchObject({ + const createdTask = requireCreatedFlowTask(created); + expect(getTaskById(createdTask.taskId)).toMatchObject({ parentFlowId: flow.flowId, ownerKey: "agent:main:main", childSessionKey: "agent:codex:acp:child", @@ -537,7 +558,7 @@ describe("task-executor", () => { controllerId: "tests/managed-flow", goal: "Long running batch", }); - const child = runTaskInFlow({ + const created = runTaskInFlow({ flowId: flow.flowId, runtime: "acp", childSessionKey: "agent:codex:acp:child", @@ -546,7 +567,8 @@ describe("task-executor", () => { status: "running", startedAt: 10, lastEventAt: 10, - }).task!; + }); + const child = requireCreatedFlowTask(created); const cancelled = await cancelFlowById({ cfg: {} as never, diff --git a/src/tasks/task-flow-registry.store.test.ts b/src/tasks/task-flow-registry.store.test.ts index 83d0bde4f86..a18f9e38be8 100644 --- a/src/tasks/task-flow-registry.store.test.ts +++ b/src/tasks/task-flow-registry.store.test.ts @@ -110,11 +110,19 @@ describe("task-flow-registry store runtime", () => { }); expect(saveSnapshot).toHaveBeenCalled(); - const latestSnapshot = saveSnapshot.mock.calls.at(-1)?.[0] as { + const latestCall = saveSnapshot.mock.calls.at(-1); + if (!latestCall) { + throw new Error("Expected task flow snapshot save call"); + } + const latestSnapshot = latestCall[0] as { flows: ReadonlyMap; }; expect(latestSnapshot.flows.size).toBe(2); - expect(latestSnapshot.flows.get("flow-restored")?.goal).toBe("Restored flow"); + const restoredFlow = latestSnapshot.flows.get("flow-restored"); + if (!restoredFlow) { + throw new Error("Expected restored task flow"); + } + expect(restoredFlow.goal).toBe("Restored flow"); }); it("restores persisted wait-state, revision, and cancel intent from sqlite", async () => { diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index 257842bff93..228e417ae12 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -21,6 +21,14 @@ import type { TaskRecord } from "./task-registry.types.js"; const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; +function requireFirstUpsertParams(upsertTaskWithDeliveryState: ReturnType): unknown { + const params = upsertTaskWithDeliveryState.mock.calls[0]?.[0]; + if (!params) { + throw new Error("expected task upsert params"); + } + return params; +} + function createStoredTask(): TaskRecord { return { taskId: "task-restored", @@ -174,7 +182,7 @@ describe("task-registry store runtime", () => { expect(deleteTaskRecordById(created.taskId)).toBe(true); expect(upsertTaskWithDeliveryState).toHaveBeenCalled(); - expect(upsertTaskWithDeliveryState.mock.calls[0]?.[0]).toMatchObject({ + expect(requireFirstUpsertParams(upsertTaskWithDeliveryState)).toMatchObject({ task: expect.objectContaining({ taskId: created.taskId, }), @@ -465,14 +473,18 @@ describe("task-registry store runtime", () => { resetTaskRegistryForTests({ persist: false }); - expect(() => + expect( markTaskLostById({ taskId: "legacy-session-task", endedAt: 200, lastEventAt: 200, error: "session missing", }), - ).not.toThrow(); + ).toMatchObject({ + taskId: "legacy-session-task", + status: "lost", + error: "session missing", + }); expect(findTaskByRunId("legacy-session-run")).toMatchObject({ taskId: "legacy-session-task", status: "lost", diff --git a/src/tasks/task-registry.test.ts b/src/tasks/task-registry.test.ts index cfd950defc8..4c6451c2883 100644 --- a/src/tasks/task-registry.test.ts +++ b/src/tasks/task-registry.test.ts @@ -72,6 +72,16 @@ const hoisted = vi.hoisted(() => { }; }); +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + vi.mock("../acp/control-plane/manager.js", () => ({ getAcpSessionManager: () => ({ cancelSession: hoisted.cancelSessionMock, @@ -1120,7 +1130,7 @@ describe("task-registry", () => { deliveryStatus: "pending", }); - expect(listTaskRecords().filter((task) => task.runId === "run-shared")).toHaveLength(2); + expect(countMatching(listTaskRecords(), (task) => task.runId === "run-shared")).toBe(2); expect(findTaskByRunId("run-shared")).toMatchObject({ runtime: "acp", task: "Spawn ACP child", @@ -1223,7 +1233,7 @@ describe("task-registry", () => { await maybeDeliverTaskTerminalUpdate(spawnedTask.taskId); expect(hoisted.sendMessageMock).toHaveBeenCalledTimes(1); - expect(listTaskRecords().filter((task) => task.runId === "run-shared-delivery")).toHaveLength( + expect(countMatching(listTaskRecords(), (task) => task.runId === "run-shared-delivery")).toBe( 1, ); expect(findTaskByRunId("run-shared-delivery")).toMatchObject({ @@ -1367,7 +1377,7 @@ describe("task-registry", () => { }); expect(directTask.taskId).toBe(spawnedTask.taskId); - expect(listTaskRecords().filter((task) => task.runId === "run-collapse")).toHaveLength(1); + expect(countMatching(listTaskRecords(), (task) => task.runId === "run-collapse")).toBe(1); expect(findTaskByRunId("run-collapse")).toMatchObject({ task: "Spawn ACP child", }); diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index dae19b0faaf..8926da88958 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -255,7 +255,8 @@ describe("wrapNoteMessage", () => { const lines = wrapped.split("\n"); expect(lines.length).toBeGreaterThan(1); expect(lines[0]?.startsWith("- ")).toBe(true); - expect(lines.slice(1).every((line) => line.startsWith(" "))).toBe(true); + const unindentedContinuationLines = lines.slice(1).filter((line) => !line.startsWith(" ")); + expect(unindentedContinuationLines).toEqual([]); }); it("preserves long Windows paths without inserting spaces/newlines", () => { diff --git a/src/test-utils/session-state-cleanup.test.ts b/src/test-utils/session-state-cleanup.test.ts index 79cfd4de925..dbe1d2671d2 100644 --- a/src/test-utils/session-state-cleanup.test.ts +++ b/src/test-utils/session-state-cleanup.test.ts @@ -20,12 +20,15 @@ const drainSessionStoreWriterQueuesMock = vi.hoisted(() => vi.fn(async () => und const drainSessionWriteLockStateMock = vi.hoisted(() => vi.fn(async () => undefined)); function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T | PromiseLike) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/tools/planner.test.ts b/src/tools/planner.test.ts index 6d3c26c1d46..bc2db436f9e 100644 --- a/src/tools/planner.test.ts +++ b/src/tools/planner.test.ts @@ -16,6 +16,16 @@ function descriptor(name: string, overrides: Partial = {}): Tool }; } +type ToolPlan = ReturnType; + +function expectHiddenTool(plan: ToolPlan, index: number): ToolPlan["hidden"][number] { + const entry = plan.hidden[index]; + if (!entry) { + throw new Error(`Expected hidden tool at index ${index}`); + } + return entry; +} + describe("buildToolPlan", () => { it("sorts visible and hidden tools deterministically", () => { const plan = buildToolPlan({ @@ -32,7 +42,9 @@ describe("buildToolPlan", () => { expect(plan.visible.map((entry) => entry.descriptor.name)).toEqual(["alpha", "zeta"]); expect(plan.hidden.map((entry) => entry.descriptor.name)).toEqual(["hidden"]); - expect(plan.hidden[0]?.diagnostics.map((entry) => entry.reason)).toEqual(["env-missing"]); + expect(expectHiddenTool(plan, 0).diagnostics.map((entry) => entry.reason)).toEqual([ + "env-missing", + ]); }); it("fails deterministically on duplicate tool names", () => { @@ -81,8 +93,9 @@ describe("buildToolPlan", () => { }); expect(plan.visible).toEqual([]); - expect(plan.hidden[0]?.descriptor.name).toBe("plugin_tool"); - expect(plan.hidden[0]?.diagnostics[0]?.reason).toBe("plugin-disabled"); + const hiddenTool = expectHiddenTool(plan, 0); + expect(hiddenTool.descriptor.name).toBe("plugin_tool"); + expect(hiddenTool.diagnostics.map((entry) => entry.reason)).toEqual(["plugin-disabled"]); }); it("hides descriptors with malformed empty allOf availability", () => { @@ -91,8 +104,9 @@ describe("buildToolPlan", () => { }); expect(plan.visible).toEqual([]); - expect(plan.hidden[0]?.descriptor.name).toBe("malformed"); - expect(plan.hidden[0]?.diagnostics).toEqual([ + const hiddenTool = expectHiddenTool(plan, 0); + expect(hiddenTool.descriptor.name).toBe("malformed"); + expect(hiddenTool.diagnostics).toEqual([ { reason: "unsupported-signal", message: "Empty availability allOf group", diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index a6358f048c7..8132ffef77c 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -63,6 +63,10 @@ function toolResultMessage(content: Extract["co }; } +function eventTypes(events: readonly Pick[]): string[] { + return events.map((event) => event.type); +} + function writeSimpleSessionFile( sessionFile: string, params: { userEntryTimestamp?: string | number } = {}, @@ -332,7 +336,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.manifest.runtimeEventCount).toBe(1); - expect(bundle.events.some((event) => event.type === "session.started")).toBe(true); + expect(eventTypes(bundle.events)).toContain("session.started"); }); it("uses the recorded runtime pointer before current environment overrides", async () => { @@ -395,8 +399,8 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.runtimeFile).toBe(recordedRuntimeFile); - expect(bundle.events.some((event) => event.type === "recorded-runtime")).toBe(true); - expect(bundle.events.some((event) => event.type === "env-runtime")).toBe(false); + expect(eventTypes(bundle.events)).toContain("recorded-runtime"); + expect(eventTypes(bundle.events)).not.toContain("env-runtime"); } finally { if (previous === undefined) { delete process.env.OPENCLAW_TRAJECTORY_DIR; @@ -446,7 +450,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.runtimeFile).toBeUndefined(); - expect(bundle.events.some((event) => event.type === "outside-runtime")).toBe(false); + expect(eventTypes(bundle.events)).not.toContain("outside-runtime"); }); it("does not fall back to runtime pointer targets that are not regular files", async () => { @@ -492,7 +496,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.runtimeFile).toBeUndefined(); - expect(bundle.events.some((event) => event.type === "symlink-runtime")).toBe(false); + expect(eventTypes(bundle.events)).not.toContain("symlink-runtime"); }); it("counts expanded transcript events when enforcing the total event limit", async () => { @@ -542,7 +546,7 @@ describe("exportTrajectoryBundle", () => { }); expect(bundle.manifest.runtimeEventCount).toBe(0); - expect(bundle.events.some((event) => event.type === "other-runtime")).toBe(false); + expect(eventTypes(bundle.events)).not.toContain("other-runtime"); }); it("redacts non-workspace paths in strings that also contain workspace paths", async () => { @@ -744,9 +748,9 @@ describe("exportTrajectoryBundle", () => { .trim() .split(/\r?\n/u) .map((line) => JSON.parse(line) as TrajectoryEvent); - expect(exportedEvents.some((event) => event.type === "tool.call")).toBe(true); - expect(exportedEvents.some((event) => event.type === "tool.result")).toBe(true); - expect(exportedEvents.some((event) => event.type === "context.compiled")).toBe(true); + expect(eventTypes(exportedEvents)).toEqual( + expect.arrayContaining(["tool.call", "tool.result", "context.compiled"]), + ); expect(JSON.stringify(exportedEvents)).toContain("$WORKSPACE_DIR/inside.txt"); expect(JSON.stringify(exportedEvents)).not.toContain("$WORKSPACE_DIR2"); @@ -767,7 +771,8 @@ describe("exportTrajectoryBundle", () => { "system-prompt.txt", "tools.json", ]); - expect(manifest.contents?.every((entry) => entry.bytes > 0)).toBe(true); + const emptyContents = (manifest.contents ?? []).filter((entry) => entry.bytes <= 0); + expect(emptyContents).toEqual([]); const metadata = JSON.parse(fs.readFileSync(path.join(outputDir, "metadata.json"), "utf8")) as { skills?: { entries?: Array<{ id?: string; invoked?: boolean }> }; diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index 63723c76d22..faebfd3b875 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -11,6 +11,8 @@ import { toTrajectoryToolDefinitions, } from "./runtime.js"; +type TrajectoryRuntimeRecorder = NonNullable>; + const tempDirs: string[] = []; function makeTempDir(): string { @@ -25,6 +27,16 @@ afterEach(() => { } }); +function expectTrajectoryRuntimeRecorder( + recorder: ReturnType, +): TrajectoryRuntimeRecorder { + expect(recorder).toEqual(expect.objectContaining({ recordEvent: expect.any(Function) })); + if (recorder === null) { + throw new Error("Expected trajectory runtime recorder"); + } + return recorder; +} + describe("trajectory runtime", () => { it("resolves a session-adjacent trajectory file by default", () => { expect( @@ -63,8 +75,8 @@ describe("trajectory runtime", () => { }, }); - expect(recorder).not.toBeNull(); - recorder?.recordEvent("context.compiled", { + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { systemPrompt: "system prompt", headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], command: "curl -H 'Authorization: Bearer sk-other-secret-token'", @@ -102,7 +114,8 @@ describe("trajectory runtime", () => { }, }); - recorder?.recordEvent("context.compiled", { + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { prompt: "x".repeat(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1), }); @@ -132,18 +145,19 @@ describe("trajectory runtime", () => { }, }); - recorder?.recordEvent("context.compiled", { + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { prompt: "x".repeat(180), }); - recorder?.recordEvent("prompt.submitted", { + runtimeRecorder.recordEvent("prompt.submitted", { prompt: "y".repeat(180), }); - recorder?.recordEvent("model.completed", { + runtimeRecorder.recordEvent("model.completed", { get prompt() { throw new Error("stopped recorder should not read dropped payloads"); }, }); - await recorder?.flush(); + await runtimeRecorder.flush(); const parsed = writes.map((line) => JSON.parse(line)); expect(parsed.map((event) => event.type)).toContain("trace.truncated"); @@ -170,7 +184,7 @@ describe("trajectory runtime", () => { }, }); - expect(recorder).not.toBeNull(); + expectTrajectoryRuntimeRecorder(recorder); const pointer = JSON.parse( fs.readFileSync(resolveTrajectoryPointerFilePath(sessionFile), "utf8"), ) as { runtimeFile?: string }; diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index ae4963cacdf..f7708c98808 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -113,12 +113,15 @@ vi.mock("../gateway/server-methods/agent-timestamp.js", () => ({ })); function deferred() { - let resolve!: (value: T) => void; - let reject!: (error?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((error?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 620c3779041..9289f465d76 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -1226,7 +1226,7 @@ describe("tui-event-handlers: streaming watchdog", () => { vi.advanceTimersByTime(10_000); const statusCalls = setActivityStatus.mock.calls.map((c) => c[0]); - expect(statusCalls.filter((s) => s === "idle").length).toBe(1); + expect(statusCalls.reduce((count, s) => count + (s === "idle" ? 1 : 0), 0)).toBe(1); expect(chatLog.addSystem).not.toHaveBeenCalledWith(expectedTimeoutMessage); expect(state.activeChatRunId).toBeNull(); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 5e502083460..f7542d8840c 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -328,11 +328,11 @@ describe("TUI shutdown safety", () => { }); it("swallows only ignorable stop errors", () => { - expect(() => { + expect( stopTuiSafely(() => { throw new Error("setRawMode EBADF"); - }); - }).not.toThrow(); + }), + ).toBeUndefined(); }); it("rethrows non-ignorable stop errors", () => { @@ -394,10 +394,12 @@ describe("resolveCodexCliBin", () => { }); it("returns null or a valid path (never throws)", () => { - // The function should never throw regardless of environment - expect(() => resolveCodexCliBin()).not.toThrow(); const result = resolveCodexCliBin(); - expect(result === null || typeof result === "string").toBe(true); + if (result === null) { + expect(result).toBeNull(); + } else { + expect(typeof result).toBe("string"); + } }); }); diff --git a/src/utils/fetch-timeout.test.ts b/src/utils/fetch-timeout.test.ts index 9342a9f102d..b2cd7fa7e40 100644 --- a/src/utils/fetch-timeout.test.ts +++ b/src/utils/fetch-timeout.test.ts @@ -150,6 +150,23 @@ describe("buildTimeoutAbortSignal", () => { cleanup(); }); + it("emits a warning without operation or url when callers omit context (#79195)", async () => { + const { signal, cleanup } = buildTimeoutAbortSignal({ + timeoutMs: 25, + }); + + await vi.advanceTimersByTimeAsync(25); + + expect(signal?.aborted).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + const [, record] = warn.mock.calls[0] as [string, Record]; + expect(record).not.toHaveProperty("operation"); + expect(record).not.toHaveProperty("url"); + expect(record.consoleMessage).toBe("fetch timeout after 25ms (elapsed 25ms)"); + + cleanup(); + }); + it("refreshes its timeout when progress is observed", async () => { const { signal, refresh, cleanup } = buildTimeoutAbortSignal({ timeoutMs: 25, diff --git a/src/video-generation/provider-registry.test.ts b/src/video-generation/provider-registry.test.ts index 2773b49e5ca..2aa877a2fc7 100644 --- a/src/video-generation/provider-registry.test.ts +++ b/src/video-generation/provider-registry.test.ts @@ -30,6 +30,14 @@ async function loadProviderRegistry() { return await import("./provider-registry.js"); } +function requireVideoProvider(id: string): VideoGenerationProviderPlugin { + const provider = getVideoGenerationProvider(id); + if (!provider) { + throw new Error(`expected video generation provider ${id}`); + } + return provider; +} + describe("video-generation provider registry", () => { beforeEach(async () => { resolvePluginCapabilityProvidersMock.mockReset(); @@ -50,7 +58,7 @@ describe("video-generation provider registry", () => { const provider = getVideoGenerationProvider("custom-video"); - expect(provider?.id).toBe("custom-video"); + expect(provider).toMatchObject({ id: "custom-video" }); expect(resolvePluginCapabilityProvidersMock).toHaveBeenCalledWith({ key: "videoGenerationProviders", cfg: undefined, @@ -66,6 +74,6 @@ describe("video-generation provider registry", () => { expect(listVideoGenerationProviders().map((provider) => provider.id)).toEqual(["safe-video"]); expect(getVideoGenerationProvider("__proto__")).toBeUndefined(); expect(getVideoGenerationProvider("constructor")).toBeUndefined(); - expect(getVideoGenerationProvider("safe-alias")?.id).toBe("safe-video"); + expect(requireVideoProvider("safe-alias")).toMatchObject({ id: "safe-video" }); }); }); diff --git a/src/video-generation/runtime.test.ts b/src/video-generation/runtime.test.ts index c1381782ab1..72eb2b3e260 100644 --- a/src/video-generation/runtime.test.ts +++ b/src/video-generation/runtime.test.ts @@ -29,6 +29,17 @@ function runGenerateVideo(params: GenerateVideoParams) { return generateVideo(params, runtimeDeps); } +function requireAttempt( + result: Awaited>, + index: number, +): NonNullable<(typeof result.attempts)[number]> { + const attempt = result.attempts[index]; + if (!attempt) { + throw new Error(`expected video generation attempt ${index}`); + } + return attempt; +} + function createProviderOptionsCaptureProvider( capabilities: VideoGenerationProvider["capabilities"], ): { provider: VideoGenerationProvider; getSeenProviderOptions: () => unknown } { @@ -330,8 +341,9 @@ describe("video-generation runtime", () => { expect(result.provider).toBe("byteplus"); expect(result.attempts).toHaveLength(1); - expect(result.attempts[0]?.provider).toBe("openai"); - expect(result.attempts[0]?.error).toMatch(/does not accept providerOptions/); + const attempt = requireAttempt(result, 0); + expect(attempt.provider).toBe("openai"); + expect(attempt.error).toMatch(/does not accept providerOptions/); }); it("skips providers that cannot satisfy reference audio inputs and falls back", async () => { @@ -376,8 +388,9 @@ describe("video-generation runtime", () => { expect(result.provider).toBe("byteplus"); expect(result.attempts).toHaveLength(1); - expect(result.attempts[0]?.provider).toBe("openai"); - expect(result.attempts[0]?.error).toMatch(/does not support reference audio inputs/); + const attempt = requireAttempt(result, 0); + expect(attempt.provider).toBe("openai"); + expect(attempt.error).toMatch(/does not support reference audio inputs/); }); it("forwards mixed image, video, and audio references when explicitly supported", async () => { @@ -498,8 +511,9 @@ describe("video-generation runtime", () => { expect(result.provider).toBe("runway"); expect(seenDurationSeconds).toBe(6); expect(result.attempts).toHaveLength(1); - expect(result.attempts[0]?.provider).toBe("openai"); - expect(result.attempts[0]?.error).toMatch(/supports at most 4s per video, 6s requested/); + const attempt = requireAttempt(result, 0); + expect(attempt.provider).toBe("openai"); + expect(attempt.error).toMatch(/supports at most 4s per video, 6s requested/); }); it("fails when every candidate is skipped for exceeding hard duration caps", async () => { diff --git a/src/web-fetch/runtime.test.ts b/src/web-fetch/runtime.test.ts index e413929986f..ba9ed22364e 100644 --- a/src/web-fetch/runtime.test.ts +++ b/src/web-fetch/runtime.test.ts @@ -71,6 +71,19 @@ function createFirecrawlPluginConfig(apiKey: unknown): OpenClawConfig { }; } +type ResolvedWebFetchDefinition = NonNullable< + ReturnType["resolveWebFetchDefinition"]> +>; + +function requireResolvedWebFetch( + resolved: ReturnType["resolveWebFetchDefinition"]>, +): ResolvedWebFetchDefinition { + if (!resolved) { + throw new Error("expected resolved web fetch definition"); + } + return resolved; +} + describe("web fetch runtime", () => { let resolveWebFetchDefinition: typeof import("./runtime.js").resolveWebFetchDefinition; let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot; @@ -136,9 +149,10 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("firecrawl"); + const webFetch = requireResolvedWebFetch(resolved); + expect(webFetch.provider.id).toBe("firecrawl"); await expect( - resolved?.definition.execute({ + webFetch.definition.execute({ url: "https://example.com", extractMode: "markdown", maxChars: 1000, @@ -160,7 +174,7 @@ describe("web fetch runtime", () => { config: {}, }); - expect(resolved?.provider.id).toBe("firecrawl"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl"); }); it("falls back to auto-detect when the configured provider is invalid", () => { @@ -181,7 +195,7 @@ describe("web fetch runtime", () => { } as OpenClawConfig, }); - expect(resolved?.provider.id).toBe("firecrawl"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl"); }); it("keeps sandboxed web fetch on bundled providers even when runtime providers are preferred", () => { @@ -198,7 +212,7 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("firecrawl"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl"); }); it("uses runtime providers for non-sandboxed web fetch when runtime providers are preferred", () => { @@ -215,7 +229,7 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("thirdparty"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("thirdparty"); }); it("resolves an explicitly configured non-bundled provider from plugin providers", () => { @@ -233,7 +247,7 @@ describe("web fetch runtime", () => { preferRuntimeProviders: false, }); - expect(resolved?.provider.id).toBe("thirdparty"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("thirdparty"); }); it("prefers an explicitly configured non-bundled provider over runtime metadata", () => { @@ -257,6 +271,6 @@ describe("web fetch runtime", () => { preferRuntimeProviders: true, }); - expect(resolved?.provider.id).toBe("thirdparty"); + expect(requireResolvedWebFetch(resolved).provider.id).toBe("thirdparty"); }); }); diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 127bea1d6a8..9c03c775b46 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -324,7 +324,7 @@ describe("runSetupWizard", () => { return dir; } - it("does not crash when preferred-provider lookup sees a provider without an id", async () => { + it("skips provider entries without an id during preferred-provider lookup", async () => { setupChannels.mockClear(); readConfigFileSnapshot.mockResolvedValueOnce({ path: "/tmp/.openclaw/openclaw.json", @@ -619,7 +619,8 @@ describe("runSetupWizard", () => { const calls = getWizardNoteCalls(note); expect(calls.length).toBeGreaterThan(0); - expect(calls.some((call) => call?.[1] === "Web search")).toBe(true); + const noteTitles = calls.map((call) => call?.[1]); + expect(noteTitles).toContain("Web search"); } finally { if (prevBraveKey === undefined) { delete process.env.BRAVE_API_KEY; @@ -843,13 +844,13 @@ describe("runSetupWizard", () => { ); const calls = getWizardNoteCalls(note); - expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true); - expect( - calls.some((call) => { - const body = call?.[0]; - return typeof body === "string" && body.includes("legacy-plugin"); - }), - ).toBe(true); + const noteTitles = calls.map((call) => call?.[1]); + expect(noteTitles).toContain("Plugin compatibility"); + const noteBodies = calls + .map((call) => call?.[0]) + .filter((body): body is string => typeof body === "string"); + const legacyPluginNotes = noteBodies.filter((body) => body.includes("legacy-plugin")); + expect(legacyPluginNotes.length).toBeGreaterThan(0); }); it("resolves gateway.auth.password SecretRef for local setup probe", async () => { @@ -983,14 +984,13 @@ describe("runSetupWizard", () => { } const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect( - calls.some( - (call) => - call?.[1] === "QuickStart" && - typeof call?.[0] === "string" && - call[0].includes("Gateway port: 18791"), - ), - ).toBe(true); + const matchingQuickStartNotes = calls.filter( + (call) => + call?.[1] === "QuickStart" && + typeof call?.[0] === "string" && + call[0].includes("Gateway port: 18791"), + ); + expect(matchingQuickStartNotes.length).toBeGreaterThan(0); }); it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => { diff --git a/test/appcast.test.ts b/test/appcast.test.ts index 7abad4740ce..cce46caf054 100644 --- a/test/appcast.test.ts +++ b/test/appcast.test.ts @@ -32,9 +32,10 @@ describe("appcast.xml", () => { expect(items.length).toBeGreaterThan(0); for (const item of items) { - expect(item.shortVersion, item.raw).not.toBeNull(); - expect(item.sparkleVersion, item.raw).not.toBeNull(); - expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion!)); + if (item.shortVersion === null || item.sparkleVersion === null) { + throw new Error(`Appcast entry missing version fields: ${item.raw}`); + } + expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion)); } }); diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index d12f239feac..912113d289c 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -23,10 +23,10 @@ describe("cli json stdout contract", () => { delete env.OPENCLAW_CONFIG_PATH; delete env.VITEST; - const entry = path.resolve(process.cwd(), "openclaw.mjs"); + const entry = path.resolve(process.cwd(), "src/entry.ts"); const result = spawnSync( process.execPath, - [entry, "update", "status", "--json", "--timeout", "1"], + ["--import", "tsx", entry, "update", "status", "--json", "--timeout", "1"], { cwd: process.cwd(), env, encoding: "utf8" }, ); @@ -34,7 +34,10 @@ describe("cli json stdout contract", () => { const stdout = result.stdout.trim(); expect(stdout.length).toBeGreaterThan(0); const parsed = JSON.parse(stdout) as unknown; - expect(parsed).toEqual(expect.any(Object)); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected JSON object stdout, got: ${stdout}`); + } + expect(Object.keys(parsed).sort()).toEqual(["availability", "channel", "update"]); expect(stdout).not.toContain("Doctor warnings"); expect(stdout).not.toContain("Doctor changes"); expect(stdout).not.toContain("Config invalid"); diff --git a/test/helpers/agents/prompt-composition-scenarios.ts b/test/helpers/agents/prompt-composition-scenarios.ts index f7d5d403f25..4043dd51d41 100644 --- a/test/helpers/agents/prompt-composition-scenarios.ts +++ b/test/helpers/agents/prompt-composition-scenarios.ts @@ -594,7 +594,8 @@ async function createMaintenanceScenario(workspaceDir: string): Promise["entries"][number]; +type OfficialChannelInstall = NonNullable< + NonNullable["install"] +>; + function makeRepoRoot(prefix: string): string { return makeTempRepoRoot(tempDirs, prefix); } @@ -20,6 +27,21 @@ function writeJson(filePath: string, value: unknown): void { writeJsonFile(filePath, value); } +function requireInstall(entry: OfficialChannelCatalogEntry | undefined): OfficialChannelInstall { + const install = entry?.openclaw?.install; + if (!install) { + throw new Error("expected official channel install config"); + } + return install; +} + +function requireNpmInstallSource(source: ReturnType) { + if (!source.npm) { + throw new Error("expected npm install source"); + } + return source.npm; +} + afterEach(() => { cleanupTempDirs(tempDirs); }); @@ -139,9 +161,9 @@ describe("buildOfficialChannelCatalog", () => { expect(entries.length).toBeGreaterThan(0); for (const entry of entries) { - const installSource = describePluginInstallSource(entry.openclaw?.install ?? {}); + const installSource = describePluginInstallSource(requireInstall(entry)); expect(installSource.warnings).toEqual([]); - expect(installSource.npm?.pinState).toBe("exact-with-integrity"); + expect(requireNpmInstallSource(installSource).pinState).toBe("exact-with-integrity"); } }); @@ -163,8 +185,8 @@ describe("buildOfficialChannelCatalog", () => { }), }), ); - const installSource = describePluginInstallSource(twitch?.openclaw?.install ?? {}); - expect(installSource.npm?.pinState).toBe("floating-without-integrity"); + const installSource = describePluginInstallSource(requireInstall(twitch)); + expect(requireNpmInstallSource(installSource).pinState).toBe("floating-without-integrity"); expect(installSource.warnings).toEqual(["npm-spec-floating", "npm-spec-missing-integrity"]); }); @@ -195,7 +217,7 @@ describe("buildOfficialChannelCatalog", () => { (candidate) => candidate.openclaw?.channel?.id === "storepack-chat", ); - expect(entry?.openclaw?.install).toEqual({ + expect(requireInstall(entry)).toEqual({ clawhubSpec: "clawhub:@openclaw/storepack-chat", npmSpec: "@openclaw/storepack-chat", defaultChoice: "clawhub", diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index c447b597f15..5d490da0801 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -17,29 +17,38 @@ const baselinePath = path.join( ); const baseline = JSON.parse(readFileSync(baselinePath, "utf8")); +function collectInventoryFiles( + inventory: Awaited>, + predicate: (file: string) => boolean, +): string[] { + const files: string[] = []; + for (const entry of inventory) { + if (predicate(entry.file)) { + files.push(entry.file); + } + } + return files; +} + describe("plugin extension import boundary inventory", () => { it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); - const blockedShimFiles = inventory - .filter( - (entry) => - entry.file === "src/plugins/web-search-providers.ts" || - entry.file === "src/plugins/bundled-web-search-registry.ts", - ) - .map((entry) => entry.file); + const blockedShimFiles = collectInventoryFiles( + inventory, + (file) => + file === "src/plugins/web-search-providers.ts" || + file === "src/plugins/bundled-web-search-registry.ts", + ); expect(blockedShimFiles).toEqual([]); }); it("ignores boundary shims by scope", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); - const boundaryShimFiles = inventory - .filter( - (entry) => - entry.file.startsWith("src/plugin-sdk/") || - entry.file.startsWith("src/plugin-sdk-internal/"), - ) - .map((entry) => entry.file); + const boundaryShimFiles = collectInventoryFiles( + inventory, + (file) => file.startsWith("src/plugin-sdk/") || file.startsWith("src/plugin-sdk-internal/"), + ); expect(boundaryShimFiles).toEqual([]); }); diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 768fd2d3115..637a18abe40 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -7,8 +7,19 @@ import { const repoRoot = path.resolve(import.meta.dirname, ".."); +type PluginNpmRuntimeBuildPlan = NonNullable>; + function expectDistRelativePaths(paths: string[]) { - expect(paths.filter((entry) => !entry.startsWith("./dist/"))).toEqual([]); + expect(paths.every((entry) => entry.startsWith("./dist/"))).toBe(true); +} + +function expectPluginNpmRuntimeBuildPlan( + plan: ReturnType, +): PluginNpmRuntimeBuildPlan { + if (!plan) { + throw new Error("expected plugin npm runtime build plan"); + } + return plan; } describe("plugin npm runtime build planning", () => { @@ -22,18 +33,19 @@ describe("plugin npm runtime build planning", () => { packageDir, }), ); - expect(plans.filter(Boolean).map((plan) => plan?.pluginDir)).toEqual( + const resolvedPlans = plans.map(expectPluginNpmRuntimeBuildPlan); + expect(resolvedPlans.map((plan) => plan.pluginDir)).toEqual( packageDirs.map((packageDir) => path.basename(packageDir)), ); - for (const plan of plans) { - expect(plan?.outDir).toBe(path.join(plan?.packageDir ?? "", "dist")); - expectDistRelativePaths(plan?.runtimeExtensions ?? []); - expectDistRelativePaths(plan?.runtimeBuildOutputs ?? []); - expect(plan?.packageFiles).toContain("dist/**"); - expect(plan?.packagePeerMetadata.peerDependencies.openclaw).toBe( - plan?.packageJson.openclaw.compat.pluginApi, + for (const plan of resolvedPlans) { + expect(plan.outDir).toBe(path.join(plan.packageDir, "dist")); + expectDistRelativePaths(plan.runtimeExtensions); + expectDistRelativePaths(plan.runtimeBuildOutputs); + expect(plan.packageFiles).toContain("dist/**"); + expect(plan.packagePeerMetadata.peerDependencies.openclaw).toBe( + plan.packageJson.openclaw.compat.pluginApi, ); - expect(plan?.packagePeerMetadata.peerDependenciesMeta.openclaw.optional).toBe(true); + expect(plan.packagePeerMetadata.peerDependenciesMeta.openclaw.optional).toBe(true); } }); @@ -42,28 +54,30 @@ describe("plugin npm runtime build planning", () => { repoRoot, packageDir: path.join(repoRoot, "extensions", "qqbot"), }); - expect(qqbotPlan?.entry).toEqual( + const qqbotRuntimePlan = expectPluginNpmRuntimeBuildPlan(qqbotPlan); + expect(qqbotRuntimePlan.entry).toEqual( expect.objectContaining({ index: path.join(repoRoot, "extensions", "qqbot", "index.ts"), "runtime-api": path.join(repoRoot, "extensions", "qqbot", "runtime-api.ts"), "setup-entry": path.join(repoRoot, "extensions", "qqbot", "setup-entry.ts"), }), ); - expect(qqbotPlan?.runtimeExtensions).toEqual(["./dist/index.js"]); - expect(qqbotPlan?.runtimeSetupEntry).toBe("./dist/setup-entry.js"); + expect(qqbotRuntimePlan.runtimeExtensions).toEqual(["./dist/index.js"]); + expect(qqbotRuntimePlan.runtimeSetupEntry).toBe("./dist/setup-entry.js"); const diffsPlan = resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "diffs"), }); - expect(diffsPlan?.entry).toEqual( + const diffsRuntimePlan = expectPluginNpmRuntimeBuildPlan(diffsPlan); + expect(diffsRuntimePlan.entry).toEqual( expect.objectContaining({ api: path.join(repoRoot, "extensions", "diffs", "api.ts"), index: path.join(repoRoot, "extensions", "diffs", "index.ts"), "runtime-api": path.join(repoRoot, "extensions", "diffs", "runtime-api.ts"), }), ); - expect(diffsPlan?.packageFiles).toEqual([ + expect(diffsRuntimePlan.packageFiles).toEqual([ "dist/**", "openclaw.plugin.json", "README.md", diff --git a/test/scripts/bench-gateway-startup.test.ts b/test/scripts/bench-gateway-startup.test.ts index 29ac4588d7b..adbddf975d2 100644 --- a/test/scripts/bench-gateway-startup.test.ts +++ b/test/scripts/bench-gateway-startup.test.ts @@ -9,7 +9,10 @@ describe("gateway startup benchmark script", () => { { cwd: process.cwd(), encoding: "utf8", - env: process.env, + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + }, }, ); diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 18fbaa7d19c..27208097301 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -8,11 +8,11 @@ import { } from "../../scripts/lib/bundled-plugin-build-entries.mjs"; function expectNoPrefixMatches(values: string[], prefix: string) { - expect(values.filter((value) => value.startsWith(prefix))).toEqual([]); + expect(values.some((value) => value.startsWith(prefix))).toBe(false); } function expectSomePrefixMatch(values: string[], prefix: string) { - expect(values.filter((value) => value.startsWith(prefix)).length).toBeGreaterThan(0); + expect(values.some((value) => value.startsWith(prefix))).toBe(true); } describe("bundled plugin build entries", () => { diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 3b9d9610b11..1acc9ce9077 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -781,7 +781,7 @@ describe("scripts/changed-lanes", () => { "utf8", ); git(dir, ["add", "package.json"]); - expect(() => + expect( execFileSync( process.execPath, [path.join(repoRoot, "scripts", "check-release-metadata-only.mjs"), "--staged"], @@ -791,7 +791,7 @@ describe("scripts/changed-lanes", () => { stdio: "pipe", }, ), - ).not.toThrow(); + ).toBeInstanceOf(Buffer); writeFileSync( path.join(dir, "package.json"), diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 6b44bbc3802..80f929657c4 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -30,6 +30,14 @@ function planFor( }).plan; } +function requireFirstLane(plan: ReturnType) { + const [lane] = plan.lanes; + if (!lane) { + throw new Error("Expected at least one Docker E2E lane"); + } + return lane; +} + describe("scripts/lib/docker-e2e-plan", () => { it("plans the full release path against package-backed e2e images", () => { const plan = planFor({ @@ -52,10 +60,10 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.lanes.map((lane) => lane.name)).toContain("commitments-safety"); expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-plugin-install-uninstall-0"); expect(plan.lanes.map((lane) => lane.name)).toContain("bundled-plugin-install-uninstall-23"); - expect(plan.lanes.filter((lane) => lane.name === "install-e2e-openai")).toHaveLength(1); - expect( - plan.lanes.filter((lane) => lane.name === "bundled-plugin-install-uninstall-0"), - ).toHaveLength(1); + const countLane = (name: string) => + plan.lanes.reduce((count, lane) => count + (lane.name === name ? 1 : 0), 0); + expect(countLane("install-e2e-openai")).toBe(1); + expect(countLane("bundled-plugin-install-uninstall-0")).toBe(1); expect(plan.lanes.map((lane) => lane.name)).not.toContain("bundled-plugin-install-uninstall"); expect(plan.lanes.map((lane) => lane.name)).not.toContain("bundled-channel-deps"); expect(plan.lanes.map((lane) => lane.name)).not.toContain("openwebui"); @@ -342,7 +350,7 @@ describe("scripts/lib/docker-e2e-plan", () => { ), }), ]); - expect(plan.lanes[0]?.command).toContain( + expect(requireFirstLane(plan).command).toContain( 'OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/published-upgrade-survivor-2026.4.29"', ); }); diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index 5648d6182b5..a4b9e24aae2 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -61,6 +61,12 @@ describe("install.ps1 failure handling", () => { const source = readFileSync(SCRIPT_PATH, "utf8"); const powershell = findPowerShell(); const runIfPowerShell = powershell ? it : it.skip; + const runPowerShell = (args: string[]) => { + if (!powershell) { + throw new Error("PowerShell is not available"); + } + return spawnSync(powershell, args, { encoding: "utf8" }); + }; it("does not exit directly from inside Main", () => { const mainBody = extractFunctionBody(source, "Main"); @@ -103,11 +109,14 @@ describe("install.ps1 failure handling", () => { ); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); @@ -119,11 +128,14 @@ describe("install.ps1 failure handling", () => { writeFileSync(scriptPath, createFailingNodeFixture(source)); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(1); }); @@ -142,9 +154,7 @@ describe("install.ps1 failure handling", () => { "}", 'Write-Output "alive-after-install"', ].join("\n"); - const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-Command", command], { - encoding: "utf8", - }); + const result = runPowerShell(["-NoLogo", "-NoProfile", "-Command", command]); expect(result.status).toBe(0); expect(result.stdout).toContain("caught=OpenClaw installation failed with exit code 1."); @@ -177,11 +187,14 @@ describe("install.ps1 failure handling", () => { ); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); @@ -219,11 +232,14 @@ describe("install.ps1 failure handling", () => { ); chmodSync(scriptPath, 0o755); - const result = spawnSync( - powershell!, - ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], - { encoding: "utf8" }, - ); + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); diff --git a/test/scripts/managed-child-process.test.ts b/test/scripts/managed-child-process.test.ts index 237c17a1a95..c7c3ae0f832 100644 --- a/test/scripts/managed-child-process.test.ts +++ b/test/scripts/managed-child-process.test.ts @@ -9,6 +9,13 @@ import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDir } = createScriptTestHarness(); +function expectProcessPid(pid: number | undefined): number { + if (pid == null) { + throw new Error("Expected spawned process to expose a pid"); + } + return pid; +} + describe("managed-child-process", () => { it("maps forwarded signals to shell-compatible exit codes", () => { expect(signalExitCode("SIGHUP")).toBe(129); @@ -56,6 +63,7 @@ process.exitCode = await runManagedCommand({ const runner = spawn(process.execPath, [runnerPath], { stdio: "ignore", }); + const runnerPid = expectProcessPid(runner.pid); let childPid = 0; try { @@ -65,14 +73,14 @@ process.exitCode = await runManagedCommand({ expect(Number.isInteger(childPid)).toBe(true); expect(isProcessAlive(childPid)).toBe(true); - process.kill(runner.pid!, "SIGTERM"); + process.kill(runnerPid, "SIGTERM"); const result = await waitForClose(runner); expect(result).toEqual({ code: 143, signal: null }); await waitFor(() => !isProcessAlive(childPid), 10_000); } finally { - if (runner.pid && isProcessAlive(runner.pid)) { - process.kill(runner.pid, "SIGKILL"); + if (isProcessAlive(runnerPid)) { + process.kill(runnerPid, "SIGKILL"); } if (childPid && isProcessAlive(childPid)) { process.kill(childPid, "SIGKILL"); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 9c71faf90f8..35ace6d23ae 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -268,9 +268,8 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(source).toContain(providerOverride); expect(source).not.toContain("models.providers.${params.providerConfig.extensionId}.baseUrl"); expect(source).toContain('"--timeout",\n String(CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS)'); - expect(source.match(/buildReleaseAgentTurnArgs\(sessionId\)/g)?.length).toBeGreaterThanOrEqual( - 2, - ); + const agentTurnArgCalls = source.match(/buildReleaseAgentTurnArgs\(sessionId\)/g) ?? []; + expect(agentTurnArgCalls.length).toBeGreaterThanOrEqual(2); }); it("treats explicit empty-string args as values instead of boolean flags", () => { @@ -989,7 +988,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }); it("accepts a git main dev-channel update status payload", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1003,11 +1002,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }, }), ), - ).not.toThrow(); + ).toBeUndefined(); }); it("accepts a git dev-channel payload for a requested non-main branch", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1023,11 +1022,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }), { ref: "codex/cross-os-release-checks-full-native-e2e" }, ), - ).not.toThrow(); + ).toBeUndefined(); }); it("accepts a git dev-channel payload pinned to a prepared source sha", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1043,11 +1042,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }), { ref: "08753a1d793c040b101c8a26c43445dbbab14995" }, ), - ).not.toThrow(); + ).toBeUndefined(); }); it("accepts uppercase requested commit shas when update status reports lowercase", () => { - expect(() => + expect( verifyDevUpdateStatus( JSON.stringify({ update: { @@ -1062,7 +1061,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }), { ref: "08753A1D793C040B101C8A26C43445DBBAB14995" }, ), - ).not.toThrow(); + ).toBeUndefined(); }); it("rejects update status payloads that are not on dev/main git", () => { diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 795d87feb72..36f9b8b491b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -563,7 +563,7 @@ describe("package artifact reuse", () => { ); expect(workflow).toContain("telegram_mode: mock-openai"); expect(workflow).toContain( - "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating", + "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating", ); expect(workflow).toContain("ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}"); expect(workflow).toContain("ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index c2cea49a4dc..d538f1d22da 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -35,6 +35,16 @@ const TS_PATHS = { const OS_TS_PATHS = [TS_PATHS.linux, TS_PATHS.macos, TS_PATHS.windows]; +function countNonEmptyLines(value: string): number { + let count = 0; + for (const line of value.split("\n")) { + if (line) { + count += 1; + } + } + return count; +} + function runTsEval(source: string, env: Record = {}) { return execFileSync("node", ["--import", "tsx", "--input-type=module", "--eval", source], { encoding: "utf8", @@ -79,7 +89,7 @@ describe("Parallels smoke model selection", () => { } else { expect(wrapper, wrapperPath).toContain(TS_PATHS[platform as "linux" | "macos" | "windows"]); } - expect(wrapper.split("\n").filter(Boolean).length).toBeLessThanOrEqual(5); + expect(countNonEmptyLines(wrapper)).toBeLessThanOrEqual(5); } }); @@ -481,7 +491,7 @@ console.log(JSON.stringify(result)); ) as { status: number; stdout: string }; expect(result.status).toBe(124); - expect(result.stdout).toEqual(expect.any(String)); + expect(result.stdout).toBeTypeOf("string"); }); it("runs the Windows agent turn through the detached done-file runner", () => { diff --git a/test/scripts/plugin-boundary-report.test.ts b/test/scripts/plugin-boundary-report.test.ts index 2907d589b69..4bb80143f24 100644 --- a/test/scripts/plugin-boundary-report.test.ts +++ b/test/scripts/plugin-boundary-report.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest"; import { createPluginBoundaryReport } from "../../scripts/plugin-boundary-report.js"; +function requirePluginSdkSummary(summary: { + pluginSdk?: { + crossOwnerReservedImportCount?: unknown; + unusedReservedCount?: unknown; + }; +}) { + if (!summary.pluginSdk) { + throw new Error("Expected plugin SDK summary"); + } + return summary.pluginSdk; +} + describe("plugin-boundary-report", () => { it("emits compact CI-safe summary JSON", () => { const result = createPluginBoundaryReport([ @@ -20,8 +32,9 @@ describe("plugin-boundary-report", () => { }; expect(result).toMatchObject({ exitCode: 0, stderr: "" }); - expect(summary.pluginSdk?.crossOwnerReservedImportCount).toBe(0); - expect(summary.pluginSdk?.unusedReservedCount).toBe(0); + const pluginSdk = requirePluginSdkSummary(summary); + expect(pluginSdk.crossOwnerReservedImportCount).toBe(0); + expect(pluginSdk.unusedReservedCount).toBe(0); expect(["private-core-bridge", "private-package-core-integrated"]).toContain( summary.memoryHostSdk?.implementation, ); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 62d826f9459..6261ce3198b 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -71,7 +71,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { const plan = createPluginPrereleaseTestPlan(); expect(plan.dockerLanes).not.toContain("openai-web-search-minimal"); - expect(plan.dockerLanes.filter((lane) => lane.startsWith("live-"))).toEqual([]); + expect(plan.dockerLanes.some((lane) => lane.startsWith("live-"))).toBe(false); expect(plan.staticChecks).toContainEqual({ check: "live-ish-availability", checkName: "checks-plugin-prerelease-live-ish-availability", diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 21efcf705d6..dcb2640165a 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -256,7 +256,7 @@ describe("bundled plugin postinstall", () => { await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n"); const warn = vi.fn(); - expect(() => + expect( runBundledPluginPostinstall({ env: { HOME: "/tmp/home" }, packageRoot, @@ -265,7 +265,7 @@ describe("bundled plugin postinstall", () => { }), log: { log: vi.fn(), warn }, }), - ).not.toThrow(); + ).toBeUndefined(); expect(warn).toHaveBeenCalledWith( "[postinstall] could not prune bundled plugin source node_modules: Error: locked", @@ -566,7 +566,7 @@ describe("bundled plugin postinstall", () => { it("keeps legacy plugin runtime deps cleanup non-fatal", () => { const warn = vi.fn(); - expect(() => + expect( pruneLegacyPluginRuntimeDepsState({ env: { HOME: "/home/alice" }, existsSync: vi.fn(() => true), @@ -576,7 +576,7 @@ describe("bundled plugin postinstall", () => { log: { log: vi.fn(), warn }, homedir: () => "/home/alice", }), - ).not.toThrow(); + ).toEqual([]); expect(warn).toHaveBeenCalledWith( expect.stringContaining( @@ -645,13 +645,13 @@ describe("bundled plugin postinstall", () => { return readFileSyncOriginal(filePath, options); }); - expect(() => + expect( pruneInstalledPackageDist({ packageRoot, readFileSync, log: { log: vi.fn(), warn: vi.fn() }, }), - ).not.toThrow(); + ).toEqual(["dist/stale.js"]); await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -701,12 +701,12 @@ describe("bundled plugin postinstall", () => { await fs.writeFile(staleFile, "export {};\n"); const warn = vi.fn(); - expect(() => + expect( runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn }, }), - ).not.toThrow(); + ).toBeUndefined(); await expectPathExists(staleFile); expect(warn).toHaveBeenCalledWith( @@ -723,12 +723,12 @@ describe("bundled plugin postinstall", () => { await fs.writeFile(inventoryPath, "{not-json}\n"); const warn = vi.fn(); - expect(() => + expect( runBundledPluginPostinstall({ packageRoot, log: { log: vi.fn(), warn }, }), - ).not.toThrow(); + ).toBeUndefined(); await expectPathExists(currentFile); expect(warn).toHaveBeenCalledWith( diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index afadea22b27..af9a7a01f3f 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -5,6 +5,14 @@ import { warnIfNonPnpmLifecycle, } from "../../scripts/preinstall-package-manager-warning.mjs"; +function requireFirstWarning(warn: ReturnType): unknown { + const [message] = warn.mock.calls[0] ?? []; + if (message === undefined) { + throw new Error("expected package manager warning"); + } + return message; +} + describe("detectLifecyclePackageManager", () => { it("prefers npm_config_user_agent when present", () => { expect( @@ -54,7 +62,7 @@ describe("warnIfNonPnpmLifecycle", () => { ), ).toBe(true); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain("detected npm"); + expect(requireFirstWarning(warn)).toContain("detected npm"); }); it("stays quiet for pnpm", () => { diff --git a/test/scripts/release-check.test.ts b/test/scripts/release-check.test.ts index d759dae3ae4..7fe3776aa0f 100644 --- a/test/scripts/release-check.test.ts +++ b/test/scripts/release-check.test.ts @@ -4,6 +4,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { writePackedBundledPluginActivationConfig } from "../../scripts/release-check.ts"; +function requirePluginEntries(config: { plugins?: { entries?: Record } }) { + if (!config.plugins?.entries) { + throw new Error("Expected plugin entries in packaged activation config"); + } + return config.plugins.entries; +} + describe("release-check", () => { it("seeds packaged activation smoke with an included channel plugin", () => { const homeDir = mkdtempSync(join(tmpdir(), "openclaw-release-check-test-")); @@ -17,9 +24,10 @@ describe("release-check", () => { }; expect(config.channels).toHaveProperty("matrix"); - expect(config.plugins?.entries).toHaveProperty("matrix"); + const pluginEntries = requirePluginEntries(config); + expect(pluginEntries).toHaveProperty("matrix"); expect(config.channels).not.toHaveProperty("feishu"); - expect(config.plugins?.entries).not.toHaveProperty("feishu"); + expect(pluginEntries).not.toHaveProperty("feishu"); } finally { rmSync(homeDir, { recursive: true, force: true }); } diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index 2463e205f9d..2067329675e 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -16,13 +16,17 @@ afterEach(async () => { describe("resolve-openclaw-package-candidate", () => { it("accepts only OpenClaw release package specs for npm candidates", () => { - expect(() => validateOpenClawPackageSpec("openclaw@beta")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@alpha")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@latest")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-1")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-beta.2")).not.toThrow(); - expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-alpha.2")).not.toThrow(); + for (const spec of [ + "openclaw@beta", + "openclaw@alpha", + "openclaw@latest", + "openclaw@2026.4.27", + "openclaw@2026.4.27-1", + "openclaw@2026.4.27-beta.2", + "openclaw@2026.4.27-alpha.2", + ]) { + expect(validateOpenClawPackageSpec(spec), spec).toBeUndefined(); + } expect(() => validateOpenClawPackageSpec("@evil/openclaw@1.0.0")).toThrow( "package_spec must be openclaw@alpha", diff --git a/test/scripts/root-package-overrides.test.ts b/test/scripts/root-package-overrides.test.ts index de7498b539f..f2d0830f751 100644 --- a/test/scripts/root-package-overrides.test.ts +++ b/test/scripts/root-package-overrides.test.ts @@ -30,8 +30,9 @@ describe("root package override guardrails", () => { it("pins the node-domexception alias exactly in npm and pnpm overrides", () => { const manifest = readRootManifest(); const pnpmOverride = manifest.pnpm?.overrides?.["node-domexception"]; + const npmOverride = manifest.overrides?.["node-domexception"]; expect(pnpmOverride).toBe("npm:@nolyfill/domexception@1.0.28"); - expect(manifest.overrides?.["node-domexception"]).toBe(pnpmOverride); + expect(npmOverride).toBe(pnpmOverride); }); }); diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 6811a9b7d54..20e9b3da69e 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -20,6 +20,13 @@ import { const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); +type RunGroupParams = { + args: string[]; + config: string; + env: Record; + targets: string[]; +}; + function runScript(args: string[], cwd = process.cwd()) { return execFileSync(process.execPath, [scriptPath, ...args], { cwd, @@ -27,6 +34,14 @@ function runScript(args: string[], cwd = process.cwd()) { }); } +function requireFirstMockArg(mock: { mock: { calls: Array<[T, ...unknown[]]> } }): T { + const [arg] = mock.mock.calls[0] ?? []; + if (arg === undefined) { + throw new Error("expected first mock call argument"); + } + return arg; +} + function findExtensionWithoutTests() { const extensionId = listAvailableExtensionIds().find( (candidate) => !resolveExtensionTestPlan({ targetArg: candidate, cwd: process.cwd() }).hasTests, @@ -466,19 +481,12 @@ describe("scripts/test-extension.mjs", () => { it("runs extension batch config groups concurrently when requested", async () => { const started: string[] = []; const resolvers: Array<() => void> = []; - const runGroup = vi.fn( - (params: { - args: string[]; - config: string; - env: Record; - targets: string[]; - }) => { - started.push(params.config); - return new Promise((resolve) => { - resolvers.push(() => resolve(0)); - }); - }, - ); + const runGroup = vi.fn((params: RunGroupParams) => { + started.push(params.config); + return new Promise((resolve) => { + resolvers.push(() => resolve(0)); + }); + }); const runPromise = runExtensionBatchPlan( { extensionCount: 3, @@ -527,12 +535,13 @@ describe("scripts/test-extension.mjs", () => { } await expect(runPromise).resolves.toBe(0); expect(runGroup).toHaveBeenCalledTimes(3); - expect(runGroup.mock.calls[0]?.[0]).toMatchObject({ + const firstRunGroupParams = requireFirstMockArg(runGroup); + expect(firstRunGroupParams).toMatchObject({ args: ["--reporter=dot"], config: "heavy", targets: ["extensions/two"], }); - expect(runGroup.mock.calls[0]?.[0].env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toContain( + expect(firstRunGroupParams.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toContain( path.join("node_modules", ".experimental-vitest-cache", "extension-batch", "0-heavy"), ); }); diff --git a/test/scripts/ts-topology.test.ts b/test/scripts/ts-topology.test.ts index 70dfa32d266..eb56c297754 100644 --- a/test/scripts/ts-topology.test.ts +++ b/test/scripts/ts-topology.test.ts @@ -34,11 +34,19 @@ function deriveReportEnvelope(report: Parameters[ const singleOwnerEnvelope = deriveReportEnvelope("single-owner-shared"); const unusedEnvelope = deriveReportEnvelope("unused-public-surface"); +function requireRecordByExport(exportName: string) { + const record = publicSurfaceEnvelope.records.find((entry) => + entry.exportNames.includes(exportName), + ); + if (!record) { + throw new Error(`Expected topology record for ${exportName}`); + } + return record; +} + describe("ts-topology", () => { it("collapses canonical symbols exported by multiple public subpaths", () => { - const sharedThing = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("sharedThing"), - ); + const sharedThing = requireRecordByExport("sharedThing"); expect(sharedThing).toMatchObject({ declarationPath: "src/lib/shared.ts", @@ -47,21 +55,15 @@ describe("ts-topology", () => { productionPackages: ["src"], productionOwners: ["extension:alpha", "extension:beta", "src"], }); - expect(sharedThing?.publicSpecifiers).toEqual(["fixture-sdk", "fixture-sdk/extra"]); + expect(sharedThing.publicSpecifiers).toEqual(["fixture-sdk", "fixture-sdk/extra"]); }); it("counts renamed imports, namespace imports, type-only imports, and test-only consumers", () => { - const aliasedThing = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("aliasedThing"), - ); - const sharedType = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("SharedType"), - ); - const testOnlyThing = publicSurfaceEnvelope.records.find((record) => - record.exportNames.includes("testOnlyThing"), - ); + const aliasedThing = requireRecordByExport("aliasedThing"); + const sharedType = requireRecordByExport("SharedType"); + const testOnlyThing = requireRecordByExport("testOnlyThing"); - expect(aliasedThing?.productionRefCount).toBe(1); + expect(aliasedThing.productionRefCount).toBe(1); expect(sharedType).toMatchObject({ isTypeOnlyCandidate: true, productionExtensions: ["alpha", "beta"], diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index d0a1b67de31..197acba7582 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -52,11 +52,11 @@ describe("resolveTsdownBuildInvocation", () => { throw new Error("locked"); }); - expect(() => + expect( pruneSourceCheckoutBundledPluginNodeModules({ cwd: process.cwd(), }), - ).not.toThrow(); + ).toBeUndefined(); expect(warn).toHaveBeenCalledWith( "tsdown: could not prune bundled plugin source node_modules: Error: locked", diff --git a/test/scripts/vitest-process-group.test.ts b/test/scripts/vitest-process-group.test.ts index 75791bdac0c..c0c2a7d47fc 100644 --- a/test/scripts/vitest-process-group.test.ts +++ b/test/scripts/vitest-process-group.test.ts @@ -7,6 +7,22 @@ import { } from "../../scripts/vitest-process-group.mjs"; describe("vitest process group helpers", () => { + function getListenerSet(listeners: Map void>>, event: string) { + const set = listeners.get(event); + if (!set) { + throw new Error(`expected ${event} listener set`); + } + return set; + } + + function expectListenerCount( + listeners: Map void>>, + event: string, + count: number, + ) { + expect(getListenerSet(listeners, event).size).toBe(count); + } + it("uses detached process groups on non-Windows hosts", () => { expect(shouldUseDetachedVitestProcessGroup("darwin")).toBe(true); expect(shouldUseDetachedVitestProcessGroup("linux")).toBe(true); @@ -84,17 +100,17 @@ describe("vitest process group helpers", () => { kill, }); - expect(listeners.get("SIGINT")?.size).toBe(1); - expect(listeners.get("SIGTERM")?.size).toBe(1); - expect(listeners.get("exit")?.size).toBe(1); + expectListenerCount(listeners, "SIGINT", 1); + expectListenerCount(listeners, "SIGTERM", 1); + expectListenerCount(listeners, "exit", 1); - listeners.get("SIGTERM")?.values().next().value?.(); + getListenerSet(listeners, "SIGTERM").values().next().value(); expect(kill).toHaveBeenCalledWith(-4200, "SIGTERM"); teardown(); - expect(listeners.get("SIGINT")?.size ?? 0).toBe(0); - expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0); - expect(listeners.get("exit")?.size ?? 0).toBe(0); + expectListenerCount(listeners, "SIGINT", 0); + expectListenerCount(listeners, "SIGTERM", 0); + expectListenerCount(listeners, "exit", 0); }); it("raises process listener limits for highly parallel cleanup handlers", () => { @@ -134,8 +150,8 @@ describe("vitest process group helpers", () => { for (const teardown of teardowns) { teardown(); } - expect(listeners.get("SIGINT")?.size ?? 0).toBe(0); - expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0); - expect(listeners.get("exit")?.size ?? 0).toBe(0); + expectListenerCount(listeners, "SIGINT", 0); + expectListenerCount(listeners, "SIGTERM", 0); + expectListenerCount(listeners, "exit", 0); }); }); diff --git a/test/test-env.test.ts b/test/test-env.test.ts index 4b462096e8e..704690d7b58 100644 --- a/test/test-env.test.ts +++ b/test/test-env.test.ts @@ -34,6 +34,32 @@ function createTempHome(): string { return makeTempDir(tempDirs, "openclaw-test-env-real-home-"); } +function requireRecord( + value: Record | undefined, + label: string, +): Record { + if (!value) { + throw new Error(`expected copied ${label} config`); + } + return value; +} + +function requireTelegramStreaming( + value: + | { + mode?: string; + chunkMode?: string; + block?: { enabled?: boolean }; + preview?: { chunk?: { minChars?: number } }; + } + | undefined, +) { + if (!value) { + throw new Error("expected copied telegram streaming config"); + } + return value; +} + afterEach(() => { while (cleanupFns.length > 0) { cleanupFns.pop()?.(); @@ -140,12 +166,19 @@ describe("installTestEnv", () => { }; }; }; - expect(copiedConfig.models?.providers?.custom).toEqual({ baseUrl: "https://example.test/v1" }); - expect(copiedConfig.agents?.defaults?.workspace).toBeUndefined(); - expect(copiedConfig.agents?.defaults?.agentDir).toBeUndefined(); - expect(copiedConfig.agents?.list?.[0]?.workspace).toBeUndefined(); - expect(copiedConfig.agents?.list?.[0]?.agentDir).toBeUndefined(); - expect(copiedConfig.channels?.telegram?.streaming).toEqual({ + const providers = copiedConfig.models?.providers; + requireRecord(providers, "model providers"); + expect(providers.custom).toEqual({ baseUrl: "https://example.test/v1" }); + + const agentDefaults = requireRecord(copiedConfig.agents?.defaults, "agent defaults"); + const agentConfig = requireRecord(copiedConfig.agents?.list?.[0], "agent"); + expect(agentDefaults.workspace).toBeUndefined(); + expect(agentDefaults.agentDir).toBeUndefined(); + expect(agentConfig.workspace).toBeUndefined(); + expect(agentConfig.agentDir).toBeUndefined(); + + const telegramStreaming = requireTelegramStreaming(copiedConfig.channels?.telegram?.streaming); + expect(telegramStreaming).toEqual({ mode: "block", chunkMode: "newline", block: { enabled: true }, diff --git a/test/vitest-boundary-config.test.ts b/test/vitest-boundary-config.test.ts index 149bb04ef55..8b5ed8c3d8e 100644 --- a/test/vitest-boundary-config.test.ts +++ b/test/vitest-boundary-config.test.ts @@ -6,6 +6,13 @@ import { } from "./vitest/vitest.boundary.config.ts"; import { boundaryTestFiles } from "./vitest/vitest.unit-paths.mjs"; +function requireTestConfig(config: ReturnType) { + if (!config.test) { + throw new Error("expected boundary vitest test config"); + } + return config.test; +} + describe("loadBoundaryIncludePatternsFromEnv", () => { it("returns null when no include file is configured", () => { expect(loadBoundaryIncludePatternsFromEnv({})).toBeNull(); @@ -15,11 +22,12 @@ describe("loadBoundaryIncludePatternsFromEnv", () => { describe("boundary vitest config", () => { it("keeps boundary suites on the non-isolated runner with shared test bootstrap", () => { const config = createBoundaryVitestConfig({}); + const testConfig = requireTestConfig(config); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); - expect(config.test?.include).toEqual(boundaryTestFiles); - expect(normalizeConfigPaths(config.test?.setupFiles)).toEqual(["test/setup.ts"]); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); + expect(testConfig.include).toEqual(boundaryTestFiles); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual(["test/setup.ts"]); }); it("narrows boundary includes to matching CLI file filters", () => { @@ -29,8 +37,9 @@ describe("boundary vitest config", () => { "run", "src/infra/openclaw-root.test.ts", ]); + const testConfig = requireTestConfig(config); - expect(config.test?.include).toEqual(["src/infra/openclaw-root.test.ts"]); - expect(config.test?.passWithNoTests).toBe(true); + expect(testConfig.include).toEqual(["src/infra/openclaw-root.test.ts"]); + expect(testConfig.passWithNoTests).toBe(true); }); }); diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index c27138761a1..bac4905a902 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -32,13 +32,30 @@ import { createUnitVitestConfig } from "./vitest/vitest.unit.config.ts"; const patternFiles = createPatternFileHelper("openclaw-vitest-projects-config-"); +function requireTestConfig(config: T): NonNullable { + if (!config.test) { + throw new Error("expected vitest test config"); + } + return config.test as NonNullable; +} + +function requireWebOptimizer(testConfig: { + deps?: { optimizer?: { web?: { enabled?: boolean } } }; +}) { + const webOptimizer = testConfig.deps?.optimizer?.web; + if (!webOptimizer) { + throw new Error("expected vitest web optimizer config"); + } + return webOptimizer; +} + afterEach(() => { patternFiles.cleanup(); }); describe("projects vitest config", () => { it("defines the native root project list for all non-live Vitest lanes", () => { - expect(baseConfig.test?.projects).toEqual([...rootVitestProjects]); + expect(requireTestConfig(baseConfig).projects).toEqual([...rootVitestProjects]); }); it("disables vite env-file loading for vitest lanes", () => { @@ -102,11 +119,11 @@ describe("projects vitest config", () => { it("gives contract project configs unique names", () => { expect([ - contractChannelSurfaceConfig.test?.name, - contractChannelConfigConfig.test?.name, - contractChannelRegistryConfig.test?.name, - contractChannelSessionConfig.test?.name, - contractPluginConfig.test?.name, + requireTestConfig(contractChannelSurfaceConfig).name, + requireTestConfig(contractChannelConfigConfig).name, + requireTestConfig(contractChannelRegistryConfig).name, + requireTestConfig(contractChannelSessionConfig).name, + requireTestConfig(contractPluginConfig).name, ]).toEqual([ "contracts-channel-surface", "contracts-channel-config", @@ -150,21 +167,23 @@ describe("projects vitest config", () => { it("keeps the root ui lane aligned with the shared jsdom setup", () => { const config = createUiVitestConfig(); - expect(config.test.environment).toBe("jsdom"); - expect(config.test.isolate).toBe(false); - expect(normalizeConfigPath(config.test.runner)).toBe("test/non-isolated-runner.ts"); - const setupFiles = normalizeConfigPaths(config.test.setupFiles); + const testConfig = requireTestConfig(config); + expect(testConfig.environment).toBe("jsdom"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); + const setupFiles = normalizeConfigPaths(testConfig.setupFiles); expect(setupFiles).not.toContain("test/setup-openclaw-runtime.ts"); expect(setupFiles).toContain("ui/src/test-helpers/lit-warnings.setup.ts"); - expect(config.test.deps?.optimizer?.web?.enabled).toBe(true); + expect(requireWebOptimizer(testConfig).enabled).toBe(true); }); it("keeps the unit-ui shard aligned with the shared jsdom setup", () => { - expect(unitUiConfig.test?.environment).toBe("jsdom"); - expect(unitUiConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(unitUiConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + const testConfig = requireTestConfig(unitUiConfig); + expect(testConfig.environment).toBe("jsdom"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); expect(unitUiIncludePatterns).toContain("ui/src/ui/views/dreaming.test.ts"); - const setupFiles = normalizeConfigPaths(unitUiConfig.test?.setupFiles); + const setupFiles = normalizeConfigPaths(testConfig.setupFiles); expect(setupFiles).not.toContain("test/setup-openclaw-runtime.ts"); expect(setupFiles).toContain("ui/src/test-helpers/lit-warnings.setup.ts"); }); @@ -182,8 +201,9 @@ describe("projects vitest config", () => { }); it("keeps the bundled lane on thread workers with the non-isolated runner", () => { - expect(bundledConfig.test?.pool).toBe("threads"); - expect(bundledConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(bundledConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + const testConfig = requireTestConfig(bundledConfig); + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); }); }); diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index b1e122852ca..ab70c1bd65e 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -85,6 +85,22 @@ function matchingExcludePatterns(patterns: string[], file: string): string[] { return patterns.filter((pattern) => path.matchesGlob(file, pattern)); } +function requireTestConfig(config: T): NonNullable { + if (!config.test) { + throw new Error("expected scoped vitest test config"); + } + return config.test as NonNullable; +} + +function expectThreadedNonIsolatedRunner(config: { + test?: { pool?: unknown; isolate?: unknown; runner?: unknown }; +}) { + const testConfig = requireTestConfig(config); + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); +} + describe("resolveVitestIsolation", () => { it("aliases private QA plugin SDK subpaths for source tests only", () => { expect(sharedVitestConfig.resolve.alias).toEqual( @@ -120,19 +136,21 @@ describe("resolveVitestIsolation", () => { it("resolves scoped discovery dirs from the repo root after config relocation", () => { const config = createExtensionMatrixVitestConfig({}); + const testConfig = requireTestConfig(config); expect(config.root).toBe(process.cwd()); - expect(config.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(config.test?.include).toContain("matrix/**/*.test.ts"); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toContain("matrix/**/*.test.ts"); }); }); describe("createScopedVitestConfig", () => { it("applies the non-isolated runner by default", () => { const config = createScopedVitestConfig(["src/example.test.ts"], { env: {} }); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); - expect(normalizeConfigPaths(config.test?.setupFiles)).toEqual([ + const testConfig = requireTestConfig(config); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup-openclaw-runtime.ts", ]); @@ -143,8 +161,9 @@ describe("createScopedVitestConfig", () => { dir: "src", env: {}, }); - expect(config.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(config.test?.include).toEqual(["example.test.ts"]); + const testConfig = requireTestConfig(config); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["example.test.ts"]); }); it("keeps scoped cli directory filters aligned with repo-root include patterns", () => { @@ -155,7 +174,7 @@ describe("createScopedVitestConfig", () => { passWithNoTests: true, }); - expect(config.test?.include).toEqual(["slack/**/*.test.*"]); + expect(requireTestConfig(config).include).toEqual(["slack/**/*.test.*"]); }); it("keeps broad scoped cli directory filters aligned with repo-root include patterns", () => { @@ -166,7 +185,7 @@ describe("createScopedVitestConfig", () => { passWithNoTests: true, }); - expect(config.test?.include).toEqual(["speech-core/**/*.test.*"]); + expect(requireTestConfig(config).include).toEqual(["speech-core/**/*.test.*"]); }); it("relativizes scoped include and exclude patterns to the configured dir", () => { @@ -175,9 +194,10 @@ describe("createScopedVitestConfig", () => { env: {}, exclude: [EXTENSIONS_CHANNEL_GLOB, "dist/**"], }); + const testConfig = requireTestConfig(config); - expect(config.test?.include).toEqual(["**/*.test.ts"]); - expect(config.test?.exclude).toEqual(expect.arrayContaining(["channel/**", "dist/**"])); + expect(testConfig.include).toEqual(["**/*.test.ts"]); + expect(testConfig.exclude).toEqual(expect.arrayContaining(["channel/**", "dist/**"])); }); it("narrows scoped includes to matching CLI file filters", () => { @@ -186,9 +206,10 @@ describe("createScopedVitestConfig", () => { dir: "extensions", env: {}, }); + const testConfig = requireTestConfig(config); - expect(config.test?.include).toEqual(["browser/index.test.ts"]); - expect(config.test?.passWithNoTests).toBe(true); + expect(testConfig.include).toEqual(["browser/index.test.ts"]); + expect(testConfig.passWithNoTests).toBe(true); }); it("loads scoped include overrides from OPENCLAW_VITEST_INCLUDE_FILE", () => { @@ -204,7 +225,7 @@ describe("createScopedVitestConfig", () => { }, }); - expect(config.test?.include).toEqual(["utils/utils-misc.test.ts"]); + expect(requireTestConfig(config).include).toEqual(["utils/utils-misc.test.ts"]); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -216,7 +237,7 @@ describe("createScopedVitestConfig", () => { setupFiles: ["test/setup.extensions.ts"], }); - expect(normalizeConfigPaths(config.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(requireTestConfig(config).setupFiles)).toEqual([ "test/setup.ts", "test/setup.extensions.ts", "test/setup-openclaw-runtime.ts", @@ -224,7 +245,7 @@ describe("createScopedVitestConfig", () => { }); it("keeps bundled unit test includes out of the bundled exclude list", () => { - const excludePatterns = bundledVitestConfig.test?.exclude ?? []; + const excludePatterns = requireTestConfig(bundledVitestConfig).exclude ?? []; for (const file of bundledPluginDependentUnitTestFiles) { expect( excludePatterns.some((pattern) => bundledExcludePatternCouldMatchFile(pattern, file)), @@ -312,77 +333,69 @@ describe("scoped vitest configs", () => { defaultAutoReplyReplyConfig, defaultToolingConfig, ]) { - expect(config.test?.pool).toBe("threads"); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); + expectThreadedNonIsolatedRunner(config); } for (const config of [defaultGatewayConfig, defaultAgentsConfig]) { - expect(config.test?.pool).toBe("threads"); - expect(config.test?.isolate).toBe(false); - expect(normalizeConfigPath(config.test?.runner)).toBe("test/non-isolated-runner.ts"); + expectThreadedNonIsolatedRunner(config); } - expect(defaultCommandsConfig.test?.pool).toBe("threads"); - expect(defaultCommandsConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(defaultCommandsConfig.test?.runner)).toBe( - "test/non-isolated-runner.ts", - ); + expectThreadedNonIsolatedRunner(defaultCommandsConfig); - expect(defaultUiConfig.test?.pool).toBe("threads"); - expect(defaultUiConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(defaultUiConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + expectThreadedNonIsolatedRunner(defaultUiConfig); }); it("keeps the process lane off the openclaw runtime setup", () => { - expect(normalizeConfigPaths(defaultProcessConfig.test?.setupFiles)).toEqual(["test/setup.ts"]); - expect(normalizeConfigPaths(defaultRuntimeConfig.test?.setupFiles)).toEqual(["test/setup.ts"]); - expect(normalizeConfigPaths(defaultPluginSdkConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(requireTestConfig(defaultProcessConfig).setupFiles)).toEqual([ + "test/setup.ts", + ]); + expect(normalizeConfigPaths(requireTestConfig(defaultRuntimeConfig).setupFiles)).toEqual([ + "test/setup.ts", + ]); + expect(normalizeConfigPaths(requireTestConfig(defaultPluginSdkConfig).setupFiles)).toEqual([ "test/setup.ts", "test/setup-openclaw-runtime.ts", ]); }); it("splits auto-reply into narrower scoped buckets", () => { - expect(defaultAutoReplyCoreConfig.test?.include).toEqual(["*.test.ts"]); - expect(defaultAutoReplyCoreConfig.test?.exclude).toEqual( - expect.arrayContaining(["reply*.test.ts"]), - ); - expect(defaultAutoReplyTopLevelConfig.test?.include).toEqual(["reply*.test.ts"]); - expect(defaultAutoReplyReplyConfig.test?.include).toEqual(["reply/**/*.test.ts"]); + const coreTestConfig = requireTestConfig(defaultAutoReplyCoreConfig); + expect(coreTestConfig.include).toEqual(["*.test.ts"]); + expect(coreTestConfig.exclude).toEqual(expect.arrayContaining(["reply*.test.ts"])); + expect(requireTestConfig(defaultAutoReplyTopLevelConfig).include).toEqual(["reply*.test.ts"]); + expect(requireTestConfig(defaultAutoReplyReplyConfig).include).toEqual(["reply/**/*.test.ts"]); }); it("keeps the broad agents lane on shared file parallelism", () => { - expect(defaultAgentsConfig.test?.fileParallelism).toBe(sharedVitestConfig.test.fileParallelism); + expect(requireTestConfig(defaultAgentsConfig).fileParallelism).toBe( + sharedVitestConfig.test.fileParallelism, + ); }); it("keeps selected plugin-sdk and commands light lanes off the openclaw runtime setup", () => { - expect(normalizeConfigPaths(defaultPluginSdkLightConfig.test?.setupFiles)).toEqual([ - "test/setup.ts", - ]); - expect(normalizeConfigPaths(defaultCommandsLightConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(requireTestConfig(defaultPluginSdkLightConfig).setupFiles)).toEqual( + ["test/setup.ts"], + ); + expect(normalizeConfigPaths(requireTestConfig(defaultCommandsLightConfig).setupFiles)).toEqual([ "test/setup.ts", ]); }); it("keeps the ui lane off both the openclaw runtime setup and unit-fast excludes", () => { - expect(normalizeConfigPaths(defaultUiConfig.test?.setupFiles)).toEqual([ + const testConfig = requireTestConfig(defaultUiConfig); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ "test/setup.ts", "ui/src/test-helpers/lit-warnings.setup.ts", ]); - expect(defaultUiConfig.test?.exclude).not.toContain("chat/slash-command-executor.node.test.ts"); + expect(testConfig.exclude).not.toContain("chat/slash-command-executor.node.test.ts"); }); it("defaults channel tests to threads with the non-isolated runner", () => { - expect(defaultChannelsConfig.test?.isolate).toBe(false); - expect(defaultChannelsConfig.test?.pool).toBe("threads"); - expect(normalizeConfigPath(defaultChannelsConfig.test?.runner)).toBe( - "test/non-isolated-runner.ts", - ); + expectThreadedNonIsolatedRunner(defaultChannelsConfig); }); it("keeps the core channel lane limited to non-extension roots", () => { - expect(defaultChannelsConfig.test?.include).toEqual(["src/channels/**/*.test.ts"]); + expect(requireTestConfig(defaultChannelsConfig).include).toEqual(["src/channels/**/*.test.ts"]); }); it("loads channel include overrides from OPENCLAW_VITEST_INCLUDE_FILE", () => { @@ -405,7 +418,7 @@ describe("scoped vitest configs", () => { OPENCLAW_VITEST_INCLUDE_FILE: includeFile, }); - expect(config.test?.include).toEqual([ + expect(requireTestConfig(config).include).toEqual([ bundledPluginFile("discord", "src/monitor/message-handler.preflight.acp-bindings.test.ts"), ]); } finally { @@ -414,11 +427,7 @@ describe("scoped vitest configs", () => { }); it("defaults extension tests to threads with the non-isolated runner", () => { - expect(defaultExtensionsConfig.test?.isolate).toBe(false); - expect(defaultExtensionsConfig.test?.pool).toBe("threads"); - expect(normalizeConfigPath(defaultExtensionsConfig.test?.runner)).toBe( - "test/non-isolated-runner.ts", - ); + expectThreadedNonIsolatedRunner(defaultExtensionsConfig); }); it("normalizes split extension channel include patterns relative to the scoped dir", () => { @@ -429,101 +438,117 @@ describe("scoped vitest configs", () => { [defaultExtensionSignalConfig, "signal/**/*.test.ts"], [defaultExtensionImessageConfig, "imessage/**/*.test.ts"], ] as const) { - expect(config.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(config.test?.include).toEqual([include]); + const testConfig = requireTestConfig(config); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual([include]); } }); it("normalizes acpx extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionAcpxConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionAcpxConfig.test?.include).toEqual(["acpx/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionAcpxConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["acpx/**/*.test.ts"]); }); it("normalizes diffs extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionDiffsConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionDiffsConfig.test?.include).toEqual(["diffs/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionDiffsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["diffs/**/*.test.ts"]); }); it("normalizes feishu extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionFeishuConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionFeishuConfig.test?.include).toEqual(["feishu/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionFeishuConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["feishu/**/*.test.ts"]); }); it("normalizes irc extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionIrcConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionIrcConfig.test?.include).toEqual(["irc/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionIrcConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["irc/**/*.test.ts"]); }); it("normalizes extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionsConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes extension provider include patterns relative to the scoped dir", () => { - expect(defaultExtensionProvidersConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionProvidersConfig.test?.include).toEqual( + const providersTestConfig = requireTestConfig(defaultExtensionProvidersConfig); + expect(providersTestConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(providersTestConfig.include).toEqual( expect.arrayContaining(["xai/**/*.test.ts", "google/**/*.test.ts"]), ); - expect(defaultExtensionProvidersConfig.test?.include).not.toContain("openai/**/*.test.ts"); - expect(defaultExtensionProviderOpenAiConfig.test?.dir).toBe( - path.join(process.cwd(), "extensions"), - ); - expect(defaultExtensionProviderOpenAiConfig.test?.include).toEqual(["openai/**/*.test.ts"]); + expect(providersTestConfig.include).not.toContain("openai/**/*.test.ts"); + const openAiTestConfig = requireTestConfig(defaultExtensionProviderOpenAiConfig); + expect(openAiTestConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(openAiTestConfig.include).toEqual(["openai/**/*.test.ts"]); }); it("normalizes extension messaging include patterns relative to the scoped dir", () => { - expect(defaultExtensionMessagingConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMessagingConfig.test?.include).toEqual( - expect.arrayContaining(["googlechat/**/*.test.ts"]), - ); + const testConfig = requireTestConfig(defaultExtensionMessagingConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(expect.arrayContaining(["googlechat/**/*.test.ts"])); }); it("normalizes matrix extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMatrixConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMatrixConfig.test?.include).toEqual(["matrix/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionMatrixConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["matrix/**/*.test.ts"]); }); it("normalizes mattermost extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMattermostConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMattermostConfig.test?.include).toEqual(["mattermost/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionMattermostConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["mattermost/**/*.test.ts"]); }); it("normalizes msteams extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMsTeamsConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMsTeamsConfig.test?.include).toEqual(["msteams/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionMsTeamsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["msteams/**/*.test.ts"]); }); it("normalizes telegram extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionTelegramConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionTelegramConfig.test?.include).toEqual(["telegram/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionTelegramConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["telegram/**/*.test.ts"]); }); it("normalizes whatsapp extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionWhatsAppConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionWhatsAppConfig.test?.include).toEqual(["whatsapp/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionWhatsAppConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["whatsapp/**/*.test.ts"]); }); it("normalizes zalo extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionZaloConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionZaloConfig.test?.include).toEqual( + const testConfig = requireTestConfig(defaultExtensionZaloConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual( expect.arrayContaining(["zalo/**/*.test.ts", "zalouser/**/*.test.ts"]), ); }); it("normalizes voice-call extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionVoiceCallConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionVoiceCallConfig.test?.include).toEqual(["voice-call/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultExtensionVoiceCallConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual(["voice-call/**/*.test.ts"]); }); it("normalizes memory extension include patterns relative to the scoped dir", () => { - expect(defaultExtensionMemoryConfig.test?.dir).toBe(path.join(process.cwd(), "extensions")); - expect(defaultExtensionMemoryConfig.test?.include).toEqual( + const testConfig = requireTestConfig(defaultExtensionMemoryConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "extensions")); + expect(testConfig.include).toEqual( expect.arrayContaining(["memory-core/**/*.test.ts", "memory-lancedb/**/*.test.ts"]), ); }); it("keeps telegram plugin tests out of the shared extensions lane", () => { - const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? []; + const extensionsTestConfig = requireTestConfig(defaultExtensionsConfig); + const channelsTestConfig = requireTestConfig(defaultChannelsConfig); + const telegramTestConfig = requireTestConfig(defaultExtensionTelegramConfig); + const extensionExcludes = extensionsTestConfig.exclude ?? []; expect( extensionExcludes.some((pattern) => path.matchesGlob("telegram/src/fetch.test.ts", pattern)), ).toBe(true); @@ -532,16 +557,16 @@ describe("scoped vitest configs", () => { path.matchesGlob("telegram/src/bot/delivery.resolve-media-retry.test.ts", pattern), ), ).toBe(true); - expect(defaultChannelsConfig.test?.include).not.toContain("extensions/telegram/**/*.test.ts"); - expect(defaultChannelsConfig.test?.exclude).not.toContain( + expect(channelsTestConfig.include).not.toContain("extensions/telegram/**/*.test.ts"); + expect(channelsTestConfig.exclude).not.toContain( bundledPluginFile("telegram", "src/fetch.test.ts"), ); - expect(normalizeConfigPaths(defaultExtensionsConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(extensionsTestConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup.extensions.ts", "test/setup-openclaw-runtime.ts", ]); - expect(normalizeConfigPaths(defaultExtensionTelegramConfig.test?.setupFiles)).toEqual([ + expect(normalizeConfigPaths(telegramTestConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup.extensions.ts", "test/setup-openclaw-runtime.ts", @@ -602,13 +627,15 @@ describe("scoped vitest configs", () => { }); it("normalizes secrets include patterns relative to the scoped dir", () => { - expect(defaultSecretsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "secrets")); - expect(defaultSecretsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultSecretsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "secrets")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes hooks include patterns relative to the scoped dir", () => { - expect(defaultHooksConfig.test?.dir).toBe(path.join(process.cwd(), "src", "hooks")); - expect(defaultHooksConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultHooksConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "hooks")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("keeps memory plugin tests out of the shared extensions lane", () => { @@ -646,10 +673,14 @@ describe("scoped vitest configs", () => { it("keeps broad dedicated extension groups out of the shared extensions lane", () => { const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? []; - expect(defaultExtensionBrowserConfig.test?.include).toContain("browser/**/*.test.ts"); - expect(defaultExtensionMediaConfig.test?.include).toContain("vydra/**/*.test.ts"); - expect(defaultExtensionMiscConfig.test?.include).toContain("firecrawl/**/*.test.ts"); - expect(defaultExtensionQaConfig.test?.include).toContain("qa-lab/**/*.test.ts"); + const browserTestConfig = requireTestConfig(defaultExtensionBrowserConfig); + const mediaTestConfig = requireTestConfig(defaultExtensionMediaConfig); + const miscTestConfig = requireTestConfig(defaultExtensionMiscConfig); + const qaTestConfig = requireTestConfig(defaultExtensionQaConfig); + expect(browserTestConfig.include).toContain("browser/**/*.test.ts"); + expect(mediaTestConfig.include).toContain("vydra/**/*.test.ts"); + expect(miscTestConfig.include).toContain("firecrawl/**/*.test.ts"); + expect(qaTestConfig.include).toContain("qa-lab/**/*.test.ts"); for (const file of [ "browser/src/browser/pw.test.ts", "vydra/src/index.test.ts", @@ -661,134 +692,149 @@ describe("scoped vitest configs", () => { }); it("normalizes gateway include patterns relative to the scoped dir", () => { - expect(defaultGatewayConfig.test?.dir).toBe(path.join(process.cwd(), "src", "gateway")); - expect(defaultGatewayConfig.test?.include).toEqual(["**/*.test.ts"]); - expect(defaultGatewayConfig.test?.exclude).toContain("gateway.test.ts"); - expect(defaultGatewayConfig.test?.exclude).toContain( - "server.startup-matrix-migration.integration.test.ts", - ); - expect(defaultGatewayConfig.test?.exclude).toContain("sessions-history-http.test.ts"); + const testConfig = requireTestConfig(defaultGatewayConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "gateway")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); + expect(testConfig.exclude).toContain("gateway.test.ts"); + expect(testConfig.exclude).toContain("server.startup-matrix-migration.integration.test.ts"); + expect(testConfig.exclude).toContain("sessions-history-http.test.ts"); }); it("normalizes infra include patterns relative to the scoped dir", () => { - expect(defaultInfraConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultInfraConfig.test?.include).toEqual(["infra/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultInfraConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["infra/**/*.test.ts"]); }); it("normalizes runtime config include patterns relative to the scoped dir", () => { - expect(defaultRuntimeConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultRuntimeConfig.test?.include).toEqual(["config/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultRuntimeConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["config/**/*.test.ts"]); }); it("normalizes cron include patterns relative to the scoped dir", () => { - expect(defaultCronConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultCronConfig.test?.include).toEqual(["cron/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultCronConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["cron/**/*.test.ts"]); }); it("normalizes daemon include patterns relative to the scoped dir", () => { - expect(defaultDaemonConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultDaemonConfig.test?.include).toEqual(["daemon/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultDaemonConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["daemon/**/*.test.ts"]); }); it("normalizes media include patterns relative to the scoped dir", () => { - expect(defaultMediaConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultMediaConfig.test?.include).toEqual(["media/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultMediaConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["media/**/*.test.ts"]); }); it("normalizes logging include patterns relative to the scoped dir", () => { - expect(defaultLoggingConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultLoggingConfig.test?.include).toEqual(["logging/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultLoggingConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["logging/**/*.test.ts"]); }); it("normalizes plugin-sdk include patterns relative to the scoped dir", () => { - expect(defaultPluginSdkConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultPluginSdkConfig.test?.include).toEqual(["plugin-sdk/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultPluginSdkConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["plugin-sdk/**/*.test.ts"]); }); it("normalizes shared-core include patterns relative to the scoped dir", () => { - expect(defaultSharedCoreConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultSharedCoreConfig.test?.include).toEqual(["shared/**/*.test.ts"]); - expect(normalizeConfigPaths(defaultSharedCoreConfig.test?.setupFiles)).toEqual([ - "test/setup.ts", - ]); + const testConfig = requireTestConfig(defaultSharedCoreConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["shared/**/*.test.ts"]); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual(["test/setup.ts"]); }); it("normalizes process include patterns relative to the scoped dir", () => { - expect(defaultProcessConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultProcessConfig.test?.include).toEqual(["process/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultProcessConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["process/**/*.test.ts"]); }); it("normalizes tasks include patterns relative to the scoped dir", () => { - expect(defaultTasksConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultTasksConfig.test?.include).toEqual(["tasks/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultTasksConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["tasks/**/*.test.ts"]); }); it("normalizes wizard include patterns relative to the scoped dir", () => { - expect(defaultWizardConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultWizardConfig.test?.include).toEqual(["wizard/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultWizardConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["wizard/**/*.test.ts"]); }); it("normalizes tui include patterns relative to the scoped dir", () => { - expect(defaultTuiConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultTuiConfig.test?.include).toEqual(["tui/**/*.test.ts"]); + const testConfig = requireTestConfig(defaultTuiConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["tui/**/*.test.ts"]); }); it("normalizes media-understanding include patterns relative to the scoped dir", () => { - expect(defaultMediaUnderstandingConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultMediaUnderstandingConfig.test?.include).toEqual([ - "media-understanding/**/*.test.ts", - ]); + const testConfig = requireTestConfig(defaultMediaUnderstandingConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["media-understanding/**/*.test.ts"]); }); it("keeps tooling tests in their own lane", () => { - expect(defaultToolingConfig.test?.include).toEqual( + const testConfig = requireTestConfig(defaultToolingConfig); + expect(testConfig.include).toEqual( expect.arrayContaining(["test/**/*.test.ts", "src/scripts/**/*.test.ts"]), ); - expect(defaultToolingConfig.test?.include).not.toContain( - "src/config/doc-baseline.integration.test.ts", - ); + expect(testConfig.include).not.toContain("src/config/doc-baseline.integration.test.ts"); }); it("normalizes acp include patterns relative to the scoped dir", () => { - expect(defaultAcpConfig.test?.dir).toBe(path.join(process.cwd(), "src", "acp")); - expect(defaultAcpConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultAcpConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "acp")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes cli include patterns relative to the scoped dir", () => { - expect(defaultCliConfig.test?.dir).toBe(path.join(process.cwd(), "src", "cli")); - expect(defaultCliConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultCliConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "cli")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes commands include patterns relative to the scoped dir", () => { - expect(defaultCommandsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "commands")); - expect(defaultCommandsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultCommandsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "commands")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes auto-reply include patterns relative to the scoped dir", () => { - expect(defaultAutoReplyConfig.test?.dir).toBe(path.join(process.cwd(), "src", "auto-reply")); - expect(defaultAutoReplyConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultAutoReplyConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "auto-reply")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes agents include patterns relative to the scoped dir", () => { - expect(defaultAgentsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "agents")); - expect(defaultAgentsConfig.test?.include).toEqual(["**/*.test.ts"]); + const testConfig = requireTestConfig(defaultAgentsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "agents")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); }); it("normalizes plugins include patterns relative to the scoped dir", () => { - expect(defaultPluginsConfig.test?.dir).toBe(path.join(process.cwd(), "src", "plugins")); - expect(defaultPluginsConfig.test?.include).toEqual(["**/*.test.ts"]); - expect(defaultPluginsConfig.test?.exclude).toContain("contracts/**"); + const testConfig = requireTestConfig(defaultPluginsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src", "plugins")); + expect(testConfig.include).toEqual(["**/*.test.ts"]); + expect(testConfig.exclude).toContain("contracts/**"); }); it("normalizes ui include patterns relative to the scoped dir", () => { - expect(defaultUiConfig.test?.dir).toBe(process.cwd()); - expect(defaultUiConfig.test?.include).toEqual(["ui/src/**/*.test.ts"]); - expect(defaultUiConfig.test?.exclude).toContain("ui/src/ui/app-chat.test.ts"); + const testConfig = requireTestConfig(defaultUiConfig); + expect(testConfig.dir).toBe(process.cwd()); + expect(testConfig.include).toEqual(["ui/src/**/*.test.ts"]); + expect(testConfig.exclude).toContain("ui/src/ui/app-chat.test.ts"); }); it("normalizes utils include patterns relative to the scoped dir", () => { - expect(defaultUtilsConfig.test?.dir).toBe(path.join(process.cwd(), "src")); - expect(defaultUtilsConfig.test?.include).toEqual(["utils/**/*.test.ts"]); - expect(normalizeConfigPaths(defaultUtilsConfig.test?.setupFiles)).toEqual(["test/setup.ts"]); + const testConfig = requireTestConfig(defaultUtilsConfig); + expect(testConfig.dir).toBe(path.join(process.cwd(), "src")); + expect(testConfig.include).toEqual(["utils/**/*.test.ts"]); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual(["test/setup.ts"]); }); }); diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index 0051a3f3312..e4f6934bae3 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -2,22 +2,34 @@ import { describe, expect, it } from "vitest"; import uiConfig from "../ui/vitest.config.ts"; import uiNodeConfig from "../ui/vitest.node.config.ts"; +function requireTestConfig(config: T): NonNullable { + if (!config.test) { + throw new Error("expected ui package vitest test config"); + } + return config.test as NonNullable; +} + describe("ui package vitest config", () => { it("keeps the standalone ui package on thread workers without isolation", () => { - expect(uiConfig.test?.pool).toBe("threads"); - expect(uiConfig.test?.isolate).toBe(false); - expect(uiConfig.test?.projects).toHaveLength(3); + const testConfig = requireTestConfig(uiConfig); - for (const project of uiConfig.test?.projects ?? []) { - expect(project.test?.pool).toBe("threads"); - expect(project.test?.isolate).toBe(false); - expect(project.test?.runner).toBeUndefined(); + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(testConfig.projects).toHaveLength(3); + + for (const project of testConfig.projects) { + const projectTestConfig = requireTestConfig(project); + expect(projectTestConfig.pool).toBe("threads"); + expect(projectTestConfig.isolate).toBe(false); + expect(projectTestConfig.runner).toBeUndefined(); } }); it("keeps the standalone ui node config on thread workers without isolation", () => { - expect(uiNodeConfig.test?.pool).toBe("threads"); - expect(uiNodeConfig.test?.isolate).toBe(false); - expect(uiNodeConfig.test?.runner).toBeUndefined(); + const testConfig = requireTestConfig(uiNodeConfig); + + expect(testConfig.pool).toBe("threads"); + expect(testConfig.isolate).toBe(false); + expect(testConfig.runner).toBeUndefined(); }); }); diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts index 17c137bed76..0849d698ecb 100644 --- a/test/vitest-unit-config.test.ts +++ b/test/vitest-unit-config.test.ts @@ -11,6 +11,13 @@ import { const patternFiles = createPatternFileHelper("openclaw-vitest-unit-config-"); +function requireTestConfig(config: T): NonNullable { + if (!config.test) { + throw new Error("expected unit vitest test config"); + } + return config.test as NonNullable; +} + afterEach(() => { patternFiles.cleanup(); }); @@ -72,14 +79,16 @@ describe("loadExtraExcludePatternsFromEnv", () => { describe("unit vitest config", () => { it("defaults unit tests to the non-isolated runner", () => { const unitConfig = createUnitVitestConfig({}); - expect(unitConfig.test?.isolate).toBe(false); - expect(normalizeConfigPath(unitConfig.test?.runner)).toBe("test/non-isolated-runner.ts"); + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.isolate).toBe(false); + expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); }); it("keeps acp and ui tests out of the generic unit lane", () => { const unitConfig = createUnitVitestConfig({}); - expect(unitConfig.test?.exclude).toEqual(expect.arrayContaining(["extensions/**", "test/**"])); - expect(unitConfig.test?.include).not.toEqual( + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.exclude).toEqual(expect.arrayContaining(["extensions/**", "test/**"])); + expect(testConfig.include).not.toEqual( expect.arrayContaining([ "ui/src/ui/app-chat.test.ts", "ui/src/ui/chat/**/*.test.ts", @@ -95,13 +104,15 @@ describe("unit vitest config", () => { argv: ["node", "vitest", "run", "src/config/channel-configured.test.ts"], }, ); - expect(unitConfig.test?.include).toEqual(["src/config/channel-configured.test.ts"]); - expect(unitConfig.test?.passWithNoTests).toBe(true); + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.include).toEqual(["src/config/channel-configured.test.ts"]); + expect(testConfig.passWithNoTests).toBe(true); }); it("adds the OpenClaw runtime setup hooks on top of the base setup", () => { const unitConfig = createUnitVitestConfig({}); - expect(normalizeConfigPaths(unitConfig.test?.setupFiles)).toEqual([ + const testConfig = requireTestConfig(unitConfig); + expect(normalizeConfigPaths(testConfig.setupFiles)).toEqual([ "test/setup.ts", "test/setup-openclaw-runtime.ts", ]); @@ -114,21 +125,24 @@ describe("unit vitest config", () => { extraExcludePatterns: ["src/security/**"], }, ); - expect(unitConfig.test?.exclude).toEqual( + const testConfig = requireTestConfig(unitConfig); + expect(testConfig.exclude).toEqual( expect.arrayContaining(["src/commands/**", "src/config/**", "src/security/**"]), ); }); it("scopes default coverage to source files owned by the unit lane", () => { const unitConfig = createUnitVitestConfig({}); - expect(unitConfig.test?.coverage?.include).toEqual( + const testConfig = requireTestConfig(unitConfig); + const coverageInclude = testConfig.coverage?.include; + expect(coverageInclude).toEqual( expect.arrayContaining([ "src/commitments/runtime.ts", "src/media-generation/runtime-shared.ts", "src/web-search/runtime.ts", ]), ); - expect(unitConfig.test?.coverage?.include).not.toEqual( + expect(coverageInclude).not.toEqual( expect.arrayContaining(["src/markdown/render.ts", "src/security/audit-workspace-skills.ts"]), ); }); @@ -150,8 +164,9 @@ describe("unit vitest config", () => { includePatterns: ["src/commitments/runtime.test.ts"], }, ); + const testConfig = requireTestConfig(unitConfig); - expect(unitConfig.test?.coverage?.include).toBeUndefined(); + expect(testConfig.coverage?.include).toBeUndefined(); }); it("keeps bundled unit include files out of the resolved exclude list", () => { @@ -165,13 +180,14 @@ describe("unit vitest config", () => { ], }, ); + const testConfig = requireTestConfig(unitConfig); - expect(unitConfig.test?.include).toEqual([ + expect(testConfig.include).toEqual([ "src/infra/matrix-plugin-helper.test.ts", "src/plugin-sdk/facade-runtime.test.ts", "src/plugins/loader.test.ts", ]); - expect(unitConfig.test?.exclude).not.toEqual( + expect(testConfig.exclude).not.toEqual( expect.arrayContaining([ "src/infra/**", "src/plugin-sdk/**", diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 481c9a0f09b..ac4d914ee3a 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -13,49 +13,81 @@ import { } from "./vitest/vitest.unit-fast-paths.mjs"; import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; +function requireTestConfig(config: T): NonNullable { + if (!config.test) { + throw new Error("expected unit-fast vitest test config"); + } + return config.test as NonNullable; +} + +function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { + let count = 0; + for (const item of items) { + if (predicate(item)) { + count += 1; + } + } + return count; +} + +type UnitFastAnalysisEntry = ReturnType[number]; + +function collectUnroutedForcedFiles( + analysis: readonly UnitFastAnalysisEntry[], + forcedFiles: ReadonlySet, +): Array<{ file: string; forced: boolean; unitFast: boolean }> { + const unrouted: Array<{ file: string; forced: boolean; unitFast: boolean }> = []; + for (const entry of analysis) { + if (!forcedFiles.has(entry.file)) { + continue; + } + if (!entry.forced || !entry.unitFast) { + unrouted.push({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast }); + } + } + return unrouted; +} + describe("unit-fast vitest lane", () => { it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => { const config = createUnitFastVitestConfig({}); + const testConfig = requireTestConfig(config); - expect(config.test?.isolate).toBe(false); - expect(config.test?.runner).toBeUndefined(); - expect(config.test?.setupFiles).toEqual([]); - expect(config.test?.include).toContain( - "src/agents/pi-tools.deferred-followup-guidance.test.ts", - ); - expect(config.test?.include).toContain("src/acp/control-plane/runtime-cache.test.ts"); - expect(config.test?.include).toContain("src/acp/runtime/registry.test.ts"); - expect(config.test?.include).toContain("src/commands/status-overview-values.test.ts"); - expect(config.test?.include).toContain("src/entry.respawn.test.ts"); - expect(config.test?.include).toContain("src/entry.version-fast-path.test.ts"); - expect(config.test?.include).toContain("src/flows/doctor-startup-channel-maintenance.test.ts"); - expect(config.test?.include).toContain("src/crestodian/rescue-policy.test.ts"); - expect(config.test?.include).toContain("src/crestodian/assistant.configured.test.ts"); - expect(config.test?.include).toContain("src/flows/search-setup.test.ts"); - expect(config.test?.include).toContain("src/memory-host-sdk/host/backend-config.test.ts"); - expect(config.test?.include).toContain("src/plugins/config-policy.test.ts"); - expect(config.test?.include).toContain("src/proxy-capture/proxy-server.test.ts"); - expect(config.test?.include).toContain("src/talk/agent-consult-tool.test.ts"); - expect(config.test?.include).toContain("src/sessions/session-lifecycle-events.test.ts"); - expect(config.test?.include).toContain("src/sessions/transcript-events.test.ts"); - expect(config.test?.include).toContain( - "src/security/audit-channel-source-config-slack.test.ts", - ); - expect(config.test?.include).toContain("src/security/audit-config-symlink.test.ts"); - expect(config.test?.include).toContain("src/security/audit-exec-sandbox-host.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway-auth-selection.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway-http-auth.test.ts"); - expect(config.test?.include).toContain("src/security/audit-gateway-tools-http.test.ts"); - expect(config.test?.include).toContain("src/security/audit-plugin-readonly-scope.test.ts"); - expect(config.test?.include).toContain("src/security/audit-loopback-logging.test.ts"); - expect(config.test?.include).toContain("src/security/audit-sandbox-browser.test.ts"); - expect(config.test?.include).toContain("src/ui-app-settings.agents-files-refresh.test.ts"); - expect(config.test?.include).toContain("src/video-generation/provider-registry.test.ts"); - expect(config.test?.include).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(config.test?.include).toContain("src/security/dangerous-config-flags.test.ts"); - expect(config.test?.include).toContain("src/security/context-visibility.test.ts"); - expect(config.test?.include).toContain("src/security/safe-regex.test.ts"); + expect(testConfig.isolate).toBe(false); + expect(testConfig.runner).toBeUndefined(); + expect(testConfig.setupFiles).toEqual([]); + expect(testConfig.include).toContain("src/agents/pi-tools.deferred-followup-guidance.test.ts"); + expect(testConfig.include).toContain("src/acp/control-plane/runtime-cache.test.ts"); + expect(testConfig.include).toContain("src/acp/runtime/registry.test.ts"); + expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts"); + expect(testConfig.include).toContain("src/entry.respawn.test.ts"); + expect(testConfig.include).toContain("src/entry.version-fast-path.test.ts"); + expect(testConfig.include).toContain("src/flows/doctor-startup-channel-maintenance.test.ts"); + expect(testConfig.include).toContain("src/crestodian/rescue-policy.test.ts"); + expect(testConfig.include).toContain("src/crestodian/assistant.configured.test.ts"); + expect(testConfig.include).toContain("src/flows/search-setup.test.ts"); + expect(testConfig.include).toContain("src/memory-host-sdk/host/backend-config.test.ts"); + expect(testConfig.include).toContain("src/plugins/config-policy.test.ts"); + expect(testConfig.include).toContain("src/proxy-capture/proxy-server.test.ts"); + expect(testConfig.include).toContain("src/talk/agent-consult-tool.test.ts"); + expect(testConfig.include).toContain("src/sessions/session-lifecycle-events.test.ts"); + expect(testConfig.include).toContain("src/sessions/transcript-events.test.ts"); + expect(testConfig.include).toContain("src/security/audit-channel-source-config-slack.test.ts"); + expect(testConfig.include).toContain("src/security/audit-config-symlink.test.ts"); + expect(testConfig.include).toContain("src/security/audit-exec-sandbox-host.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway-auth-selection.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway-http-auth.test.ts"); + expect(testConfig.include).toContain("src/security/audit-gateway-tools-http.test.ts"); + expect(testConfig.include).toContain("src/security/audit-plugin-readonly-scope.test.ts"); + expect(testConfig.include).toContain("src/security/audit-loopback-logging.test.ts"); + expect(testConfig.include).toContain("src/security/audit-sandbox-browser.test.ts"); + expect(testConfig.include).toContain("src/ui-app-settings.agents-files-refresh.test.ts"); + expect(testConfig.include).toContain("src/video-generation/provider-registry.test.ts"); + expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts"); + expect(testConfig.include).toContain("src/security/dangerous-config-flags.test.ts"); + expect(testConfig.include).toContain("src/security/context-visibility.test.ts"); + expect(testConfig.include).toContain("src/security/safe-regex.test.ts"); }); it("does not treat moved config paths as CLI include filters", () => { @@ -66,8 +98,9 @@ describe("unit-fast vitest lane", () => { }, ); - expect(config.test?.include).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(config.test?.include).toContain("src/commands/status-overview-values.test.ts"); + const testConfig = requireTestConfig(config); + expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts"); + expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts"); }); it("keeps obvious stateful files out of the unit-fast lane", () => { @@ -95,17 +128,16 @@ describe("unit-fast vitest lane", () => { it("routes audited stateful-looking tests through the fast lane", () => { const analysis = collectUnitFastTestFileAnalysis(); - const forcedAnalysis = analysis.filter((entry) => forcedUnitFastTestFiles.includes(entry.file)); + const forcedFileSet = new Set(forcedUnitFastTestFiles); + const forcedAnalysisCount = countMatching(analysis, (entry) => forcedFileSet.has(entry.file)); const unitFastTestFiles = getUnitFastTestFiles(); - expect(forcedAnalysis).toHaveLength(forcedUnitFastTestFiles.length); + expect(forcedAnalysisCount).toBe(forcedUnitFastTestFiles.length); for (const file of forcedUnitFastTestFiles) { expect(unitFastTestFiles).toContain(file); expect(isUnitFastTestFile(file)).toBe(true); } - const unroutedForcedFiles = forcedAnalysis - .filter((entry) => !entry.forced || !entry.unitFast) - .map((entry) => ({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast })); + const unroutedForcedFiles = collectUnroutedForcedFiles(analysis, forcedFileSet); expect(unroutedForcedFiles).toEqual([]); }); @@ -117,7 +149,7 @@ describe("unit-fast vitest lane", () => { expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length); expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length); - expect(broadAnalysis.filter((entry) => entry.unitFast).length).toBeGreaterThan( + expect(countMatching(broadAnalysis, (entry) => entry.unitFast)).toBeGreaterThan( unitFastTestFiles.length, ); }); @@ -128,7 +160,9 @@ describe("unit-fast vitest lane", () => { const unitFastTestFiles = getUnitFastTestFiles(); expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(pluginSdkLight.test?.exclude).toContain("plugin-sdk/provider-entry.test.ts"); - expect(commandsLight.test?.exclude).toContain("status-overview-values.test.ts"); + expect(requireTestConfig(pluginSdkLight).exclude).toContain( + "plugin-sdk/provider-entry.test.ts", + ); + expect(requireTestConfig(commandsLight).exclude).toContain("status-overview-values.test.ts"); }); }); diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index a8b73e53781..d57acacbf80 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -143,8 +143,12 @@ describe("i18n", () => { it("keeps the version label available in shipped locales", () => { for (const [locale, value] of Object.entries(shippedLocales)) { - expect((value.common as { version?: string }).version, locale).toEqual(expect.any(String)); - expect((value.common as { version?: string }).version?.trim(), locale).not.toBe(""); + const version = (value.common as { version?: unknown }).version; + expect(version, locale).toBeTypeOf("string"); + if (typeof version !== "string") { + throw new Error(`expected ${locale} common.version to be a string`); + } + expect(version.trim(), locale).not.toBe(""); } }); diff --git a/ui/src/ui/app-channels.test.ts b/ui/src/ui/app-channels.test.ts index 16847e97d27..863b6c83b7d 100644 --- a/ui/src/ui/app-channels.test.ts +++ b/ui/src/ui/app-channels.test.ts @@ -31,6 +31,15 @@ function createChannelsSnapshot(name = "saved"): ChannelsStatusSnapshot { }; } +function requireConfigSnapshot( + host: ChannelsActionHostForTest, +): NonNullable { + if (!host.configSnapshot) { + throw new Error("expected config snapshot"); + } + return host.configSnapshot; +} + function createHost(request: ReturnType = vi.fn()): ChannelsActionHostForTest { return { applySessionKey: "main", @@ -133,7 +142,7 @@ describe("channel config actions", () => { expect(host.lastError).toContain("Config hash mismatch"); expect(host.configFormDirty).toBe(true); expect(host.configForm).toEqual({ gateway: { mode: "local" } }); - expect(host.configSnapshot?.config).toEqual({ gateway: { mode: "remote" } }); + expect(requireConfigSnapshot(host).config).toEqual({ gateway: { mode: "remote" } }); expect(request.mock.calls.map(([method]) => method)).not.toContain("channels.status"); }); }); diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 369495d7e4a..6c6459d1b05 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -133,12 +133,15 @@ function row(key: string, overrides?: Partial): GatewaySessio } function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } diff --git a/ui/src/ui/app-lifecycle-connect.node.test.ts b/ui/src/ui/app-lifecycle-connect.node.test.ts index c49adc81a5b..e77f56f5d47 100644 --- a/ui/src/ui/app-lifecycle-connect.node.test.ts +++ b/ui/src/ui/app-lifecycle-connect.node.test.ts @@ -41,6 +41,17 @@ vi.mock("./app-scroll.ts", () => ({ import { handleConnected } from "./app-lifecycle.ts"; +function createDeferred() { + let resolve: (() => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (!resolve) { + throw new Error("Expected bootstrap deferred resolver to be initialized"); + } + return { promise, resolve }; +} + function createHost() { return { basePath: "", @@ -77,30 +88,22 @@ describe("handleConnected", () => { }); it("waits for bootstrap load before first gateway connect", async () => { - let resolveBootstrap!: () => void; - loadBootstrapMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveBootstrap = resolve; - }), - ); + const bootstrap = createDeferred(); + loadBootstrapMock.mockReturnValueOnce(bootstrap.promise); connectGatewayMock.mockReset(); const host = createHost(); handleConnected(host as never); expect(connectGatewayMock).not.toHaveBeenCalled(); - resolveBootstrap(); + bootstrap.resolve(); await Promise.resolve(); expect(connectGatewayMock).toHaveBeenCalledTimes(1); }); it("skips deferred connect when disconnected before bootstrap resolves", async () => { - let resolveBootstrap!: () => void; - loadBootstrapMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveBootstrap = resolve; - }), - ); + const bootstrap = createDeferred(); + loadBootstrapMock.mockReturnValueOnce(bootstrap.promise); connectGatewayMock.mockReset(); const host = createHost(); @@ -108,7 +111,7 @@ describe("handleConnected", () => { expect(connectGatewayMock).not.toHaveBeenCalled(); host.connectGeneration += 1; - resolveBootstrap(); + bootstrap.resolve(); await Promise.resolve(); expect(connectGatewayMock).not.toHaveBeenCalled(); diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index f23f80dfda8..463e333f9ba 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -254,7 +254,7 @@ describe("renderApp assistant avatar routing", () => { expect(quickSettingsProps.current?.security.execPolicy).toBe("full"); }); - it("does not throw when stale cron state contains a job without a payload", () => { + it("renders stale cron state containing a job without a payload", () => { expect(() => renderApp( createState({ diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index fb0c1b981ba..2f8da37f2ef 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -63,8 +63,30 @@ function renderRefreshButton(overrides: Partial = {}) { const button = container.querySelector( `.chat-controls .btn--icon[data-tooltip="${t("chat.refreshTitle")}"]`, ); - expect(button).not.toBeNull(); - return button!; + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected chat refresh button"); + } + return button; +} + +function requireButton( + button: HTMLButtonElement | null | undefined, + label: string, +): HTMLButtonElement { + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${label} button`); + } + return button; +} + +function requireElement(element: T | null | undefined, label: string): T { + expect(element).toBeInstanceOf(Element); + if (!element) { + throw new Error(`Expected ${label} element`); + } + return element; } describe("chat header controls (browser)", () => { @@ -140,14 +162,12 @@ describe("chat header controls (browser)", () => { ); expect(buttons).toHaveLength(4); - const cronButton = buttons.at(-1); - expect(cronButton?.classList.contains("active")).toBe(true); - expect(cronButton?.getAttribute("aria-pressed")).toBe("true"); - expect(cronButton?.getAttribute("title")).toBe( - t("chat.showCronSessionsHidden", { count: "1" }), - ); + const cronButton = requireButton(buttons.at(-1), "cron sessions"); + expect(cronButton.classList.contains("active")).toBe(true); + expect(cronButton.getAttribute("aria-pressed")).toBe("true"); + expect(cronButton.getAttribute("title")).toBe(t("chat.showCronSessionsHidden", { count: "1" })); - cronButton?.click(); + cronButton.click(); expect(state.sessionsHideCron).toBe(false); }); @@ -182,10 +202,14 @@ describe("chat header controls (browser)", () => { const sessionRows = container.querySelectorAll(".chat-controls__session-row"); expect(sessionRows).toHaveLength(1); - expect(container.querySelector('select[data-chat-agent-filter="true"]')).not.toBeNull(); - expect(container.querySelector('select[data-chat-session-select="true"]')).not.toBeNull(); - expect(container.querySelector('select[data-chat-model-select="true"]')).not.toBeNull(); - expect(container.querySelector('select[data-chat-thinking-select="true"]')).not.toBeNull(); + expect( + Array.from(container.querySelectorAll("select")).map((select) => select.dataset), + ).toEqual([ + expect.objectContaining({ chatAgentFilter: "true" }), + expect.objectContaining({ chatSessionSelect: "true" }), + expect.objectContaining({ chatModelSelect: "true" }), + expect.objectContaining({ chatThinkingSelect: "true" }), + ]); }); it("renders the mobile dropdown from state instead of mutating DOM classes", async () => { @@ -198,19 +222,23 @@ describe("chat header controls (browser)", () => { render(renderChatMobileToggle(state), container); await Promise.resolve(); - const toggle = container.querySelector(".chat-controls-mobile-toggle"); - const dropdown = container.querySelector(".chat-controls-dropdown"); - expect(toggle).not.toBeNull(); - expect(dropdown).not.toBeNull(); - expect(toggle?.getAttribute("aria-expanded")).toBe("false"); - expect(toggle?.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown"); - expect(dropdown?.id).toBe("chat-mobile-controls-dropdown"); - expect(dropdown?.classList.contains("open")).toBe(false); + const toggle = requireButton( + container.querySelector(".chat-controls-mobile-toggle"), + "mobile controls toggle", + ); + const dropdown = requireElement( + container.querySelector(".chat-controls-dropdown"), + "mobile controls dropdown", + ); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(toggle.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown"); + expect(dropdown.id).toBe("chat-mobile-controls-dropdown"); + expect(dropdown.classList.contains("open")).toBe(false); - toggle?.click(); + toggle.click(); expect(setChatMobileControlsOpen).toHaveBeenCalledWith(true, { trigger: toggle }); - expect(dropdown?.classList.contains("open")).toBe(false); + expect(dropdown.classList.contains("open")).toBe(false); render( renderChatMobileToggle( @@ -223,9 +251,15 @@ describe("chat header controls (browser)", () => { ); await Promise.resolve(); - const openToggle = container.querySelector(".chat-controls-mobile-toggle"); - const openDropdown = container.querySelector(".chat-controls-dropdown"); - expect(openToggle?.getAttribute("aria-expanded")).toBe("true"); - expect(openDropdown?.classList.contains("open")).toBe(true); + const openToggle = requireButton( + container.querySelector(".chat-controls-mobile-toggle"), + "open mobile controls toggle", + ); + const openDropdown = requireElement( + container.querySelector(".chat-controls-dropdown"), + "open mobile controls dropdown", + ); + expect(openToggle.getAttribute("aria-expanded")).toBe("true"); + expect(openDropdown.classList.contains("open")).toBe(true); }); }); diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 8a04237c500..89efb30e61c 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -661,7 +661,7 @@ describe("handleChatManualRefresh", () => { }), }); try { - let resolveRefresh!: () => void; + let resolveRefresh: (() => void) | undefined; refreshChatMock.mockReturnValueOnce( new Promise((resolve) => { resolveRefresh = resolve; @@ -679,6 +679,9 @@ describe("handleChatManualRefresh", () => { await Promise.resolve(); expect(state.scrollToBottom).not.toHaveBeenCalled(); + if (!resolveRefresh) { + throw new Error("Expected chat refresh resolver to be initialized"); + } resolveRefresh(); await run; diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index 8038adb5af2..d05829b9a3a 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -3,6 +3,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; type CronRunsLoadStatus = "ok" | "error" | "skipped"; +function createDeferred() { + let resolve: ((value: T | PromiseLike) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } + return { promise, resolve }; +} + const mocks = vi.hoisted(() => ({ refreshChatMock: vi.fn(async () => {}), scheduleChatScrollMock: vi.fn(), @@ -196,12 +207,8 @@ describe("refreshActiveTab", () => { it("records tab visible timing without waiting for the tab refresh RPC", async () => { const host = createHost(); host.tab = "chat"; - let resolveSessions!: () => void; - mocks.loadSessionsMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveSessions = resolve; - }), - ); + const sessions = createDeferred(); + mocks.loadSessionsMock.mockReturnValueOnce(sessions.promise); setTab(host as never, "sessions"); @@ -221,7 +228,7 @@ describe("refreshActiveTab", () => { ); }); - resolveSessions(); + sessions.resolve(); }); it("does not wait for secondary overview refreshes before resolving", async () => { @@ -244,12 +251,8 @@ describe("refreshActiveTab", () => { it("does not wait for config schema before resolving config tab refresh", async () => { const host = createHost(); host.tab = "config"; - let resolveSchema!: () => void; - mocks.loadConfigSchemaMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveSchema = resolve; - }), - ); + const schema = createDeferred(); + mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); const refresh = refreshActiveTab(host as never); const outcome = await Promise.race([ @@ -262,7 +265,7 @@ describe("refreshActiveTab", () => { expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); expect(host.requestUpdate).not.toHaveBeenCalled(); - resolveSchema(); + schema.resolve(); await vi.waitFor(() => { expect(host.requestUpdate).toHaveBeenCalledOnce(); @@ -272,18 +275,12 @@ describe("refreshActiveTab", () => { it("renders channels from the cheap snapshot before starting slow probes", async () => { const host = createHost(); host.tab = "channels"; - let resolveSchema!: () => void; - let resolveProbe!: () => void; - mocks.loadConfigSchemaMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveSchema = resolve; - }), - ); + const schema = createDeferred(); + const channelProbe = createDeferred(); + mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); mocks.loadChannelsMock.mockImplementation(async (_host, probe) => { if (probe) { - await new Promise((resolve) => { - resolveProbe = resolve; - }); + await channelProbe.promise; } }); @@ -298,8 +295,8 @@ describe("refreshActiveTab", () => { expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); expect(host.requestUpdate).not.toHaveBeenCalled(); - resolveSchema(); - resolveProbe(); + schema.resolve(); + channelProbe.resolve(); await vi.waitFor(() => { expect(host.requestUpdate).toHaveBeenCalledTimes(2); @@ -309,16 +306,12 @@ describe("refreshActiveTab", () => { it("records overview secondary refresh duration and aggregate status", async () => { const host = createHost(); host.tab = "overview"; - let resolveUsage!: () => void; - mocks.loadUsageMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveUsage = resolve; - }), - ); + const usage = createDeferred(); + mocks.loadUsageMock.mockReturnValueOnce(usage.promise); mocks.loadSkillsMock.mockRejectedValueOnce(new Error("skills failed")); await refreshActiveTab(host as never); - resolveUsage(); + usage.resolve(); await vi.waitFor(() => { expect(host.eventLogBuffer).toEqual( @@ -401,16 +394,12 @@ describe("refreshActiveTab", () => { it("does not record stale cron run timing after leaving the cron tab", async () => { const host = createHost(); host.tab = "cron"; - let resolveRuns!: () => void; - mocks.loadCronRunsMock.mockReturnValueOnce( - new Promise<"ok">((resolve) => { - resolveRuns = () => resolve("ok"); - }), - ); + const runs = createDeferred<"ok">(); + mocks.loadCronRunsMock.mockReturnValueOnce(runs.promise); await refreshActiveTab(host as never); host.tab = "chat"; - resolveRuns(); + runs.resolve("ok"); await Promise.resolve(); expect(host.eventLogBuffer).not.toEqual( diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 4dc2a67b408..acb9d91d012 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -256,8 +256,8 @@ describe("setTabFromRoute", () => { const host = createHost("chat"); setTabFromRoute(host, "logs"); - expect(host.logsPollInterval).not.toBeNull(); expect(host.debugPollInterval).toBeNull(); + expect(host.logsPollInterval).not.toBe(host.debugPollInterval); setTabFromRoute(host, "chat"); expect(host.logsPollInterval).toBeNull(); @@ -267,8 +267,8 @@ describe("setTabFromRoute", () => { const host = createHost("chat"); setTabFromRoute(host, "debug"); - expect(host.debugPollInterval).not.toBeNull(); expect(host.logsPollInterval).toBeNull(); + expect(host.debugPollInterval).not.toBe(host.logsPollInterval); setTabFromRoute(host, "chat"); expect(host.debugPollInterval).toBeNull(); diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index 6784d8a63e3..a6e9fe30f7b 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { handleAgentEvent, type FallbackStatus, type ToolStreamEntry } from "./app-tool-stream.ts"; type ToolStreamHost = Parameters[0]; @@ -54,13 +54,28 @@ function expectCompactionCompleteAndAutoClears(host: MutableHost) { startedAt: expect.any(Number), completedAt: expect.any(Number), }); - expect(host.compactionClearTimer).not.toBeNull(); + expect(host.compactionClearTimer).toMatchObject({ + hasRef: expect.any(Function), + ref: expect.any(Function), + unref: expect.any(Function), + }); vi.advanceTimersByTime(5_000); expect(host.compactionStatus).toBeNull(); expect(host.compactionClearTimer).toBeNull(); } +function requireFallbackStatus(host: MutableHost): FallbackStatus { + if (!host.fallbackStatus) { + throw new Error("expected fallback status"); + } + return host.fallbackStatus; +} + +function useToolStreamFakeTimers(): void { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); +} + describe("app-tool-stream fallback lifecycle handling", () => { beforeAll(() => { const globalWithWindow = globalThis as typeof globalThis & { @@ -71,8 +86,16 @@ describe("app-tool-stream fallback lifecycle handling", () => { } }); + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("accepts session-scoped fallback lifecycle events when no run is active", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -91,16 +114,15 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.fallbackStatus?.selected).toBe( - "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", - ); - expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5"); - expect(host.fallbackStatus?.reason).toBe("rate limit"); + const fallbackStatus = requireFallbackStatus(host); + expect(fallbackStatus.selected).toBe("fireworks/accounts/fireworks/routers/kimi-k2p5-turbo"); + expect(fallbackStatus.active).toBe("deepinfra/moonshotai/Kimi-K2.5"); + expect(fallbackStatus.reason).toBe("rate limit"); vi.useRealTimers(); }); it("rejects idle fallback lifecycle events for other sessions", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -123,7 +145,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("auto-clears fallback status after toast duration", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -141,16 +163,24 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.fallbackStatus).not.toBeNull(); + expect(host.fallbackStatus).toMatchObject({ + phase: "active", + selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + active: "deepinfra/moonshotai/Kimi-K2.5", + }); vi.advanceTimersByTime(7_999); - expect(host.fallbackStatus).not.toBeNull(); + expect(host.fallbackStatus).toMatchObject({ + phase: "active", + selected: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + active: "deepinfra/moonshotai/Kimi-K2.5", + }); vi.advanceTimersByTime(1); expect(host.fallbackStatus).toBeNull(); vi.useRealTimers(); }); it("builds previous fallback label from provider + model on fallback_cleared", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, { @@ -170,13 +200,14 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.fallbackStatus?.phase).toBe("cleared"); - expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5"); + const fallbackStatus = requireFallbackStatus(host); + expect(fallbackStatus.phase).toBe("cleared"); + expect(fallbackStatus.previous).toBe("deepinfra/moonshotai/Kimi-K2.5"); vi.useRealTimers(); }); it("keeps compaction in retry-pending state until the matching lifecycle end", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" })); @@ -222,7 +253,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("treats lifecycle error as terminal for retry-pending compaction", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" })); @@ -251,7 +282,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); it("does not surface retrying or complete when retry compaction failed", () => { - vi.useFakeTimers(); + useToolStreamFakeTimers(); const host = createHost(); handleAgentEvent(host, agentEvent("run-1", 1, "compaction", { phase: "start" })); diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts index ac0750d198c..e1811b4c878 100644 --- a/ui/src/ui/app.talk.test.ts +++ b/ui/src/ui/app.talk.test.ts @@ -59,6 +59,9 @@ describe("OpenClawApp Talk controls", () => { expect(startMock).toHaveBeenCalledOnce(); expect(stopMock).not.toHaveBeenCalled(); expect(app.realtimeTalkStatus).toBe("connecting"); - expect(app.realtimeTalkSession).not.toBeNull(); + expect(app.realtimeTalkSession).toMatchObject({ + start: startMock, + stop: stopMock, + }); }); }); diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index 6e818f76bef..956e2890e6f 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import type { MessageGroup } from "../types/chat-types.ts"; import { buildChatItems, type BuildChatItemsProps } from "./build-chat-items.ts"; +const SENDER_METADATA_BLOCK = + 'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui","id":"openclaw-control-ui"}\n```'; + function createProps(overrides: Partial = {}): BuildChatItemsProps { return { sessionKey: "main", @@ -151,6 +154,50 @@ describe("buildChatItems", () => { expect(items).toEqual([]); }); + it("suppresses active sender metadata streams before rendering", () => { + const items = buildChatItems( + createProps({ + stream: SENDER_METADATA_BLOCK, + streamStartedAt: 1, + }), + ); + + expect(items).toEqual([]); + }); + + it("strips sender metadata from active stream text that has visible content", () => { + const items = buildChatItems( + createProps({ + stream: `${SENDER_METADATA_BLOCK}\n\nVisible reply`, + streamStartedAt: 1, + }), + ); + + expect(items).toEqual([ + { + kind: "stream", + key: "stream:main:1", + text: "Visible reply", + startedAt: 1, + }, + ]); + }); + + it("suppresses metadata-only history messages before grouping", () => { + const groups = messageGroups({ + messages: [ + { + role: "user", + content: SENDER_METADATA_BLOCK, + senderLabel: "openclaw-control-ui", + timestamp: 1, + }, + ], + }); + + expect(groups).toEqual([]); + }); + it("renders only the last 100 history messages and shows a hidden-count notice", () => { const items = buildChatItems( createProps({ @@ -264,6 +311,45 @@ describe("buildChatItems", () => { expect(canvasBlocksIn(groups[1])).toEqual([]); }); + it("preserves a metadata-only assistant anchor when lifting canvas previews", () => { + const groups = messageGroups({ + messages: [ + { + id: "assistant-metadata-anchor", + role: "assistant", + content: SENDER_METADATA_BLOCK, + timestamp: 1_000, + }, + ], + toolMessages: [ + { + id: "tool-canvas-for-empty-anchor", + role: "tool", + toolCallId: "call-canvas-empty-anchor", + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: "cv_empty_anchor", + url: "/__openclaw__/canvas/documents/cv_empty_anchor/index.html", + title: "Empty anchor demo", + preferred_height: 320, + }, + presentation: { + target: "assistant_message", + }, + }), + timestamp: 1_001, + }, + ], + }); + + expect( + groups.some((group) => firstMessageContent(group).some((block) => isCanvasBlock(block))), + ).toBe(true); + }); + it("does not lift generic view handles from non-canvas payloads", () => { const groups = messageGroups({ messages: [ diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts index 3720b7ba946..c05c24632b6 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/ui/chat/build-chat-items.ts @@ -5,7 +5,7 @@ import { } from "./heartbeat-display.ts"; import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts"; import { extractTextCached } from "./message-extract.ts"; -import { normalizeMessage } from "./message-normalizer.ts"; +import { normalizeMessage, stripMessageDisplayMetadataText } from "./message-normalizer.ts"; import { normalizeRoleForGrouping } from "./role-normalizer.ts"; import { messageMatchesSearchQuery } from "./search-match.ts"; import { extractToolCards, extractToolPreview } from "./tool-cards.ts"; @@ -249,12 +249,29 @@ function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] { return collapsed; } +function hasRenderableNormalizedMessage(message: unknown): boolean { + const normalized = normalizeMessage(message); + return normalized.content.length > 0 || Boolean(normalized.replyTarget); +} + +function sanitizeStreamText(text: string): string { + const stripped = stripMessageDisplayMetadataText(text); + return stripped.trim().length > 0 ? stripped : ""; +} + export function buildChatItems(props: BuildChatItemsProps): Array { - const items: ChatItem[] = []; + let items: ChatItem[] = []; const history = (Array.isArray(props.messages) ? props.messages : []).filter( (message) => !isAssistantHeartbeatAckForDisplay(message), ); const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; + const liftedCanvasSources = tools + .map((tool) => extractChatMessagePreview(tool)) + .filter((entry) => Boolean(entry)) as Array<{ + preview: Extract, { kind: "canvas" }>; + text: string | null; + timestamp: number | null; + }>; const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT); if (historyStart > 0) { items.push({ @@ -299,6 +316,9 @@ export function buildChatItems(props: BuildChatItemsProps): Array extractChatMessagePreview(tool)) - .filter((entry) => Boolean(entry)) as Array<{ - preview: Extract, { kind: "canvas" }>; - text: string | null; - timestamp: number | null; - }>; for (const liftedCanvasSource of liftedCanvasSources) { const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp); if (assistantIndex == null) { @@ -331,16 +344,22 @@ export function buildChatItems(props: BuildChatItemsProps): Array item.kind !== "message" || hasRenderableNormalizedMessage(item.message), + ); const segments = props.streamSegments ?? []; const maxLen = Math.max(segments.length, tools.length); for (let i = 0; i < maxLen; i++) { - if (i < segments.length && segments[i].text.trim().length > 0) { - items.push({ - kind: "stream", - key: `stream-seg:${props.sessionKey}:${i}`, - text: segments[i].text, - startedAt: segments[i].ts, - }); + if (i < segments.length) { + const text = sanitizeStreamText(segments[i].text); + if (text.length > 0) { + items.push({ + kind: "stream", + key: `stream-seg:${props.sessionKey}:${i}`, + text, + startedAt: segments[i].ts, + }); + } } if (i < tools.length && props.showToolCalls) { items.push({ @@ -353,16 +372,17 @@ export function buildChatItems(props: BuildChatItemsProps): Array 0) { - if (!stripHeartbeatTokenForDisplay(props.stream).shouldSkip) { + const text = sanitizeStreamText(props.stream); + if (text.length > 0) { + if (!stripHeartbeatTokenForDisplay(text).shouldSkip) { items.push({ kind: "stream", key, - text: props.stream, + text, startedAt: props.streamStartedAt ?? Date.now(), }); } - } else { + } else if (props.stream.trim().length === 0) { items.push({ kind: "reading-indicator", key }); } } diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/ui/chat/chat-avatar.test.ts index 5efa18d7ebf..541f88500fb 100644 --- a/ui/src/ui/chat/chat-avatar.test.ts +++ b/ui/src/ui/chat/chat-avatar.test.ts @@ -41,7 +41,6 @@ function renderAvatar(params: Parameters) { describe("renderChatAvatar", () => { it("renders assistant fallback, blob image, and text avatars", () => { const defaultAvatar = renderAvatar(["assistant"]); - expect(defaultAvatar).not.toBeNull(); expect(defaultAvatar?.getAttribute("src")).toBe("apple-touch-icon.png"); const remoteAvatar = renderAvatar([ diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/ui/chat/chat-responsive.browser.test.ts index 21bd8f125ca..26e3f560a20 100644 --- a/ui/src/ui/chat/chat-responsive.browser.test.ts +++ b/ui/src/ui/chat/chat-responsive.browser.test.ts @@ -17,6 +17,42 @@ const describeBrowserLayout = existsSync(chromium.executablePath()) ? describe : let browser: Browser; +type ControlRect = { + x: number; + y: number; + width: number; + height: number; + text?: string; + display?: string; +}; + +async function getBoundingBox(page: Page, selector: string) { + const box = await page.locator(selector).boundingBox(); + expect(box).toMatchObject({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + if (box === null) { + throw new Error(`Expected bounding box for ${selector}`); + } + return box; +} + +function expectControlRect(rect: ControlRect | null, label: string): ControlRect { + expect(rect).toMatchObject({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + if (rect === null) { + throw new Error(`Expected ${label} control rect`); + } + return rect; +} + function readUiCss(): string { const files = [ "ui/src/styles/base.css", @@ -252,9 +288,11 @@ describeBrowserLayout("chat responsive browser layout", () => { ].filter((value): value is number => typeof value === "number"); expect(rowY.length).toBe(5); expect(Math.max(...rowY) - Math.min(...rowY)).toBeLessThanOrEqual(4); - expect(controls.agent!.x).toBeLessThan(controls.session!.x); - expect(controls.session!.width / controls.agent!.width).toBeGreaterThan(1.25); - expect(controls.session!.width / controls.agent!.width).toBeLessThan(1.55); + const agent = expectControlRect(controls.agent, "agent"); + const session = expectControlRect(controls.session, "session"); + expect(agent.x).toBeLessThan(session.x); + expect(session.width / agent.width).toBeGreaterThan(1.25); + expect(session.width / agent.width).toBeLessThan(1.55); } finally { await page.close(); } @@ -285,9 +323,8 @@ describeBrowserLayout("chat responsive browser layout", () => { const page = await openFixture(width, height); try { await expectNoHorizontalOverflow(page); - const code = await page.locator(".chat-text pre").boundingBox(); - expect(code).not.toBeNull(); - expect(code!.x + code!.width).toBeLessThanOrEqual(width + 1); + const code = await getBoundingBox(page, ".chat-text pre"); + expect(code.x + code.width).toBeLessThanOrEqual(width + 1); } finally { await page.close(); } @@ -302,10 +339,9 @@ describeBrowserLayout("chat responsive browser layout", () => { (mode) => document.documentElement.setAttribute("data-theme-mode", mode), themeMode, ); - const dropdown = await page.locator(".chat-controls-dropdown.open").boundingBox(); - expect(dropdown).not.toBeNull(); - expect(dropdown!.x).toBeGreaterThanOrEqual(8); - expect(dropdown!.x + dropdown!.width).toBeLessThanOrEqual(312); + const dropdown = await getBoundingBox(page, ".chat-controls-dropdown.open"); + expect(dropdown.x).toBeGreaterThanOrEqual(8); + expect(dropdown.x + dropdown.width).toBeLessThanOrEqual(312); await expectNoHorizontalOverflow(page); const mobileControls = await page.evaluate(() => { const rectFor = (selector: string) => { @@ -331,12 +367,12 @@ describeBrowserLayout("chat responsive browser layout", () => { .length, }; }); - expect(mobileControls.agent).not.toBeNull(); - expect(mobileControls.session).not.toBeNull(); - expect(mobileControls.session!.y).toBe(mobileControls.agent!.y); - expect(mobileControls.agent!.x).toBeLessThan(mobileControls.session!.x); - expect(mobileControls.session!.width / mobileControls.agent!.width).toBeGreaterThan(1.25); - expect(mobileControls.session!.width / mobileControls.agent!.width).toBeLessThan(1.55); + const agent = expectControlRect(mobileControls.agent, "agent"); + const session = expectControlRect(mobileControls.session, "session"); + expect(session.y).toBe(agent.y); + expect(agent.x).toBeLessThan(session.x); + expect(session.width / agent.width).toBeGreaterThan(1.25); + expect(session.width / agent.width).toBeLessThan(1.55); expect(mobileControls.thinkingFull?.display).not.toBe("none"); expect(mobileControls.thinkingFull?.text).toBe("Default (high)"); expect(mobileControls.compactCount).toBe(0); @@ -386,15 +422,12 @@ describeBrowserLayout("chat responsive browser layout", () => { try { await expectNoHorizontalOverflow(page); expect(await page.locator('[data-chat-agent-filter="true"]').count()).toBe(0); - const session = await page.locator('[data-chat-session-select="true"]').boundingBox(); - const model = await page.locator('[data-chat-model-select="true"]').boundingBox(); - const thinking = await page.locator('[data-chat-thinking-select="true"]').boundingBox(); - expect(session).not.toBeNull(); - expect(model).not.toBeNull(); - expect(thinking).not.toBeNull(); - expect(thinking!.x).toBeGreaterThan(session!.x); - expect(model!.y).toBeGreaterThan(session!.y); - expect(model!.width).toBeGreaterThan(session!.width); + const session = await getBoundingBox(page, '[data-chat-session-select="true"]'); + const model = await getBoundingBox(page, '[data-chat-model-select="true"]'); + const thinking = await getBoundingBox(page, '[data-chat-thinking-select="true"]'); + expect(thinking.x).toBeGreaterThan(session.x); + expect(model.y).toBeGreaterThan(session.y); + expect(model.width).toBeGreaterThan(session.width); } finally { await page.close(); } diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 7398ffd95bc..e51671d6256 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -91,6 +91,19 @@ vi.mock("../tool-display.ts", () => ({ type RenderMessageGroupOptions = Parameters[1]; +function expectElement( + container: Element, + selector: string, + constructor: new () => T, +): T { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; +} + function renderAssistantMessage( container: HTMLElement, message: unknown, @@ -292,6 +305,15 @@ function getLastCaptureClickListener(calls: readonly unknown[][]) { return null; } +function expectLastCaptureClickListener(calls: readonly unknown[][]): unknown { + const listener = getLastCaptureClickListener(calls); + expect(listener).toEqual(expect.any(Function)); + if (listener === null) { + throw new Error("Expected capture click listener"); + } + return listener; +} + function countCaptureClickListenerRemovals(calls: readonly unknown[][], listener: unknown) { return calls.filter( ([type, removedListener, options]) => @@ -320,7 +342,7 @@ function renderDeleteConfirmFixture() { { onDelete }, ); const deleteButton = container.querySelector(".chat-group-delete"); - expect(deleteButton).not.toBeNull(); + expect(deleteButton).toBeInstanceOf(HTMLButtonElement); return { container, deleteButton: deleteButton!, onDelete }; } @@ -345,9 +367,8 @@ function setupArmedDeleteConfirm() { openDeleteConfirm(fixture.deleteButton); flushAnimationFrames(); - const outsideClickListener = getLastCaptureClickListener(addListenerSpy.mock.calls); - expect(outsideClickListener).not.toBeNull(); - expect(fixture.container.querySelector(".chat-delete-confirm")).not.toBeNull(); + const outsideClickListener = expectLastCaptureClickListener(addListenerSpy.mock.calls); + expect(fixture.container.querySelectorAll(".chat-delete-confirm")).toHaveLength(1); return { ...fixture, outsideClickListener, removeListenerSpy }; } @@ -451,25 +472,23 @@ describe("grouped chat rendering", () => { const userDeleteButton = container.querySelector( ".chat-group.user .chat-group-delete", ); - expect(userDeleteButton).not.toBeNull(); - userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(userDeleteButton).toBeInstanceOf(HTMLButtonElement); + userDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); const userConfirm = container.querySelector( ".chat-group.user .chat-delete-confirm", ); - expect(userConfirm).not.toBeNull(); expect(userConfirm?.classList.contains("chat-delete-confirm--left")).toBe(true); const assistantDeleteButton = container.querySelector( ".chat-group.assistant .chat-group-delete", ); - expect(assistantDeleteButton).not.toBeNull(); - assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(assistantDeleteButton).toBeInstanceOf(HTMLButtonElement); + assistantDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); const assistantConfirm = container.querySelector( ".chat-group.assistant .chat-delete-confirm", ); - expect(assistantConfirm).not.toBeNull(); expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true); }); @@ -479,8 +498,8 @@ describe("grouped chat rendering", () => { ".chat-delete-confirm__cancel", ); - expect(cancel).not.toBeNull(); - cancel?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cancel).toBeInstanceOf(HTMLButtonElement); + cancel!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expectDeleteConfirmDismissed(fixture); expect(fixture.onDelete).not.toHaveBeenCalled(); @@ -490,8 +509,8 @@ describe("grouped chat rendering", () => { const fixture = setupArmedDeleteConfirm(); const confirm = fixture.container.querySelector(".chat-delete-confirm__yes"); - expect(confirm).not.toBeNull(); - confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(confirm).toBeInstanceOf(HTMLButtonElement); + confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expectDeleteConfirmDismissed(fixture); expect(fixture.onDelete).toHaveBeenCalledTimes(1); @@ -565,7 +584,6 @@ describe("grouped chat rendering", () => { 1_000_000, ); const meta = cached.querySelector("details.msg-meta"); - expect(meta).not.toBeNull(); expect(meta?.open).toBe(false); expect(meta?.querySelector("summary")?.textContent).toContain("Context"); expect(cached.querySelector(".msg-meta__ctx")?.textContent).toBe("44% ctx"); @@ -622,7 +640,6 @@ describe("grouped chat rendering", () => { const time = container.querySelector(".chat-group-timestamp"); const display = formatChatTimestampForDisplay(timestamp); - expect(time).not.toBeNull(); expect(time?.dateTime).toBe(display.dateTime); expect(time?.textContent?.trim()).toBe(display.label); expect(time?.getAttribute("title")).toBe(display.title); @@ -716,7 +733,7 @@ describe("grouped chat rendering", () => { isToolMessageExpanded: () => false, }); - expect(container.querySelector(".chat-bubble--tool-shell")).not.toBeNull(); + expectElement(container, ".chat-bubble--tool-shell", HTMLElement); const summary = container.querySelector(".chat-tool-msg-summary"); expect(summary?.textContent).toContain("Tool call"); expect(container.textContent).not.toContain('"thread": true'); @@ -849,8 +866,8 @@ describe("grouped chat rendering", () => { expect(container.querySelector(".chat-reply-pill")?.textContent).toContain( "Replying to current message", ); - expect(container.querySelector(".chat-message-image")).not.toBeNull(); - expect(container.querySelector("audio")).not.toBeNull(); + expectElement(container, ".chat-message-image", HTMLImageElement); + expectElement(container, "audio", HTMLAudioElement); expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent).toContain( "Voice note", ); @@ -1089,8 +1106,8 @@ describe("grouped chat rendering", () => { { showToolCalls: false }, ); - expect(container.querySelector(".chat-bubble")).not.toBeNull(); - expect(container.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); + expectElement(container, ".chat-bubble", HTMLElement); + expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); expect(container.textContent).toContain("Tic-Tac-Toe"); }); @@ -1107,8 +1124,8 @@ describe("grouped chat rendering", () => { try { renderAssistantImage("https://example.com/cat.png"); let image = container.querySelector(".chat-message-image"); - expect(image).not.toBeNull(); - image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(image).toBeInstanceOf(HTMLImageElement); + image!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledWith( @@ -1120,14 +1137,14 @@ describe("grouped chat rendering", () => { openSpy.mockClear(); renderAssistantImage("javascript:alert(1)"); image = container.querySelector(".chat-message-image"); - expect(image).not.toBeNull(); - image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(image).toBeInstanceOf(HTMLImageElement); + image!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).not.toHaveBeenCalled(); renderAssistantImage("data:image/svg+xml,"); image = container.querySelector(".chat-message-image"); - expect(image).not.toBeNull(); - image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(image).toBeInstanceOf(HTMLImageElement); + image!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(openSpy).not.toHaveBeenCalled(); } finally { openSpy.mockRestore(); @@ -1301,7 +1318,7 @@ describe("grouped chat rendering", () => { "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&meta=1", expect.objectContaining({ credentials: "same-origin", method: "GET" }), ); - expect(container.querySelector(".chat-message-image")).not.toBeNull(); + expectElement(container, ".chat-message-image", HTMLImageElement); expect( container.querySelector(".chat-message-image")?.getAttribute("src"), ).toBe( @@ -1491,7 +1508,7 @@ describe("grouped chat rendering", () => { await flushAssistantAttachmentAvailabilityChecks(); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(container.querySelector(".chat-message-image")).not.toBeNull(); + expectElement(container, ".chat-message-image", HTMLImageElement); expect(container.textContent).not.toContain("Unavailable"); vi.useRealTimers(); @@ -1572,12 +1589,12 @@ describe("grouped chat rendering", () => { { showToolCalls: true }, ); - const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble"); const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame"); expect(allPreviews).toHaveLength(1); - expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); - expect(assistantBubble?.textContent).toContain("This item is ready."); - expect(assistantBubble?.textContent).toContain("Live history preview"); + const bubble = expectElement(container, ".chat-group.assistant .chat-bubble", HTMLElement); + expectElement(bubble, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(bubble.textContent).toContain("This item is ready."); + expect(bubble.textContent).toContain("Live history preview"); }); it("renders hidden assistant_message canvas results with the configured sandbox", () => { @@ -1606,10 +1623,9 @@ describe("grouped chat rendering", () => { renderCanvas({ suffix: "default" }); - let iframe = container.querySelector(".chat-tool-card__preview-frame"); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); - expect(iframe?.getAttribute("src")).toBe( + let iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts"); + expect(iframe.getAttribute("src")).toBe( "/__openclaw__/canvas/documents/cv_inline_default/index.html", ); expect(container.textContent).toContain("Inline canvas result."); @@ -1617,8 +1633,8 @@ describe("grouped chat rendering", () => { expect(container.textContent).toContain("Raw details"); renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" }); - iframe = container.querySelector(".chat-tool-card__preview-frame"); - expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); + iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement); + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); }); it("renders assistant_message canvas results in the assistant bubble even when tool rows are visible", () => { @@ -1667,10 +1683,10 @@ describe("grouped chat rendering", () => { }, ); - const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble"); const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame"); expect(allPreviews).toHaveLength(1); - expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); + const bubble = expectElement(container, ".chat-group.assistant .chat-bubble", HTMLElement); + expectElement(bubble, ".chat-tool-card__preview-frame", HTMLIFrameElement); expect(container.textContent).toContain("Tool output"); expect(container.textContent).toContain("canvas_render"); expect(container.textContent).toContain("Inline canvas result."); @@ -1724,10 +1740,10 @@ describe("grouped chat rendering", () => { ); const sidebarButton = container.querySelector(".chat-tool-card__action-btn"); - sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(sidebarButton).toBeInstanceOf(HTMLButtonElement); + sidebarButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull(); - expect(sidebarButton).not.toBeNull(); expect(onOpenSidebar).toHaveBeenCalledWith( expect.objectContaining({ kind: "markdown", diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index d41bbe1a0ba..efa7625e957 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { normalizeMessage } from "./message-normalizer.ts"; +const SENDER_METADATA_BLOCK = + 'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui","id":"openclaw-control-ui"}\n```'; + describe("message-normalizer", () => { describe("normalizeMessage", () => { beforeEach(() => { @@ -29,6 +32,24 @@ describe("message-normalizer", () => { }); }); + it("strips sender metadata blocks before displaying message text", () => { + const result = normalizeMessage({ + role: "assistant", + content: `${SENDER_METADATA_BLOCK}\n\nVisible reply`, + }); + + expect(result.content).toEqual([{ type: "text", text: "Visible reply" }]); + }); + + it("drops standalone sender metadata blocks before display", () => { + const result = normalizeMessage({ + role: "system", + content: SENDER_METADATA_BLOCK, + }); + + expect(result.content).toEqual([]); + }); + it("does not reinterpret directive-like user string content", () => { const result = normalizeMessage({ role: "user", diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index dc090e97daa..85181147753 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -158,6 +158,21 @@ function mergeAdjacentTextItems(items: MessageContentItem[]): MessageContentItem return merged.filter((item) => item.type !== "text" || Boolean(item.text?.trim())); } +export function stripMessageDisplayMetadataText(text: string): string { + return stripInboundMetadata(text); +} + +function stripMessageDisplayMetadata(items: MessageContentItem[]): MessageContentItem[] { + return items + .map((item) => { + if (item.type !== "text" || typeof item.text !== "string") { + return item; + } + return { ...item, text: stripMessageDisplayMetadataText(item.text) }; + }) + .filter((item) => item.type !== "text" || Boolean(item.text?.trim())); +} + function expandTextContent(text: string): { content: MessageContentItem[]; audioAsVoice: boolean; @@ -370,15 +385,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { const senderLabel = typeof m.senderLabel === "string" && m.senderLabel.trim() ? m.senderLabel.trim() : null; - // Strip AI-injected metadata prefix blocks from user messages before display. - if (role === "user" || role === "User") { - content = content.map((item) => { - if (item.type === "text" && typeof item.text === "string") { - return { ...item, text: stripInboundMetadata(item.text) }; - } - return item; - }); - } + content = stripMessageDisplayMetadata(content); return { role, diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index 539b900c457..61336933695 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -37,6 +37,15 @@ function createProps(overrides: Partial = {}): ChatRunCont }; } +function getButton(container: Element, selector: string): HTMLButtonElement { + const button = container.querySelector(selector); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button matching ${selector}`); + } + return button; +} + describe("chat run controls", () => { it("switches between idle and abort actions", () => { const container = document.createElement("div"); @@ -57,12 +66,11 @@ describe("chat run controls", () => { container, ); - const queueButton = container.querySelector('button[title="Queue"]'); - const stopButton = container.querySelector('button[title="Stop"]'); - expect(queueButton).not.toBeNull(); - expect(queueButton?.disabled).toBe(true); - expect(stopButton).not.toBeNull(); - stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const queueButton = getButton(container, 'button[title="Queue"]'); + const stopButton = getButton(container, 'button[title="Stop"]'); + expect(queueButton.disabled).toBe(true); + expect(stopButton.title).toBe("Stop"); + stopButton.click(); expect(onAbort).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("New session"); @@ -82,16 +90,14 @@ describe("chat run controls", () => { container, ); - const newSessionButton = container.querySelector( - 'button[title="New session"]', - ); - expect(newSessionButton).not.toBeNull(); - newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const newSessionButton = getButton(container, 'button[title="New session"]'); + expect(newSessionButton.title).toBe("New session"); + newSessionButton.click(); expect(onNewSession).toHaveBeenCalledTimes(1); - const sendButton = container.querySelector('button[title="Send"]'); - expect(sendButton).not.toBeNull(); - sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const sendButton = getButton(container, 'button[title="Send"]'); + expect(sendButton.title).toBe("Send"); + sendButton.click(); expect(onStoreDraft).toHaveBeenCalledWith(" run this "); expect(onSend).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("Stop"); @@ -113,10 +119,9 @@ describe("chat run controls", () => { container, ); - const queueButton = container.querySelector('button[title="Queue"]'); - expect(queueButton).not.toBeNull(); - expect(queueButton?.disabled).toBe(false); - queueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const queueButton = getButton(container, 'button[title="Queue"]'); + expect(queueButton.disabled).toBe(false); + queueButton.click(); expect(onStoreDraft).toHaveBeenCalledWith(" follow up "); expect(onSend).toHaveBeenCalledTimes(1); }); @@ -135,10 +140,9 @@ describe("chat run controls", () => { container, ); - const stopButton = container.querySelector('button[title="Stop"]'); - expect(stopButton).not.toBeNull(); - expect(stopButton?.disabled).toBe(false); - stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const stopButton = getButton(container, 'button[title="Stop"]'); + expect(stopButton.disabled).toBe(false); + stopButton.click(); expect(onAbort).toHaveBeenCalledTimes(1); }); }); @@ -176,10 +180,8 @@ describe("chat status indicators", () => { ); let indicator = container.querySelector(".compaction-indicator--active"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Compacting context..."); indicator = container.querySelector(".compaction-indicator--fallback"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5"); renderIndicators( @@ -199,10 +201,8 @@ describe("chat status indicators", () => { }, ); indicator = container.querySelector(".compaction-indicator--complete"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Context compacted"); indicator = container.querySelector(".compaction-indicator--fallback-cleared"); - expect(indicator).not.toBeNull(); expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5"); nowSpy.mockReturnValue(20_000); @@ -260,8 +260,8 @@ describe("context notice", () => { render(renderContextNotice(lowUsageSession, 200_000), container); expect(container.textContent).toContain("23% context used"); expect(container.textContent).toContain("46k / 200k"); - expect(container.querySelector(".context-notice--usage")).not.toBeNull(); - expect(container.querySelector(".context-notice__meter")).not.toBeNull(); + expect(container.querySelectorAll(".context-notice--usage")).toHaveLength(1); + expect(container.querySelectorAll(".context-notice__meter")).toHaveLength(1); expect(container.querySelector(".context-notice__icon")).toBeNull(); expect(container.textContent).not.toContain("757.3k / 200k"); @@ -280,7 +280,6 @@ describe("context notice", () => { expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true); expect(container.textContent).not.toContain("757.3k / 200k"); const notice = container.querySelector(".context-notice"); - expect(notice).not.toBeNull(); expect(notice?.classList.contains("context-notice--warning")).toBe(true); expect(notice?.getAttribute("title")).toBe("Session context usage: 190k / 200k (95%)"); expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); @@ -289,17 +288,16 @@ describe("context notice", () => { expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); const icon = container.querySelector(".context-notice__icon"); - expect(icon).not.toBeNull(); expect(icon?.tagName.toLowerCase()).toBe("svg"); expect(icon?.classList.contains("context-notice__icon")).toBe(true); expect(icon?.getAttribute("width")).toBe("16"); expect(icon?.getAttribute("height")).toBe("16"); - expect(icon?.querySelector("path")).not.toBeNull(); + expect(icon?.querySelectorAll("path")).toHaveLength(1); const onCompact = vi.fn(); render(renderContextNotice(session, 200_000, { onCompact }), container); expect(container.textContent).toContain("Compact"); - container.querySelector(".context-notice__action")?.click(); + getButton(container, ".context-notice__action").click(); expect(onCompact).toHaveBeenCalledTimes(1); expect( @@ -351,15 +349,17 @@ describe("side result render", () => { container, ); - expect(container.querySelector(".chat-side-result")).not.toBeNull(); expect(container.textContent).toContain("BTW"); expect(container.textContent).toContain("what changed?"); expect(container.textContent).toContain("Not saved to chat history"); expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); const button = container.querySelector(".chat-side-result__dismiss"); - expect(button).not.toBeNull(); - button?.click(); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected side result dismiss button"); + } + button.click(); expect(onDismissSideResult).toHaveBeenCalledTimes(1); render( @@ -375,6 +375,6 @@ describe("side result render", () => { container, ); - expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); + expect(container.querySelectorAll(".chat-side-result--error")).toHaveLength(1); }); }); diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index 1c58fa6b66d..3c852112cda 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -83,7 +83,6 @@ describe("tool-cards", () => { expect(container.textContent).toContain("Tool call"); expect(container.textContent).not.toContain("Tool input"); const summaryButton = container.querySelector("button.chat-tool-msg-summary"); - expect(summaryButton).not.toBeNull(); expect(summaryButton?.getAttribute("aria-expanded")).toBe("false"); }); @@ -130,7 +129,8 @@ describe("tool-cards", () => { expect(rawToggle?.getAttribute("aria-expanded")).toBe("false"); expect(rawBody?.hidden).toBe(true); - rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(rawToggle).toBeInstanceOf(HTMLButtonElement); + rawToggle!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(rawToggle?.getAttribute("aria-expanded")).toBe("true"); expect(rawBody?.hidden).toBe(false); @@ -174,9 +174,10 @@ describe("tool-cards", () => { ); const sidebarButton = container.querySelector(".chat-tool-card__action-btn"); - sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(sidebarButton).toBeInstanceOf(HTMLButtonElement); + expect(sidebarButton?.classList.contains("chat-tool-card__action-btn")).toBe(true); + sidebarButton!.click(); - expect(sidebarButton).not.toBeNull(); expect(onOpenSidebar).toHaveBeenCalledWith( expect.objectContaining({ kind: "canvas", diff --git a/ui/src/ui/components/modal-dialog.test.ts b/ui/src/ui/components/modal-dialog.test.ts index db88558ac86..2c2a686f4b7 100644 --- a/ui/src/ui/components/modal-dialog.test.ts +++ b/ui/src/ui/components/modal-dialog.test.ts @@ -60,12 +60,26 @@ async function renderModal() { container, ); const modal = container.querySelector("openclaw-modal-dialog"); - expect(modal).not.toBeNull(); - await modal!.updateComplete; + expect(modal).toBeInstanceOf(HTMLElement); + if (!modal) { + throw new Error("Expected openclaw-modal-dialog"); + } + await modal.updateComplete; await nextFrame(); - const dialog = modal!.shadowRoot?.querySelector("dialog"); - expect(dialog).not.toBeNull(); - return { modal: modal!, dialog: dialog! }; + const dialog = modal.shadowRoot?.querySelector("dialog"); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + if (!(dialog instanceof HTMLDialogElement)) { + throw new Error("Expected rendered dialog"); + } + return { modal, dialog }; +} + +function expectShadowElement(modal: OpenClawModalDialog, id: string): HTMLElement { + const element = modal.shadowRoot?.getElementById(id); + if (!(element instanceof HTMLElement)) { + throw new Error(`Expected shadow element #${id}`); + } + return element; } describe("openclaw-modal-dialog", () => { @@ -95,8 +109,10 @@ describe("openclaw-modal-dialog", () => { expect(descriptionId).toBe("openclaw-modal-dialog-description"); expect(dialog.getRootNode()).toBe(modal.shadowRoot); expect(dialog.ownerDocument.querySelector(`#${labelId}`)).toBeNull(); - expect(modal.shadowRoot?.getElementById(labelId!)?.textContent).toBe("Confirm action"); - expect(modal.shadowRoot?.getElementById(descriptionId!)?.textContent).toBe( + expect(expectShadowElement(modal, "openclaw-modal-dialog-label").textContent).toBe( + "Confirm action", + ); + expect(expectShadowElement(modal, "openclaw-modal-dialog-description").textContent).toBe( "Review the operation before continuing.", ); }); @@ -112,21 +128,24 @@ describe("openclaw-modal-dialog", () => { const { dialog } = await renderModal(); const first = container.querySelector("#first-action"); const last = container.querySelector("#last-action"); - expect(first).not.toBeNull(); - expect(last).not.toBeNull(); + expect(first?.id).toBe("first-action"); + expect(last?.id).toBe("last-action"); + if (!first || !last) { + throw new Error("expected modal focus trap actions"); + } - last!.focus(); + last.focus(); const tab = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true, composed: true, }); - last!.dispatchEvent(tab); + last.dispatchEvent(tab); expect(tab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(first); - first!.focus(); + first.focus(); const shiftTab = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, @@ -134,7 +153,7 @@ describe("openclaw-modal-dialog", () => { cancelable: true, composed: true, }); - first!.dispatchEvent(shiftTab); + first.dispatchEvent(shiftTab); expect(shiftTab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(last); expect(dialog.open).toBe(true); diff --git a/ui/src/ui/components/resizable-divider.test.ts b/ui/src/ui/components/resizable-divider.test.ts index 6de8f7ddc23..71e2237398a 100644 --- a/ui/src/ui/components/resizable-divider.test.ts +++ b/ui/src/ui/components/resizable-divider.test.ts @@ -44,10 +44,13 @@ async function renderDivider() { const root = container.querySelector("#split-root"); const divider = container.querySelector("resizable-divider"); - expect(root).not.toBeNull(); - expect(divider).not.toBeNull(); + expect(root?.id).toBe("split-root"); + expect(divider?.tagName.toLowerCase()).toBe("resizable-divider"); + if (!root || !divider) { + throw new Error("expected resizable divider fixture"); + } - root!.getBoundingClientRect = vi.fn(() => ({ + root.getBoundingClientRect = vi.fn(() => ({ bottom: 0, height: 0, left: 0, @@ -59,9 +62,9 @@ async function renderDivider() { toJSON: () => ({}), })); - await divider!.updateComplete; + await divider.updateComplete; await nextFrame(); - return divider!; + return divider; } function dispatchPointer(target: EventTarget, type: string, clientX: number) { diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 563f18df43f..92600faf699 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -34,6 +34,14 @@ const rootSchema = { }; const rootAnalysis = analyzeConfigSchema(rootSchema); +function expectElement(element: T | null | undefined, label: string): T { + expect(element, label).toEqual(expect.any(Element)); + if (!element) { + throw new Error(`missing ${label}`); + } + return element; +} + describe("config form renderer", () => { it("renders inputs and patches values", () => { const onPatch = vi.fn(); @@ -53,48 +61,51 @@ describe("config form renderer", () => { container, ); - const tokenInput: HTMLInputElement | null = container.querySelector( - '#config-section-gateway input.cfg-input[type="text"]', + const tokenInput = expectElement( + container.querySelector( + '#config-section-gateway input.cfg-input[type="text"]', + ), + "gateway token input", ); - expect(tokenInput).not.toBeNull(); - if (!tokenInput) { - return; - } tokenInput.value = "abc123"; tokenInput.dispatchEvent(new Event("input", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123"); - const tokenButton = Array.from( - container.querySelectorAll(".cfg-segmented__btn"), - ).find((btn) => btn.textContent?.trim() === "token"); - expect(tokenButton).not.toBeUndefined(); - tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const tokenButton = expectElement( + Array.from(container.querySelectorAll(".cfg-segmented__btn")).find( + (btn) => btn.textContent?.trim() === "token", + ), + "token segmented button", + ); + tokenButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); - const checkbox: HTMLInputElement | null = container.querySelector("input[type='checkbox']"); - expect(checkbox).not.toBeNull(); - if (!checkbox) { - return; - } + const checkbox = expectElement( + container.querySelector("input[type='checkbox']"), + "enabled checkbox", + ); checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["enabled"], true); - const addButton = container.querySelector(".cfg-array__add"); - expect(addButton).not.toBeUndefined(); - addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const addButton = expectElement(container.querySelector(".cfg-array__add"), "array add button"); + addButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]); - const removeButton = container.querySelector(".cfg-array__item-remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const removeButton = expectElement( + container.querySelector(".cfg-array__item-remove"), + "array remove button", + ); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); - const tailnetButton = Array.from( - container.querySelectorAll(".cfg-segmented__btn"), - ).find((btn) => btn.textContent?.trim() === "tailnet"); - expect(tailnetButton).not.toBeUndefined(); - tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const tailnetButton = expectElement( + Array.from(container.querySelectorAll(".cfg-segmented__btn")).find( + (btn) => btn.textContent?.trim() === "tailnet", + ), + "tailnet segmented button", + ); + tailnetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); }); @@ -168,9 +179,11 @@ describe("config form renderer", () => { container, ); - const removeButton = container.querySelector(".cfg-map__item-remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const removeButton = expectElement( + container.querySelector(".cfg-map__item-remove"), + "map remove button", + ); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["slack"], {}); }); @@ -324,13 +337,12 @@ describe("config form renderer", () => { container, ); - const apiKeyInput: HTMLInputElement | null = container.querySelector( - "#config-section-models .cfg-map__item-value input.cfg-input[type='text']", + const apiKeyInput = expectElement( + container.querySelector( + "#config-section-models .cfg-map__item-value input.cfg-input[type='text']", + ), + "provider api key input", ); - expect(apiKeyInput).not.toBeNull(); - if (!apiKeyInput) { - return; - } apiKeyInput.value = "new-key"; apiKeyInput.dispatchEvent(new Event("input", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["models", "providers", "openai", "apiKey"], "new-key"); @@ -404,9 +416,11 @@ describe("config form renderer", () => { container, ); - const removeButton = container.querySelector(".cfg-map__item-remove"); - expect(removeButton).not.toBeNull(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const removeButton = expectElement( + container.querySelector(".cfg-map__item-remove"), + "accounts remove button", + ); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["accounts"], {}); }); }); diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts index 52b9ab6e799..6812b57c2dc 100644 --- a/ui/src/ui/control-ui-performance.test.ts +++ b/ui/src/ui/control-ui-performance.test.ts @@ -70,8 +70,13 @@ describe("recordControlUiPerformanceEvent", () => { } expect(host.eventLogBuffer).toHaveLength(250); - expect(host.eventLogBuffer[0]?.payload).toEqual({ i: 259 }); - expect(host.eventLogBuffer.at(-1)?.payload).toEqual({ i: 10 }); + const [newestEvent] = host.eventLogBuffer; + const oldestEvent = host.eventLogBuffer.at(-1); + if (!newestEvent || !oldestEvent) { + throw new Error("Expected bounded performance event buffer entries"); + } + expect(newestEvent.payload).toEqual({ i: 259 }); + expect(oldestEvent.payload).toEqual({ i: 10 }); }); }); diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts index 2b18d927da4..f67126723ed 100644 --- a/ui/src/ui/controllers/assistant-identity.test.ts +++ b/ui/src/ui/controllers/assistant-identity.test.ts @@ -5,10 +5,13 @@ import { loadLocalAssistantIdentity } from "../storage.ts"; import { loadAssistantIdentity, setAssistantAvatarOverride } from "./assistant-identity.ts"; function createDeferred() { - let resolve!: (value: T) => void; + let resolve: ((value: T) => void) | undefined; const promise = new Promise((next) => { resolve = next; }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } return { promise, resolve }; } diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/ui/controllers/channels.test.ts index 2db07d32ed4..25012353eda 100644 --- a/ui/src/ui/controllers/channels.test.ts +++ b/ui/src/ui/controllers/channels.test.ts @@ -3,12 +3,15 @@ import type { ChannelsStatusSnapshot } from "../types.ts"; import { loadChannels, waitWhatsAppLogin, type ChannelsState } from "./channels.ts"; function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } @@ -29,6 +32,14 @@ function createState(): ChannelsState { }; } +function requireClientRequest(state: ChannelsState) { + const request = state.client?.request; + if (!request) { + throw new Error("Expected channels controller client request"); + } + return vi.mocked(request); +} + describe("channels controller WhatsApp wait", () => { beforeEach(() => { vi.clearAllMocks(); @@ -36,7 +47,7 @@ describe("channels controller WhatsApp wait", () => { it("passes the currently displayed QR and replaces it when the login QR rotates", async () => { const state = createState(); - const request = vi.mocked(state.client!.request); + const request = requireClientRequest(state); request.mockResolvedValueOnce({ connected: false, message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.", @@ -76,7 +87,7 @@ describe("loadChannels", () => { ts: 2, }; const deferred = createDeferred(); - const request = vi.mocked(state.client!.request); + const request = requireClientRequest(state); request.mockReturnValueOnce(deferred.promise); state.channelsSnapshot = previous; state.channelsLastSuccess = 10; @@ -95,7 +106,7 @@ describe("loadChannels", () => { expect(state.channelsLoading).toBe(false); expect(state.channelsSnapshot).toBe(next); - expect(state.channelsLastSuccess).toEqual(expect.any(Number)); + expect(state.channelsLastSuccess).toBeGreaterThan(10); } finally { vi.useRealTimers(); } diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 1f2b6faef25..4c165f4e5a4 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -13,6 +13,8 @@ import { type ChatState, } from "./chat.ts"; +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + function createState(overrides: Partial = {}): ChatState { return { chatAttachments: [], @@ -37,12 +39,15 @@ afterEach(() => { }); function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + if (!resolve || !reject) { + throw new Error("Expected deferred callbacks to be initialized"); + } return { promise, resolve, reject }; } @@ -864,7 +869,7 @@ describe("sendChatMessage", () => { await loadChatHistory(state); const result = await sendChatMessage(state, "continue"); - expect(result).toEqual(expect.any(String)); + expect(result).toMatch(UUID_V4_RE); expect(state.currentSessionId).toBe("session-before-reconnect"); expect(request).toHaveBeenLastCalledWith( "chat.send", @@ -892,7 +897,7 @@ describe("sendChatMessage", () => { }, ]); - expect(result).toEqual(expect.any(String)); + expect(result).toMatch(UUID_V4_RE); expect(request).toHaveBeenCalledWith( "chat.send", expect.objectContaining({ @@ -944,7 +949,7 @@ describe("sendChatMessage", () => { const result = await sendChatMessage(state, "summarize", [attachment]); - expect(result).toEqual(expect.any(String)); + expect(result).toMatch(UUID_V4_RE); expect(request).toHaveBeenCalledWith( "chat.send", expect.objectContaining({ diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index dea41b310a7..b32a57069a7 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -55,6 +55,14 @@ function createRequestWithConfigGet() { }); } +function requireRequestCall(request: ReturnType, index = 0): unknown[] { + const call = request.mock.calls[index]; + if (!call) { + throw new Error("expected client request call"); + } + return call; +} + describe("applyConfigSnapshot", () => { it("does not clobber form edits while dirty", () => { const state = createState(); @@ -573,8 +581,9 @@ describe("applyConfig", () => { await applyConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.apply"); - const params = request.mock.calls[0]?.[1] as { + const call = requireRequestCall(request); + expect(call[0]).toBe("config.apply"); + const params = call[1] as { raw: string; baseHash: string; sessionKey: string; @@ -615,8 +624,9 @@ describe("saveConfig", () => { await saveConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.set"); - const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string }; + const call = requireRequestCall(request); + expect(call[0]).toBe("config.set"); + const params = call[1] as { raw: string; baseHash: string }; expect(params.baseHash).toBe("hash-original"); }); @@ -645,8 +655,9 @@ describe("saveConfig", () => { await saveConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.set"); - const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string }; + const call = requireRequestCall(request); + expect(call[0]).toBe("config.set"); + const params = call[1] as { raw: string; baseHash: string }; const parsed = JSON.parse(params.raw) as { gateway: { port: unknown; enabled: unknown }; }; @@ -670,8 +681,9 @@ describe("saveConfig", () => { await saveConfig(state); - expect(request.mock.calls[0]?.[0]).toBe("config.set"); - const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string }; + const call = requireRequestCall(request); + expect(call[0]).toBe("config.set"); + const params = call[1] as { raw: string; baseHash: string }; const parsed = JSON.parse(params.raw) as { gateway: { port: unknown }; }; diff --git a/ui/src/ui/controllers/exec-approval.test.ts b/ui/src/ui/controllers/exec-approval.test.ts index 4230c3e703a..ba5402e007b 100644 --- a/ui/src/ui/controllers/exec-approval.test.ts +++ b/ui/src/ui/controllers/exec-approval.test.ts @@ -9,9 +9,10 @@ describe("parseExecApprovalRequested", () => { createdAtMs: 1000, expiresAtMs: 2000, }); - expect(result).not.toBeNull(); - expect(result!.kind).toBe("exec"); - expect(result!.request.command).toBe("rm -rf /"); + expect(result).toMatchObject({ + kind: "exec", + request: { command: "rm -rf /" }, + }); }); }); @@ -34,17 +35,20 @@ describe("parsePluginApprovalRequested", () => { it("parses a valid payload", () => { const result = parsePluginApprovalRequested(validPayload); - expect(result).not.toBeNull(); - expect(result!.kind).toBe("plugin"); - expect(result!.pluginTitle).toBe("Dangerous command detected"); - expect(result!.pluginDescription).toBe("chmod 777 script.sh modifies file permissions"); - expect(result!.pluginSeverity).toBe("high"); - expect(result!.pluginId).toBe("sage"); - expect(result!.request.command).toBe("Dangerous command detected"); - expect(result!.request.agentId).toBe("agent-1"); - expect(result!.request.sessionKey).toBe("sess-1"); - expect(result!.createdAtMs).toBe(1000); - expect(result!.expiresAtMs).toBe(120_000); + expect(result).toMatchObject({ + kind: "plugin", + pluginTitle: "Dangerous command detected", + pluginDescription: "chmod 777 script.sh modifies file permissions", + pluginSeverity: "high", + pluginId: "sage", + request: { + command: "Dangerous command detected", + agentId: "agent-1", + sessionKey: "sess-1", + }, + createdAtMs: 1000, + expiresAtMs: 120_000, + }); }); it("returns null when title is missing from request", () => { @@ -86,14 +90,17 @@ describe("parsePluginApprovalRequested", () => { request: { title: "Alert" }, }; const result = parsePluginApprovalRequested(minimal); - expect(result).not.toBeNull(); - expect(result!.kind).toBe("plugin"); - expect(result!.pluginTitle).toBe("Alert"); - expect(result!.pluginDescription).toBeNull(); - expect(result!.pluginSeverity).toBeNull(); - expect(result!.pluginId).toBeNull(); - expect(result!.request.agentId).toBeNull(); - expect(result!.request.sessionKey).toBeNull(); + expect(result).toMatchObject({ + kind: "plugin", + pluginTitle: "Alert", + pluginDescription: null, + pluginSeverity: null, + pluginId: null, + request: { + agentId: null, + sessionKey: null, + }, + }); }); }); diff --git a/ui/src/ui/custom-theme.test.ts b/ui/src/ui/custom-theme.test.ts index 4cddb4d0fe5..0272ebd07c5 100644 --- a/ui/src/ui/custom-theme.test.ts +++ b/ui/src/ui/custom-theme.test.ts @@ -299,7 +299,11 @@ describe("custom theme import helpers", () => { it("parses stored imported themes and rejects malformed records", () => { const imported = createImportedTheme(); - expect(parseImportedCustomTheme(imported)?.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z"); + const parsed = parseImportedCustomTheme(imported); + if (!parsed) { + throw new Error("Expected imported custom theme to parse"); + } + expect(parsed.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z"); expect(parseImportedCustomTheme({ ...imported, light: {} })).toBeNull(); }); diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 58d8fc3da1d..dd40cc789a1 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -27,6 +27,17 @@ type HandlerMap = { type MockWebSocketHandler = (ev?: { code?: number; data?: string; reason?: string }) => void; +function createDeferred() { + let resolve: ((value: T) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (!resolve) { + throw new Error("Expected deferred resolver to be initialized"); + } + return { promise, resolve }; +} + class MockWebSocket { static OPEN = 1; @@ -196,6 +207,8 @@ async function expectRetriedDeviceTokenConnect(params: { describe("GatewayBrowserClient", () => { beforeEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); const storage = createStorageMock(); wsInstances.length = 0; loadOrCreateDeviceIdentityMock.mockReset(); @@ -554,13 +567,8 @@ describe("GatewayBrowserClient", () => { it("does not send stale connect frames on a replacement socket", async () => { vi.useFakeTimers(); - let resolveIdentity!: (identity: DeviceIdentity) => void; - loadOrCreateDeviceIdentityMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveIdentity = resolve; - }), - ); + const identity = createDeferred(); + loadOrCreateDeviceIdentityMock.mockImplementationOnce(() => identity.promise); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", @@ -583,7 +591,7 @@ describe("GatewayBrowserClient", () => { const secondWs = getLatestWebSocket(); expect(secondWs).not.toBe(firstWs); - resolveIdentity({ + identity.resolve({ deviceId: "device-1", privateKey: "private-key", // pragma: allowlist secret publicKey: "public-key", // pragma: allowlist secret diff --git a/ui/src/ui/lazy-view.browser.test.ts b/ui/src/ui/lazy-view.browser.test.ts index f33bf7fb898..66b662cbd38 100644 --- a/ui/src/ui/lazy-view.browser.test.ts +++ b/ui/src/ui/lazy-view.browser.test.ts @@ -7,6 +7,17 @@ async function flushPromises() { await Promise.resolve(); } +function expectButtonWithText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === text, + ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; +} + describe("lazy view rendering", () => { it("renders a loading panel until the view module resolves", async () => { const onChange = vi.fn(); @@ -52,11 +63,8 @@ describe("lazy view rendering", () => { expect(container.textContent).toContain("Panel failed to load"); expect(container.textContent).toContain("chunk 404"); - const retry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Retry", - ); - expect(retry).not.toBeUndefined(); - retry?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const retry = expectButtonWithText(container, "Retry"); + retry.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await flushPromises(); render( renderLazyView(view, (mod) => mod.label), diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index f807bec34e9..485b9856ba9 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -13,16 +13,33 @@ function nextFrame() { }); } -function findConfirmButton(app: ReturnType) { - return Array.from(app.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Confirm", +function expectElement( + root: Element, + selector: string, + constructor: new () => T, +): T { + const element = root.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; +} + +function expectButtonWithText(app: ReturnType, text: string): HTMLButtonElement { + const button = Array.from(app.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === text, ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; } async function confirmPendingGatewayChange(app: ReturnType) { - const confirmButton = findConfirmButton(app); - expect(confirmButton).not.toBeUndefined(); - confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const confirmButton = expectButtonWithText(app, "Confirm"); + confirmButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await app.updateComplete; } @@ -40,21 +57,21 @@ describe("control UI routing", () => { expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); - const dreamsLink = app.querySelector('a.nav-item[href="/dreaming"]'); - expect(dreamsLink).not.toBeNull(); + expectElement(app, 'a.nav-item[href="/dreaming"]', HTMLAnchorElement); }); it("renders the dashboard breadcrumb as an overview link", async () => { const app = mountApp("/channels"); await app.updateComplete; - const breadcrumb = app.querySelector( + const breadcrumb = expectElement( + app, "dashboard-header .dashboard-header__breadcrumb-link", + HTMLAnchorElement, ); - expect(breadcrumb).toBeInstanceOf(HTMLAnchorElement); - expect(breadcrumb?.getAttribute("href")).toBe("/overview"); + expect(breadcrumb.getAttribute("href")).toBe("/overview"); - breadcrumb?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + breadcrumb.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await app.updateComplete; expect(app.tab).toBe("overview"); @@ -65,11 +82,12 @@ describe("control UI routing", () => { const app = mountApp("/ui/channels"); await app.updateComplete; - const breadcrumb = app.querySelector( + const breadcrumb = expectElement( + app, "dashboard-header .dashboard-header__breadcrumb-link", + HTMLAnchorElement, ); - expect(breadcrumb).toBeInstanceOf(HTMLAnchorElement); - expect(breadcrumb?.getAttribute("href")).toBe("/ui/overview"); + expect(breadcrumb.getAttribute("href")).toBe("/ui/overview"); }); it("renders the dreaming view on the /dreaming route", async () => { @@ -134,8 +152,8 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.tab).toBe("dreams"); - expect(app.querySelector(".dreams__tab")).not.toBeNull(); - expect(app.querySelector(".dreams__lobster")).not.toBeNull(); + expectElement(app, ".dreams__tab", HTMLElement); + expectElement(app, ".dreams__lobster", HTMLElement); }); it("requires confirmation before sending dreaming restart patch", async () => { @@ -295,17 +313,13 @@ describe("control UI routing", () => { app.requestUpdate(); await app.updateComplete; - const toggle = app.querySelector(".dreams__phase-toggle--on"); - expect(toggle).not.toBeNull(); - toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const toggle = expectElement(app, ".dreams__phase-toggle--on", HTMLButtonElement); + toggle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await app.updateComplete; expect(request).not.toHaveBeenCalledWith("config.patch", expect.anything()); - const confirmRestart = Array.from(app.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Confirm Restart", - ); - expect(confirmRestart).not.toBeUndefined(); - confirmRestart?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + const confirmRestart = expectButtonWithText(app, "Confirm Restart"); + confirmRestart.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await nextFrame(); await app.updateComplete; @@ -322,18 +336,18 @@ describe("control UI routing", () => { const app = mountApp("/chat"); await app.updateComplete; - expect(app.querySelector(".topnav-shell")).not.toBeNull(); - expect(app.querySelector(".topnav-shell__content")).not.toBeNull(); - expect(app.querySelector(".topnav-shell__actions")).not.toBeNull(); + expectElement(app, ".topnav-shell", HTMLElement); + expectElement(app, ".topnav-shell__content", HTMLElement); + expectElement(app, ".topnav-shell__actions", HTMLElement); expect(app.querySelector(".topnav-shell .brand-title")).toBeNull(); - expect(app.querySelector(".sidebar-shell")).not.toBeNull(); - expect(app.querySelector(".sidebar-shell__header")).not.toBeNull(); - expect(app.querySelector(".sidebar-shell__body")).not.toBeNull(); - expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull(); - expect(app.querySelector(".sidebar-brand")).not.toBeNull(); - expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull(); - expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull(); + expectElement(app, ".sidebar-shell", HTMLElement); + expectElement(app, ".sidebar-shell__header", HTMLElement); + expectElement(app, ".sidebar-shell__body", HTMLElement); + expectElement(app, ".sidebar-shell__footer", HTMLElement); + expectElement(app, ".sidebar-brand", HTMLElement); + expectElement(app, ".sidebar-brand__logo", HTMLElement); + expectElement(app, ".sidebar-brand__copy", HTMLElement); app.hello = { ok: true, @@ -342,65 +356,45 @@ describe("control UI routing", () => { app.requestUpdate(); await app.updateComplete; - const version = app.querySelector(".sidebar-version"); - const statusDot = app.querySelector(".sidebar-version__status"); - expect(version).not.toBeNull(); - expect(statusDot).not.toBeNull(); - expect(statusDot?.getAttribute("aria-label")).toContain("Online"); + expectElement(app, ".sidebar-version", HTMLElement); + const statusDot = expectElement(app, ".sidebar-version__status", HTMLElement); + expect(statusDot.getAttribute("aria-label")).toContain("Online"); app.applySettings({ ...app.settings, navWidth: 360 }); await app.updateComplete; expect(app.querySelector(".sidebar-resizer")).toBeNull(); - const shell = app.querySelector(".shell"); - expect(shell?.style.getPropertyValue("--shell-nav-width")).toBe(""); + const shell = expectElement(app, ".shell", HTMLElement); + expect(shell.style.getPropertyValue("--shell-nav-width")).toBe(""); - const split = app.querySelector(".chat-split-container"); - expect(split).not.toBeNull(); - if (split) { - split.classList.add("chat-split-container--open"); - await app.updateComplete; - expect(split.classList.contains("chat-split-container--open")).toBe(true); - } + const split = expectElement(app, ".chat-split-container", HTMLElement); + split.classList.add("chat-split-container--open"); + await app.updateComplete; + expect(split.classList.contains("chat-split-container--open")).toBe(true); - const chatMain = app.querySelector(".chat-main"); - expect(chatMain).not.toBeNull(); + expectElement(app, ".chat-main", HTMLElement); - const topShell = app.querySelector(".topnav-shell"); - const content = app.querySelector(".topnav-shell__content"); - expect(topShell).not.toBeNull(); - expect(content).not.toBeNull(); - if (!topShell || !content) { - return; - } + const topShell = expectElement(app, ".topnav-shell", HTMLElement); + const content = expectElement(app, ".topnav-shell__content", HTMLElement); expect(topShell.classList.contains("topnav-shell")).toBe(true); expect(content.classList.contains("topnav-shell__content")).toBe(true); - expect(topShell.querySelector(".topbar-nav-toggle")).not.toBeNull(); + expectElement(topShell, ".topbar-nav-toggle", HTMLElement); expect(topShell.children[1]).toBe(content); - expect(topShell.querySelector(".topnav-shell__actions")).not.toBeNull(); + expectElement(topShell, ".topnav-shell__actions", HTMLElement); - const toggle = app.querySelector(".topbar-nav-toggle"); - const actions = app.querySelector(".topnav-shell__actions"); - expect(toggle).not.toBeNull(); - expect(actions).not.toBeNull(); - if (!toggle || !actions || !shell) { - return; - } + const toggle = expectElement(app, ".topbar-nav-toggle", HTMLElement); + const actions = expectElement(app, ".topnav-shell__actions", HTMLElement); expect(toggle.classList.contains("topbar-nav-toggle")).toBe(true); expect(toggle.classList.contains("sidebar-menu-trigger")).toBe(true); expect(actions.classList.contains("topnav-shell__actions")).toBe(true); expect(topShell.firstElementChild).toBe(toggle); expect(topShell.querySelector(".topbar-nav-toggle")).toBe(toggle); - expect(actions.querySelector(".topbar-search")).not.toBeNull(); + expectElement(actions, ".topbar-search", HTMLElement); expect(toggle.getAttribute("aria-label")).toEqual(expect.stringMatching(/\S/u)); - const nav = app.querySelector(".shell-nav"); - expect(nav).not.toBeNull(); - if (!nav) { - return; - } + const nav = expectElement(app, ".shell-nav", HTMLElement); expect(shell.classList.contains("shell--nav-drawer-open")).toBe(false); toggle.click(); @@ -410,9 +404,8 @@ describe("control UI routing", () => { expect(nav.classList.contains("shell-nav")).toBe(true); expect(toggle.getAttribute("aria-expanded")).toBe("true"); - const link = app.querySelector('a.nav-item[href="/channels"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + const link = expectElement(app, 'a.nav-item[href="/channels"]', HTMLAnchorElement); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("channels"); @@ -424,37 +417,26 @@ describe("control UI routing", () => { expect(app.querySelector(".nav-section__label")).toBeNull(); expect(app.querySelector(".sidebar-brand__logo")).toBeNull(); - expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull(); - expect(app.querySelector(".sidebar-utility-link")).not.toBeNull(); + expectElement(app, ".sidebar-shell__footer", HTMLElement); + expectElement(app, ".sidebar-utility-link", HTMLElement); - const item = app.querySelector(".sidebar .nav-item"); - const header = app.querySelector(".sidebar-shell__header"); - const sidebar = app.querySelector(".sidebar"); - expect(item).not.toBeNull(); - expect(header).not.toBeNull(); - expect(sidebar).not.toBeNull(); - if (!item || !header || !sidebar) { - return; - } + const item = expectElement(app, ".sidebar .nav-item", HTMLElement); + const header = expectElement(app, ".sidebar-shell__header", HTMLElement); + const sidebar = expectElement(app, ".sidebar", HTMLElement); expect(sidebar.classList.contains("sidebar--collapsed")).toBe(true); - expect(item.querySelector(".nav-item__icon")).not.toBeNull(); + expectElement(item, ".nav-item__icon", HTMLElement); expect(item.querySelector(".nav-item__text")).toBeNull(); expect(app.querySelector(".sidebar-brand__copy")).toBeNull(); - expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull(); + expectElement(header, ".nav-collapse-toggle", HTMLElement); }); it("closes mobile chat controls on Escape, outside pointerdown, and tab changes", async () => { const app = mountApp("/chat"); await app.updateComplete; - const toggle = app.querySelector(".chat-controls-mobile-toggle"); - const dropdown = app.querySelector(".chat-controls-dropdown"); - expect(toggle).not.toBeNull(); - expect(dropdown).not.toBeNull(); - if (!toggle || !dropdown) { - return; - } + const toggle = expectElement(app, ".chat-controls-mobile-toggle", HTMLButtonElement); + const dropdown = expectElement(app, ".chat-controls-dropdown", HTMLElement); toggle.focus(); toggle.click(); @@ -489,7 +471,7 @@ describe("control UI routing", () => { expect(app.chatMobileControlsOpen).toBe(false); expect(closedDropdown?.classList.contains("open")).toBe(false); - app.querySelector(".chat-controls-mobile-toggle")?.click(); + expectElement(app, ".chat-controls-mobile-toggle", HTMLButtonElement).click(); await app.updateComplete; expect(app.chatMobileControlsOpen).toBe(true); @@ -502,9 +484,8 @@ describe("control UI routing", () => { const app = mountApp("/sessions?session=agent:main:subagent:task-123"); await app.updateComplete; - const link = app.querySelector('a.nav-item[href="/chat"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + const link = expectElement(app, 'a.nav-item[href="/chat"]', HTMLAnchorElement); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("chat"); @@ -512,35 +493,30 @@ describe("control UI routing", () => { expect(window.location.pathname).toBe("/chat"); expect(window.location.search).toBe("?session=agent%3Amain%3Asubagent%3Atask-123"); - const shell = app.querySelector(".shell"); - expect(shell).not.toBeNull(); - expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + const shell = expectElement(app, ".shell", HTMLElement); + expect(shell.classList.contains("shell--chat-focus")).toBe(false); - const toggle = app.querySelector('button[title^="Toggle focus mode"]'); - expect(toggle).not.toBeNull(); - toggle?.click(); + const toggle = expectElement(app, 'button[title^="Toggle focus mode"]', HTMLButtonElement); + toggle.click(); await app.updateComplete; - expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + expect(shell.classList.contains("shell--chat-focus")).toBe(true); - const channelsLink = app.querySelector('a.nav-item[href="/channels"]'); - expect(channelsLink).not.toBeNull(); - channelsLink?.dispatchEvent( + const channelsLink = expectElement(app, 'a.nav-item[href="/channels"]', HTMLAnchorElement); + channelsLink.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), ); await app.updateComplete; expect(app.tab).toBe("channels"); - expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + expect(shell.classList.contains("shell--chat-focus")).toBe(false); - const chatLink = app.querySelector('a.nav-item[href="/chat"]'); - chatLink?.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), - ); + const chatLink = expectElement(app, 'a.nav-item[href="/chat"]', HTMLAnchorElement); + chatLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; expect(app.tab).toBe("chat"); - expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + expect(shell.classList.contains("shell--chat-focus")).toBe(true); }); it("auto-scrolls chat history to the latest message", async () => { @@ -551,34 +527,32 @@ describe("control UI routing", () => { const app = mountApp("/chat"); await app.updateComplete; - const initialContainer: HTMLElement | null = app.querySelector(".chat-thread"); - expect(initialContainer).not.toBeNull(); - if (!initialContainer) { - return; - } - initialContainer.style.maxHeight = "180px"; - initialContainer.style.overflow = "auto"; + const initialContainer = app.querySelector(".chat-thread"); + expect(initialContainer).toBeInstanceOf(HTMLElement); + const initialThread = initialContainer!; + initialThread.style.maxHeight = "180px"; + initialThread.style.overflow = "auto"; let scrollTop = 0; - Object.defineProperty(initialContainer, "clientHeight", { + Object.defineProperty(initialThread, "clientHeight", { configurable: true, get: () => 180, }); - Object.defineProperty(initialContainer, "scrollHeight", { + Object.defineProperty(initialThread, "scrollHeight", { configurable: true, get: () => 2400, }); - Object.defineProperty(initialContainer, "scrollTop", { + Object.defineProperty(initialThread, "scrollTop", { configurable: true, get: () => scrollTop, set: (value: number) => { scrollTop = value; }, }); - initialContainer.scrollTo = ((options?: ScrollToOptions | number, y?: number) => { + initialThread.scrollTo = ((options?: ScrollToOptions | number, y?: number) => { const top = typeof options === "number" ? (y ?? 0) : typeof options?.top === "number" ? options.top : 0; scrollTop = Math.max(0, Math.min(top, 2400 - 180)); - }) as typeof initialContainer.scrollTo; + }) as typeof initialThread.scrollTo; app.chatMessages = Array.from({ length: 3 }, (_, index) => ({ role: "assistant", @@ -591,35 +565,33 @@ describe("control UI routing", () => { await nextFrame(); } - const container = app.querySelector(".chat-thread"); - expect(container).not.toBeNull(); - if (!container) { - return; - } + const container = app.querySelector(".chat-thread"); + expect(container).toBeInstanceOf(HTMLElement); + const thread = container!; let finalScrollTop = 0; - Object.defineProperty(container, "clientHeight", { + Object.defineProperty(thread, "clientHeight", { value: 180, configurable: true, }); - Object.defineProperty(container, "scrollHeight", { + Object.defineProperty(thread, "scrollHeight", { value: 960, configurable: true, }); - Object.defineProperty(container, "scrollTop", { + Object.defineProperty(thread, "scrollTop", { configurable: true, get: () => finalScrollTop, set: (value: number) => { finalScrollTop = value; }, }); - Object.defineProperty(container, "scrollTo", { + Object.defineProperty(thread, "scrollTo", { configurable: true, value: ({ top }: { top: number }) => { finalScrollTop = top; }, }); - const targetScrollTop = container.scrollHeight; - expect(targetScrollTop).toBeGreaterThan(container.clientHeight); + const targetScrollTop = thread.scrollHeight; + expect(targetScrollTop).toBeGreaterThan(thread.clientHeight); app.chatMessages = [ ...app.chatMessages, { @@ -630,12 +602,12 @@ describe("control UI routing", () => { ]; await app.updateComplete; for (let i = 0; i < 10; i++) { - if (container.scrollTop === targetScrollTop) { + if (thread.scrollTop === targetScrollTop) { break; } await nextFrame(); } - expect(container.scrollTop).toBe(targetScrollTop); + expect(thread.scrollTop).toBe(targetScrollTop); }); it("hydrates hash tokens, restores same-tab refreshes, and clears after gateway changes", async () => { @@ -658,12 +630,13 @@ describe("control UI routing", () => { undefined, ); - const gatewayUrlInput = refreshed.querySelector( + const gatewayUrlInput = expectElement( + refreshed, 'input[placeholder="ws://100.x.y.z:18789"]', + HTMLInputElement, ); - expect(gatewayUrlInput).not.toBeNull(); - gatewayUrlInput!.value = "wss://other-gateway.example/openclaw"; - gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true })); + gatewayUrlInput.value = "wss://other-gateway.example/openclaw"; + gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true })); await refreshed.updateComplete; expect(refreshed.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index e8f39474a9d..f4d8c970318 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -571,8 +571,17 @@ describe("loadSettings default gateway URL derivation", () => { const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}"); - expect(persisted.sessionsByGateway).toEqual(expect.any(Object)); - const scopes = Object.keys(persisted.sessionsByGateway); + const sessionsByGateway = persisted.sessionsByGateway as unknown; + expect(sessionsByGateway).toEqual( + expect.objectContaining({ + "wss://gateway.example:8443": { + sessionKey: "agent:current:main", + lastActiveSessionKey: "agent:current:main", + }, + }), + ); + const scopedSessions = sessionsByGateway as Record; + const scopes = Object.keys(scopedSessions); expect(scopes).toHaveLength(10); // oldest stale entries should be evicted expect(scopes).not.toContain("wss://stale-0.example:8443"); @@ -580,10 +589,6 @@ describe("loadSettings default gateway URL derivation", () => { // newest stale entries and the current gateway should be retained expect(scopes).toContain("wss://stale-10.example:8443"); expect(scopes).toContain("wss://gateway.example:8443"); - expect(persisted.sessionsByGateway["wss://gateway.example:8443"]).toEqual({ - sessionKey: "agent:current:main", - lastActiveSessionKey: "agent:current:main", - }); }); it("persists local user identity separately from gateway settings", () => { diff --git a/ui/src/ui/usage-helpers.node.test.ts b/ui/src/ui/usage-helpers.node.test.ts index 4ad05476b2c..5adbb0f0a17 100644 --- a/ui/src/ui/usage-helpers.node.test.ts +++ b/ui/src/ui/usage-helpers.node.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest"; import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts"; +function requireFirstTool(tools: Array<[string, number]>): [string, number] { + const tool = tools[0]; + if (!tool) { + throw new Error("expected parsed tool summary entry"); + } + return tool; +} + describe("usage-helpers", () => { it("tokenizes query terms including quoted strings", () => { const terms = extractQueryTerms('agent:main "model:gpt-5.2" has:errors'); @@ -40,7 +48,8 @@ describe("usage-helpers", () => { ); expect(res.summary).toContain("read"); expect(res.summary).toContain("exec"); - expect(res.tools[0]?.[0]).toBe("read"); - expect(res.tools[0]?.[1]).toBe(2); + const firstTool = requireFirstTool(res.tools); + expect(firstTool[0]).toBe("read"); + expect(firstTool[1]).toBe(2); }); }); diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index 8a64ae7833e..6f6406c369f 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -164,11 +164,11 @@ describe("agents tools panel (browser)", () => { const group = container.querySelector(".agent-tools-group"); const tool = container.querySelector(".agent-tool-card"); - expect(group).not.toBeNull(); - expect(tool).not.toBeNull(); + expect(group?.classList.contains("agent-tools-group")).toBe(true); + expect(tool?.classList.contains("agent-tool-card")).toBe(true); if (!group || !tool) { - return; + throw new Error("expected agent tool group and card"); } group.open = true; @@ -319,13 +319,13 @@ describe("agents tools panel (browser)", () => { '.agent-tools-runtime-chip[href="#agent-tool-read"]', ); - expect(group).not.toBeNull(); - expect(tool).not.toBeNull(); - expect(chip).not.toBeNull(); + expect(group?.classList.contains("agent-tools-group")).toBe(true); + expect(tool?.classList.contains("agent-tool-card")).toBe(true); + expect(chip?.getAttribute("href")).toBe("#agent-tool-read"); if (!group || !tool || !chip) { container.remove(); - return; + throw new Error("expected agent tool runtime chip"); } expect(group.open).toBe(false); diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index aa2ff0ff36c..3f502698aec 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -383,7 +383,9 @@ describe("renderAgentFiles", () => { container, ); - expect(container.querySelector(".md-preview-dialog__reader.sidebar-markdown")).not.toBeNull(); + expect(container.querySelectorAll(".md-preview-dialog__reader.sidebar-markdown")).toHaveLength( + 1, + ); expect(container.querySelector(".md-preview-dialog__path")?.textContent?.trim()).toBe( "USER.md", ); @@ -486,14 +488,17 @@ describe("renderAgentFiles", () => { const panel = container.querySelector(".md-preview-dialog__panel"); const expandButton = container.querySelector(".md-preview-expand-btn"); - expandButton?.click(); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + expect(panel).toBeInstanceOf(HTMLElement); + expect(expandButton).toBeInstanceOf(HTMLButtonElement); + expandButton!.click(); expect(panel?.classList.contains("fullscreen")).toBe(true); expect(expandButton?.classList.contains("is-fullscreen")).toBe(true); expect(expandButton?.getAttribute("aria-pressed")).toBe("true"); expect(expandButton?.getAttribute("aria-label")).toBe("Collapse preview"); - dialog?.dispatchEvent(new Event("close")); + dialog!.dispatchEvent(new Event("close")); expect(panel?.classList.contains("fullscreen")).toBe(false); expect(expandButton?.classList.contains("is-fullscreen")).toBe(false); diff --git a/ui/src/ui/views/channels.test.ts b/ui/src/ui/views/channels.test.ts index 1db3175b419..d353aabe277 100644 --- a/ui/src/ui/views/channels.test.ts +++ b/ui/src/ui/views/channels.test.ts @@ -173,7 +173,9 @@ describe("WhatsApp card actions", () => { expect(labels).not.toContain("Relink"); expect(labels).not.toContain("Wait for scan"); - buttons.find((button) => button.textContent?.trim() === "Show QR")?.click(); + const showQr = buttons.find((button) => button.textContent?.trim() === "Show QR"); + expect(showQr).toBeInstanceOf(HTMLButtonElement); + showQr!.click(); expect(onWhatsAppStart).toHaveBeenCalledWith(false); }); @@ -187,7 +189,9 @@ describe("WhatsApp card actions", () => { expect(labels).toContain("Relink"); expect(labels).not.toContain("Show QR"); - buttons.find((button) => button.textContent?.trim() === "Relink")?.click(); + const relink = buttons.find((button) => button.textContent?.trim() === "Relink"); + expect(relink).toBeInstanceOf(HTMLButtonElement); + relink!.click(); expect(onWhatsAppStart).toHaveBeenCalledWith(true); }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 9ee6647e5a6..9946554d240 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -331,6 +331,17 @@ async function flushTasks() { await vi.dynamicImportSettled(); } +function getChatModelSelect(container: Element): HTMLSelectElement { + const select = container.querySelector( + 'select[data-chat-model-select="true"]', + ); + expect(select).toBeInstanceOf(HTMLSelectElement); + if (!(select instanceof HTMLSelectElement)) { + throw new Error("Expected chat model select"); + } + return select; +} + function renderChatView(overrides: Partial[0]> = {}) { const container = document.createElement("div"); render( @@ -427,7 +438,8 @@ describe("chat compaction divider", () => { const button = container.querySelector(".chat-divider__action"); expect(button?.textContent).toContain("Open checkpoints"); - button?.click(); + expect(button).toBeInstanceOf(HTMLButtonElement); + button!.click(); expect(onOpenSessionCheckpoints).toHaveBeenCalledTimes(1); }); @@ -445,7 +457,7 @@ describe("chat loading skeleton", () => { it("shows the skeleton while the initial history load has no rendered content", () => { const container = renderChatView({ loading: true }); - expect(container.querySelector(".chat-loading-skeleton")).not.toBeNull(); + expect(container.querySelectorAll(".chat-loading-skeleton")).toHaveLength(1); expect(container.querySelector(".agent-chat__welcome")).toBeNull(); }); @@ -484,7 +496,7 @@ describe("chat loading skeleton", () => { }); expect(container.querySelector(".chat-loading-skeleton")).toBeNull(); - expect(container.querySelector(".chat-reading-indicator")).not.toBeNull(); + expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(1); }); }); @@ -492,7 +504,7 @@ describe("chat voice controls", () => { it("keeps Talk visible without the stale browser dictation button", () => { const container = renderChatView(); - expect(container.querySelector('[aria-label="Start Talk"]')).not.toBeNull(); + expect(container.querySelectorAll('[aria-label="Start Talk"]')).toHaveLength(1); expect(container.querySelector('[aria-label="Voice input"]')).toBeNull(); }); @@ -509,7 +521,9 @@ describe("chat voice controls", () => { 'Realtime voice provider "openai" is not configured', ); - container.querySelector('[aria-label="Dismiss error"]')?.click(); + const dismiss = container.querySelector('[aria-label="Dismiss error"]'); + expect(dismiss).toBeInstanceOf(HTMLButtonElement); + dismiss!.click(); expect(onDismissError).toHaveBeenCalledTimes(1); }); @@ -518,14 +532,14 @@ describe("chat voice controls", () => { describe("chat slash menu accessibility", () => { function inputDraft(container: HTMLElement, value: string) { const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); + expect(textarea).toBeInstanceOf(HTMLTextAreaElement); textarea!.value = value; textarea!.dispatchEvent(new Event("input", { bubbles: true })); } function keydownComposer(container: HTMLElement, key: string) { const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); + expect(textarea).toBeInstanceOf(HTMLTextAreaElement); textarea!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); } @@ -656,12 +670,12 @@ describe("chat attachment picker", () => { const input = container.querySelector(".agent-chat__file-input"); const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" }); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); Object.defineProperty(input!, "files", { configurable: true, value: [file], }); - input?.dispatchEvent(new Event("change", { bubbles: true })); + input!.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => { expect(onAttachmentsChange).toHaveBeenCalledWith([ @@ -676,7 +690,7 @@ describe("chat attachment picker", () => { const nextAttachments = onAttachmentsChange.mock.calls[0]?.[0] ?? []; expect(getChatAttachmentDataUrl(nextAttachments[0])).toMatch(/^data:application\/pdf;base64,/); const preview = renderChatView({ attachments: nextAttachments }); - expect(preview.querySelector(".chat-attachment-thumb--file")).not.toBeNull(); + expect(preview.querySelectorAll(".chat-attachment-thumb--file")).toHaveLength(1); expect(preview.textContent).toContain("brief.pdf"); }); @@ -686,12 +700,12 @@ describe("chat attachment picker", () => { const input = container.querySelector(".agent-chat__file-input"); const file = new File(["video"], "clip.mp4", { type: "video/mp4" }); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); Object.defineProperty(input!, "files", { configurable: true, value: [file], }); - input?.dispatchEvent(new Event("change", { bubbles: true })); + input!.dispatchEvent(new Event("change", { bubbles: true })); expect(onAttachmentsChange).not.toHaveBeenCalled(); }); @@ -769,7 +783,6 @@ describe("chat welcome", () => { let container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null }); const avatar = container.querySelector(".agent-chat__avatar"); - expect(avatar).not.toBeNull(); expect(avatar?.tagName).toBe("DIV"); expect(avatar?.textContent).toContain("VC"); expect(avatar?.getAttribute("aria-label")).toBe("Val"); @@ -780,7 +793,6 @@ describe("chat welcome", () => { }); const imageAvatar = container.querySelector("img"); - expect(imageAvatar).not.toBeNull(); expect(imageAvatar?.getAttribute("src")).toBe("blob:identity-avatar"); expect(imageAvatar?.getAttribute("alt")).toBe("Val"); @@ -789,7 +801,6 @@ describe("chat welcome", () => { const fallbackAvatar = container.querySelector( ".agent-chat__avatar--logo img", ); - expect(fallbackAvatar).not.toBeNull(); expect(fallbackAvatar?.getAttribute("src")).toBe("apple-touch-icon.png"); expect(fallbackAvatar?.getAttribute("alt")).toBe("Val"); }); @@ -871,7 +882,7 @@ describe("chat session controls", () => { const agentSelect = container.querySelector( 'select[data-chat-agent-filter="true"]', ); - expect(agentSelect).not.toBeNull(); + expect(agentSelect).toBeInstanceOf(HTMLSelectElement); agentSelect!.value = "beta"; agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); @@ -891,7 +902,7 @@ describe("chat session controls", () => { expect(notice?.getAttribute("role")).toBe("status"); expect(notice?.getAttribute("aria-live")).toBe("polite"); expect(notice?.textContent?.trim()).toBe("Switched to Coding"); - expect(container.querySelector(".chat-controls__session-row--flash")).not.toBeNull(); + expect(container.querySelectorAll(".chat-controls__session-row--flash")).toHaveLength(1); }); it("shows the active agent main session instead of a blank select when no row exists yet", () => { @@ -936,14 +947,11 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe(""); + const modelSelect = getChatModelSelect(container); + expect(modelSelect.value).toBe(""); - modelSelect!.value = "openai/gpt-5-mini"; - modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); + modelSelect.value = "openai/gpt-5-mini"; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", @@ -966,14 +974,11 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe("openai/gpt-5-mini"); + const modelSelect = getChatModelSelect(container); + expect(modelSelect.value).toBe("openai/gpt-5-mini"); - modelSelect!.value = ""; - modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); + modelSelect.value = ""; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", @@ -991,11 +996,8 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); - expect(modelSelect?.disabled).toBe(true); + const modelSelect = getChatModelSelect(container); + expect(modelSelect.disabled).toBe(true); }); it("keeps the selected model visible when the active session is absent from sessions.list", async () => { @@ -1003,20 +1005,15 @@ describe("chat session controls", () => { const container = document.createElement("div"); render(renderChatSessionSelect(state), container); - const modelSelect = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(modelSelect).not.toBeNull(); + const modelSelect = getChatModelSelect(container); - modelSelect!.value = "openai/gpt-5-mini"; - modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); + modelSelect.value = "openai/gpt-5-mini"; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); render(renderChatSessionSelect(state), container); - const rerendered = container.querySelector( - 'select[data-chat-model-select="true"]', - ); - expect(rerendered?.value).toBe("openai/gpt-5-mini"); + const rerendered = getChatModelSelect(container); + expect(rerendered.value).toBe("openai/gpt-5-mini"); }); it("uses default thinking options when the active session is absent", () => { diff --git a/ui/src/ui/views/command-palette.test.ts b/ui/src/ui/views/command-palette.test.ts index b010ac5f54e..b96317e49ed 100644 --- a/ui/src/ui/views/command-palette.test.ts +++ b/ui/src/ui/views/command-palette.test.ts @@ -39,6 +39,22 @@ function restoreShowModalDescriptor() { delete (HTMLDialogElement.prototype as Partial).showModal; } +function expectPaletteInput(): HTMLInputElement { + const input = container.querySelector("#cmd-palette-input"); + if (!(input instanceof HTMLInputElement)) { + throw new Error("Expected command palette input"); + } + return input; +} + +function expectPaletteDialog(): HTMLDialogElement { + const dialog = container.querySelector("dialog.cmd-palette-overlay"); + if (!(dialog instanceof HTMLDialogElement)) { + throw new Error("Expected command palette dialog"); + } + return dialog; +} + function createProps(overrides: Partial = {}): CommandPaletteProps { return { open: true, @@ -141,31 +157,27 @@ describe("command palette", () => { await renderPalette({ query: "overview", activeIndex: 0 }); const dialog = container.querySelector("dialog.cmd-palette-overlay"); - expect(dialog).not.toBeNull(); - expect(dialog!.open).toBe(true); - expect(dialog!.hasAttribute("role")).toBe(false); - expect(dialog!.hasAttribute("aria-modal")).toBe(false); - expect(dialog!.getAttribute("aria-labelledby")).toBe("cmd-palette-label"); + expect(dialog?.open).toBe(true); + expect(dialog?.hasAttribute("role")).toBe(false); + expect(dialog?.hasAttribute("aria-modal")).toBe(false); + expect(dialog?.getAttribute("aria-labelledby")).toBe("cmd-palette-label"); const label = container.querySelector("#cmd-palette-label"); const input = container.querySelector("#cmd-palette-input"); const listbox = container.querySelector("#cmd-palette-listbox"); expect(label?.textContent).toBe("Type a command…"); expect(label?.getAttribute("for")).toBe("cmd-palette-input"); - expect(input).not.toBeNull(); - expect(input!.getAttribute("role")).toBe("combobox"); - expect(input!.getAttribute("aria-autocomplete")).toBe("list"); - expect(input!.getAttribute("aria-expanded")).toBe("true"); - expect(input!.getAttribute("aria-controls")).toBe("cmd-palette-listbox"); - expect(input!.getAttribute("aria-activedescendant")).toBe("cmd-palette-option-nav-overview"); + expect(input?.getAttribute("role")).toBe("combobox"); + expect(input?.getAttribute("aria-autocomplete")).toBe("list"); + expect(input?.getAttribute("aria-expanded")).toBe("true"); + expect(input?.getAttribute("aria-controls")).toBe("cmd-palette-listbox"); + expect(input?.getAttribute("aria-activedescendant")).toBe("cmd-palette-option-nav-overview"); expect(document.activeElement).toBe(input); - expect(listbox).not.toBeNull(); - expect(listbox!.getAttribute("role")).toBe("listbox"); - const option = listbox!.querySelector("#cmd-palette-option-nav-overview"); - expect(option).not.toBeNull(); - expect(option!.getAttribute("role")).toBe("option"); - expect(option!.getAttribute("aria-selected")).toBe("true"); + expect(listbox?.getAttribute("role")).toBe("listbox"); + const option = listbox?.querySelector("#cmd-palette-option-nav-overview"); + expect(option?.getAttribute("role")).toBe("option"); + expect(option?.getAttribute("aria-selected")).toBe("true"); }); it("traps Tab on the combobox and restores focus on Escape", async () => { @@ -176,8 +188,7 @@ describe("command palette", () => { const onToggle = vi.fn(); await renderPalette({ onToggle }); - const input = container.querySelector("#cmd-palette-input"); - expect(input).not.toBeNull(); + const input = expectPaletteInput(); expect(document.activeElement).toBe(input); const tab = new KeyboardEvent("keydown", { @@ -185,7 +196,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input!.dispatchEvent(tab); + input.dispatchEvent(tab); expect(tab.defaultPrevented).toBe(true); expect(document.activeElement).toBe(input); @@ -194,7 +205,7 @@ describe("command palette", () => { bubbles: true, cancelable: true, }); - input!.dispatchEvent(escape); + input.dispatchEvent(escape); expect(escape.defaultPrevented).toBe(true); expect(onToggle).toHaveBeenCalledTimes(1); @@ -206,19 +217,18 @@ describe("command palette", () => { it("does not toggle twice when Escape is followed by dialog cancel", async () => { const onToggle = vi.fn(); await renderPalette({ onToggle }); - const dialog = container.querySelector("dialog.cmd-palette-overlay"); - const input = container.querySelector("#cmd-palette-input"); - expect(dialog).not.toBeNull(); - expect(input).not.toBeNull(); + const dialog = expectPaletteDialog(); + const input = expectPaletteInput(); + expect(dialog.open).toBe(true); - input!.dispatchEvent( + input.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }), ); - dialog!.dispatchEvent(new Event("cancel", { cancelable: true })); + dialog.dispatchEvent(new Event("cancel", { cancelable: true })); expect(onToggle).toHaveBeenCalledTimes(1); }); diff --git a/ui/src/ui/views/config-presets.test.ts b/ui/src/ui/views/config-presets.test.ts index b72e0f731b2..e174bb504db 100644 --- a/ui/src/ui/views/config-presets.test.ts +++ b/ui/src/ui/views/config-presets.test.ts @@ -7,7 +7,9 @@ describe("detectActivePreset", () => { for (const preset of CONFIG_PRESETS) { const defaults = preset.patch.agents.defaults; - expect(() => OpenClawSchema.parse(preset.patch), preset.id).not.toThrow(); + expect(OpenClawSchema.safeParse(preset.patch), preset.id).toMatchObject({ + success: true, + }); expect(defaults.bootstrapMaxChars, preset.id).toBeGreaterThan(0); expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThan(0); expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThanOrEqual( diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 17cf2f8cf0c..0317bb18360 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -4,6 +4,23 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { renderQuickSettings, type QuickSettingsProps } from "./config-quick.ts"; +function expectButtonByText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === text, + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button labelled ${text}`); + } + return button; +} + +function expectFileInput(input: Element | null | undefined): HTMLInputElement { + if (!(input instanceof HTMLInputElement)) { + throw new Error("Expected file input"); + } + return input; +} + function createProps(overrides: Partial = {}): QuickSettingsProps { return { currentModel: "gpt-5.5", @@ -67,14 +84,23 @@ describe("renderQuickSettings", () => { render(renderQuickSettings(createProps()), container); - expect(container.querySelector(".qs-card--model")).not.toBeNull(); - expect(container.querySelector(".qs-card--channels")).not.toBeNull(); - expect(container.querySelector(".qs-card--security")).not.toBeNull(); - expect(container.querySelector(".qs-card--appearance")).not.toBeNull(); - expect(container.querySelector(".qs-card--automations")).not.toBeNull(); - expect(container.querySelector(".qs-side-stack .qs-card--appearance")).not.toBeNull(); - expect(container.querySelector(".qs-side-stack .qs-card--automations")).not.toBeNull(); - expect(container.querySelector(".qs-card--personal")).not.toBeNull(); + expect( + Array.from(container.querySelectorAll(".qs-card")) + .map((card) => + Array.from(card.classList).find( + (className) => className.startsWith("qs-card--") && className !== "qs-card--span-all", + ), + ) + .filter(Boolean), + ).toEqual([ + "qs-card--model", + "qs-card--channels", + "qs-card--security", + "qs-card--personal", + "qs-card--appearance", + "qs-card--automations", + ]); + expect(container.querySelectorAll(".qs-side-stack .qs-card")).toHaveLength(2); expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1); }); @@ -203,9 +229,9 @@ describe("renderQuickSettings", () => { const input = inputs.find((node) => node.closest(".qs-identity-card--assistant"), ) as HTMLInputElement | null; - expect(input).not.toBeNull(); + expect(input?.type).toBe("file"); if (!input) { - return; + throw new Error("expected assistant avatar file input"); } Object.defineProperty(input, "files", { @@ -244,11 +270,7 @@ describe("renderQuickSettings", () => { expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain( "UI override", ); - const clear = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Clear override", - ); - expect(clear).not.toBeUndefined(); - clear?.dispatchEvent(new Event("click")); + expectButtonByText(container, "Clear override").dispatchEvent(new Event("click")); expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1); }); @@ -298,13 +320,11 @@ describe("renderQuickSettings", () => { const container = document.createElement("div"); render(renderQuickSettings(createProps({ onUserAvatarChange })), container); - const input = Array.from(container.querySelectorAll('input[type="file"]')).find( - (node) => !node.closest(".qs-identity-card--assistant"), - ) as HTMLInputElement | null; - expect(input).not.toBeNull(); - if (!input) { - return; - } + const input = expectFileInput( + Array.from(container.querySelectorAll('input[type="file"]')).find( + (node) => !node.closest(".qs-identity-card--assistant"), + ), + ); const file = new File([new Uint8Array(1_500_001)], "avatar.png", { type: "image/png" }); Object.defineProperty(input, "files", { @@ -349,10 +369,7 @@ describe("renderQuickSettings", () => { container, ); - const customButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Import", - ); - customButton?.click(); + expectButtonByText(container, "Import").click(); expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1); expect(setTheme).not.toHaveBeenCalled(); @@ -376,10 +393,7 @@ describe("renderQuickSettings", () => { container, ); - const customButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Light Green", - ); - customButton?.click(); + expectButtonByText(container, "Light Green").click(); expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object)); expect(onOpenCustomThemeImport).not.toHaveBeenCalled(); diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index d2512cb4be9..85bb57ee5a6 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -76,6 +76,17 @@ describe("config view", () => { }; } + function requireActionButton( + button: HTMLButtonElement | undefined, + text: string, + ): HTMLButtonElement { + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${text} action button`); + } + return button; + } + function renderConfigView(overrides: Partial = {}): { container: HTMLElement; props: ConfigProps; @@ -121,9 +132,14 @@ describe("config view", () => { return button; } - function queryRequired(container: HTMLElement, selector: string): Element { + function queryRequired( + container: HTMLElement, + selector: string, + constructor: new () => T, + ): T { const element = container.querySelector(selector); - if (!element) { + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { throw new Error(`Expected element matching "${selector}"`); } return element; @@ -153,10 +169,11 @@ describe("config view", () => { formMode: "form", formValue: { mixed: "x" }, }); - let { saveButton, applyButton } = findActionButtons(container); - expect(saveButton).not.toBeUndefined(); - expect(saveButton?.disabled).toBe(false); - expect(applyButton?.disabled).toBe(false); + let actionButtons = findActionButtons(container); + let saveButton = requireActionButton(actionButtons.saveButton, "Save"); + let applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(saveButton.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); renderCase({ schema: null, @@ -164,24 +181,24 @@ describe("config view", () => { formValue: { gateway: { mode: "local" } }, originalValue: {}, }); - ({ saveButton, applyButton } = findActionButtons(container)); - expect(saveButton).not.toBeUndefined(); - expect(saveButton?.disabled).toBe(true); - expect(applyButton?.disabled).toBe(true); + actionButtons = findActionButtons(container); + saveButton = requireActionButton(actionButtons.saveButton, "Save"); + applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(saveButton.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); renderCase({ formMode: "raw", raw: "{\n}\n", originalRaw: "{\n}\n", }); - let clearButton: HTMLButtonElement | undefined; - ({ clearButton, saveButton, applyButton } = findActionButtons(container)); - expect(clearButton).not.toBeUndefined(); - expect(saveButton).not.toBeUndefined(); - expect(applyButton).not.toBeUndefined(); - expect(clearButton?.disabled).toBe(true); - expect(saveButton?.disabled).toBe(true); - expect(applyButton?.disabled).toBe(true); + actionButtons = findActionButtons(container); + let clearButton = requireActionButton(actionButtons.clearButton, "Clear"); + saveButton = requireActionButton(actionButtons.saveButton, "Save"); + applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(clearButton.disabled).toBe(true); + expect(saveButton.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); const onReset = vi.fn(); renderCase({ @@ -190,14 +207,15 @@ describe("config view", () => { originalRaw: "{\n}\n", onReset, }); - ({ clearButton, saveButton, applyButton } = findActionButtons(container)); - expect(saveButton).not.toBeUndefined(); - expect(applyButton).not.toBeUndefined(); - expect(clearButton?.disabled).toBe(false); - expect(saveButton?.disabled).toBe(false); - expect(applyButton?.disabled).toBe(false); + actionButtons = findActionButtons(container); + clearButton = requireActionButton(actionButtons.clearButton, "Clear"); + saveButton = requireActionButton(actionButtons.saveButton, "Save"); + applyButton = requireActionButton(actionButtons.applyButton, "Apply"); + expect(clearButton.disabled).toBe(false); + expect(saveButton.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); - clearButton?.click(); + clearButton.click(); expect(onReset).toHaveBeenCalledTimes(1); }); @@ -222,26 +240,30 @@ describe("config view", () => { renderCase({ saving: true }); let busyButton = findButtonContainingText(container, "Saving…"); - let { clearButton, applyButton } = findActionButtons(container); + let actionButtons = findActionButtons(container); + let clearButton = requireActionButton(actionButtons.clearButton, "Clear"); + let applyButton = requireActionButton(actionButtons.applyButton, "Apply"); expect(busyButton.disabled).toBe(true); expect(busyButton.getAttribute("aria-busy")).toBe("true"); - expect(busyButton.querySelector(".config-action-spinner")).not.toBeNull(); - expect(clearButton?.disabled).toBe(false); - expect(applyButton?.disabled).toBe(false); + expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); + expect(clearButton.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); renderCase({ applying: true }); busyButton = findButtonContainingText(container, "Applying…"); - ({ clearButton } = findActionButtons(container)); + actionButtons = findActionButtons(container); + clearButton = requireActionButton(actionButtons.clearButton, "Clear"); expect(busyButton.disabled).toBe(true); - expect(busyButton.querySelector(".config-action-spinner")).not.toBeNull(); - expect(clearButton?.disabled).toBe(false); + expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); + expect(clearButton.disabled).toBe(false); renderCase({ updating: true }); busyButton = findButtonContainingText(container, "Updating…"); - ({ clearButton } = findActionButtons(container)); + actionButtons = findActionButtons(container); + clearButton = requireActionButton(actionButtons.clearButton, "Clear"); expect(busyButton.disabled).toBe(true); - expect(busyButton.querySelector(".config-action-spinner")).not.toBeNull(); - expect(clearButton?.disabled).toBe(false); + expect(busyButton.querySelectorAll(".config-action-spinner")).toHaveLength(1); + expect(clearButton.disabled).toBe(false); }); it("switches mode via the sidebar toggle", () => { @@ -281,26 +303,20 @@ describe("config view", () => { originalValue: { gateway: { mode: "local" } }, }); - const formButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Form", - ); - const rawButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Raw", - ); - expect(formButton?.classList.contains("active")).toBe(true); - expect(rawButton?.disabled).toBe(true); - const rawNotice = container.querySelector(".config-actions__notice"); - const actionButtons = container.querySelector(".config-actions__buttons"); - expect(rawNotice).not.toBeNull(); - expect(actionButtons).not.toBeNull(); - expect(actionButtons?.textContent).toContain("Reload"); - expect(actionButtons?.textContent).toContain("Update"); + const formButton = findButtonByText(container, "Form"); + const rawButton = findButtonByText(container, "Raw"); + expect(formButton.classList.contains("active")).toBe(true); + expect(rawButton.disabled).toBe(true); + queryRequired(container, ".config-actions__notice", HTMLElement); + const actionButtons = queryRequired(container, ".config-actions__buttons", HTMLElement); + expect(actionButtons.textContent).toContain("Reload"); + expect(actionButtons.textContent).toContain("Update"); expect(normalizedText(container)).toContain( "Raw mode disabled (snapshot cannot safely round-trip raw text).", ); expect(container.querySelector(".config-raw-field")).toBeNull(); - rawButton?.click(); + rawButton.click(); expect(onFormModeChange).not.toHaveBeenCalled(); }); @@ -366,7 +382,7 @@ describe("config view", () => { }, }); - const content = queryRequired(container, ".config-content") as HTMLElement; + const content = queryRequired(container, ".config-content", HTMLElement); content.scrollTop = 280; content.scrollLeft = 24; content.scrollTo = vi.fn(({ top, left }: { top?: number; left?: number }) => { @@ -397,17 +413,14 @@ describe("config view", () => { container, ); - const icon = container.querySelector(".config-search__icon"); - expect(icon).not.toBeNull(); - expect(icon?.closest(".config-search__input-row")).not.toBeNull(); + const icon = queryRequired(container, ".config-search__icon", SVGElement); + expect(icon.closest(".config-search__input-row")).toBeInstanceOf(HTMLElement); const input = container.querySelector(".config-search__input"); - expect(input).not.toBeNull(); - if (!input) { - return; - } - (input as HTMLInputElement).value = "gateway"; - input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input).toBeInstanceOf(HTMLInputElement); + const searchInput = input as HTMLInputElement; + searchInput.value = "gateway"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); expect(onSearchChange).toHaveBeenCalledWith("gateway"); }); @@ -511,18 +524,11 @@ describe("config view", () => { expect(text).not.toContain("supersecret"); expect(container.querySelector("textarea")).toBeNull(); - const revealButton = container.querySelector(".config-raw-toggle"); - if (!revealButton) { - throw new Error("Expected raw config reveal button"); - } + const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); revealButton.click(); - const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); - expect(textarea?.value).toContain("supersecret"); - if (!textarea) { - return; - } + const textarea = queryRequired(container, "textarea", HTMLTextAreaElement); + expect(textarea.value).toContain("supersecret"); textarea.value = textarea.value.replace("supersecret", "updatedsecret"); textarea.dispatchEvent(new Event("input", { bubbles: true })); expect(onRawChange).toHaveBeenCalledWith(textarea.value); @@ -565,10 +571,9 @@ describe("config view", () => { expect(normalizedText(container)).toContain("View pending changes"); expect(normalizedText(container)).not.toContain("gateway.mode"); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); const text = normalizedText(container); expect(updateCount).toBe(1); @@ -640,10 +645,9 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); const text = normalizedText(container); expect(text).toContain("channels.discord.token.id"); @@ -651,9 +655,8 @@ describe("config view", () => { expect(text).not.toContain("TOKEN_BEFORE"); expect(text).not.toContain("TOKEN_AFTER"); - const revealButton = container.querySelector(".config-raw-toggle"); - expect(revealButton).not.toBeNull(); - revealButton!.click(); + const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); + revealButton.click(); const revealedText = normalizedText(container); expect(revealedText).toContain("TOKEN_BEFORE"); @@ -688,13 +691,11 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); - const revealButton = container.querySelector(".config-raw-toggle"); - expect(revealButton).not.toBeNull(); - revealButton!.click(); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); + const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); + revealButton.click(); expect(normalizedText(container)).toContain("TOKEN_A_AFTER"); props.configPath = "/tmp/openclaw-b.json5"; @@ -751,10 +752,9 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); const text = normalizedText(container); expect(text).toContain("integrations.foo.bar.credential"); @@ -791,10 +791,9 @@ describe("config view", () => { ); rerender(); - const details = container.querySelector(".config-diff"); - expect(details).not.toBeNull(); - details!.open = true; - details!.dispatchEvent(new Event("toggle")); + const details = queryRequired(container, ".config-diff", HTMLDetailsElement); + details.open = true; + details.dispatchEvent(new Event("toggle")); expect(normalizedText(container)).toContain("gateway.mode"); props.raw = props.originalRaw; @@ -849,7 +848,7 @@ describe("config view", () => { }); const input = container.querySelector(".cfg-input"); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); expect(input?.readOnly).toBe(true); expect(input?.value).toBe(""); expect(input?.placeholder).toContain("Structured value (SecretRef)"); @@ -878,9 +877,8 @@ describe("config view", () => { container, ); - const rawUnavailableInput = container.querySelector(".cfg-input"); - expect(rawUnavailableInput).not.toBeNull(); - expect(rawUnavailableInput?.placeholder).toBe( + const rawUnavailableInput = queryRequired(container, ".cfg-input", HTMLInputElement); + expect(rawUnavailableInput.placeholder).toBe( "Structured value (SecretRef) - edit the config file directly", ); }); @@ -915,7 +913,7 @@ describe("config view", () => { }); const input = container.querySelector(".cfg-input"); - expect(input).not.toBeNull(); + expect(input).toBeInstanceOf(HTMLInputElement); expect(input?.readOnly).toBe(false); expect(input?.value).toContain("malformed"); expect(input?.value).not.toBe("[object Object]"); @@ -937,16 +935,14 @@ describe("config view", () => { onOpenCustomThemeImport, }); - const customButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Import", - ); + const customButton = findButtonByText(container, "Import"); - expect(customButton?.disabled).toBe(false); + expect(customButton.disabled).toBe(false); expect(normalizedText(container)).toContain( "Click Import to add one browser-local tweakcn theme", ); - customButton?.click(); + customButton.click(); expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1); }); @@ -959,12 +955,10 @@ describe("config view", () => { customThemeImportFocusToken: 1, }); - const importButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Import theme"), - ); + const importButton = findButtonContainingText(container, "Import theme"); - expect(importButton?.disabled).toBe(true); - expect(container.querySelector(".settings-theme-import__input")).not.toBeNull(); + expect(importButton.disabled).toBe(true); + queryRequired(container, ".settings-theme-import__input", HTMLInputElement); expect( container.querySelector(".settings-theme-import__external")?.href, ).toBe("https://tweakcn.com/editor/theme"); @@ -989,21 +983,15 @@ describe("config view", () => { onCustomThemeImportUrlChange, }); - const customButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Light Green", - ); - expect(customButton?.disabled).toBe(false); - customButton?.click(); + const customButton = findButtonByText(container, "Light Green"); + expect(customButton.disabled).toBe(false); + customButton.click(); expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object)); - const replaceButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Replace Light Green"), - ); - const clearButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Clear Light Green"), - ); - replaceButton?.click(); - clearButton?.click(); + const replaceButton = findButtonContainingText(container, "Replace Light Green"); + const clearButton = findButtonContainingText(container, "Clear Light Green"); + replaceButton.click(); + clearButton.click(); expect(onImportCustomTheme).toHaveBeenCalledTimes(1); expect(onClearCustomTheme).toHaveBeenCalledTimes(1); diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index e1fdf776e16..b17e84966d8 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -77,10 +77,39 @@ function createProps(overrides: Partial = {}): CronProps { }; } -function getButtonByText(container: Element, text: string) { - return Array.from(container.querySelectorAll("button")).find( +function getButtonByText(container: Element, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( (btn) => btn.textContent?.trim() === text, ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text "${text}"`); + } + return button; +} + +function getButtonByAnyText(container: Element, texts: string[]): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find((btn) => + texts.includes(btn.textContent?.trim() ?? ""), + ); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected button with text ${texts.join(" or ")}`); + } + return button; +} + +function getElement( + container: Element, + selector: string, + constructor: new () => T, +): T { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(constructor); + if (!(element instanceof constructor)) { + throw new Error(`Expected ${selector} to match ${constructor.name}`); + } + return element; } describe("cron view", () => { @@ -116,13 +145,11 @@ describe("cron view", () => { expect(container.textContent).toContain("All delivery"); expect(container.textContent).not.toContain("multi-select"); - const statusOk = container.querySelector( + const statusOk = getElement( + container, '.cron-filter-dropdown[data-filter="status"] input[value="ok"]', + HTMLInputElement, ); - expect(statusOk).not.toBeNull(); - if (!(statusOk instanceof HTMLInputElement)) { - return; - } statusOk.checked = true; statusOk.dispatchEvent(new Event("change", { bubbles: true })); @@ -131,25 +158,21 @@ describe("cron view", () => { expect(container.textContent).toContain("Due"); expect(container.textContent).not.toContain("Next 13"); - const scheduleSelect = container.querySelector( + const scheduleSelect = getElement( + container, 'select[data-test-id="cron-jobs-schedule-filter"]', + HTMLSelectElement, ); - expect(scheduleSelect).not.toBeNull(); - if (!(scheduleSelect instanceof HTMLSelectElement)) { - return; - } scheduleSelect.value = "cron"; scheduleSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsScheduleKindFilter: "cron" }); - const lastRunSelect = container.querySelector( + const lastRunSelect = getElement( + container, 'select[data-test-id="cron-jobs-last-status-filter"]', + HTMLSelectElement, ); - expect(lastRunSelect).not.toBeNull(); - if (!(lastRunSelect instanceof HTMLSelectElement)) { - return; - } lastRunSelect.value = "error"; lastRunSelect.dispatchEvent(new Event("change", { bubbles: true })); @@ -165,9 +188,12 @@ describe("cron view", () => { container, ); - const reset = container.querySelector('button[data-test-id="cron-jobs-filters-reset"]'); - expect(reset).not.toBeNull(); - reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const reset = getElement( + container, + 'button[data-test-id="cron-jobs-filters-reset"]', + HTMLButtonElement, + ); + reset.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); @@ -199,26 +225,26 @@ describe("cron view", () => { container, ); - const selected = container.querySelector(".list-item-selected"); - expect(selected).not.toBeNull(); + getElement(container, ".list-item-selected", HTMLElement); - const row = container.querySelector(".list-item-clickable"); - expect(row).not.toBeNull(); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const row = getElement(container, ".list-item-clickable", HTMLElement); + row.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).toHaveBeenCalledWith("job-1"); const historyButton = Array.from(container.querySelectorAll("button")).find( (btn) => btn.textContent?.trim() === "History", ); - expect(historyButton).not.toBeUndefined(); - historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(historyButton).toBeInstanceOf(HTMLButtonElement); + if (!(historyButton instanceof HTMLButtonElement)) { + throw new Error("Expected History button"); + } + historyButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).toHaveBeenCalledTimes(2); expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1"); expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1"); const link = container.querySelector("a.session-link"); - expect(link).not.toBeNull(); expect(link?.getAttribute("href")).toContain( "/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc", ); @@ -229,11 +255,14 @@ describe("cron view", () => { const runHistoryCard = cards.find( (card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history", ); - expect(runHistoryCard).not.toBeUndefined(); + expect(runHistoryCard).toBeInstanceOf(Element); + if (!(runHistoryCard instanceof Element)) { + throw new Error("Expected run history card"); + } - const summaries = Array.from( - runHistoryCard?.querySelectorAll(".cron-run-entry__body") ?? [], - ).map((el) => (el.textContent ?? "").trim()); + const summaries = Array.from(runHistoryCard.querySelectorAll(".cron-run-entry__body")).map( + (el) => (el.textContent ?? "").trim(), + ); expect(summaries[0]).toBe("newer run"); expect(summaries[1]).toBe("older run"); }); @@ -289,12 +318,15 @@ describe("cron view", () => { render(renderCron(expandedProps), container); - const collapseButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); - expect(collapseButton).not.toBeNull(); - expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); - collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const collapseButton = getElement( + container, + '[data-test-id="cron-form-collapse-toggle"]', + HTMLButtonElement, + ); + expect(collapseButton.getAttribute("aria-expanded")).toBe("true"); + collapseButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleFormCollapsed).toHaveBeenCalledWith(true); - expect(container.querySelector(".cron-form")).not.toBeNull(); + getElement(container, ".cron-form", HTMLElement); const collapsedProps = createProps() as CronProps & { cronFormCollapsed: boolean; @@ -305,14 +337,18 @@ describe("cron view", () => { render(renderCron(collapsedProps), container); - const collapsedButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]'); - expect(container.querySelector(".cron-workspace--form-collapsed")).not.toBeNull(); - expect(container.querySelector(".cron-workspace-form--collapsed")).not.toBeNull(); - expect(collapsedButton?.getAttribute("aria-expanded")).toBe("false"); + const collapsedButton = getElement( + container, + '[data-test-id="cron-form-collapse-toggle"]', + HTMLButtonElement, + ); + expect(container.querySelectorAll(".cron-workspace--form-collapsed")).toHaveLength(1); + expect(container.querySelectorAll(".cron-workspace-form--collapsed")).toHaveLength(1); + expect(collapsedButton.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".cron-form")?.hasAttribute("hidden")).toBe(true); expect(container.querySelector(".cron-form-actions")?.hasAttribute("hidden")).toBe(true); - collapsedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + collapsedButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleFormCollapsed).toHaveBeenLastCalledWith(false); }); @@ -338,23 +374,23 @@ describe("cron view", () => { expect(container.textContent).toContain("https://example.invalid/cron"); }); - it("does not throw when a stale cron job has no payload", () => { + it("renders a stale cron job with no payload", () => { const container = document.createElement("div"); const job = { ...createJob("job-broken"), payload: undefined, } as unknown as CronJob; - expect(() => - render( - renderCron( - createProps({ - jobs: [job], - }), - ), - container, + render( + renderCron( + createProps({ + jobs: [job], + }), ), - ).not.toThrow(); + container, + ); + + expect(container.textContent).toContain("Daily ping"); }); it("renders cron job prompts and run summaries as sanitized markdown", () => { @@ -388,22 +424,22 @@ describe("cron view", () => { container, ); - const prompt = container.querySelector(".cron-job-detail-value.chat-text"); - expect(prompt?.querySelector("strong")?.textContent).toBe("Ship"); - expect(prompt?.querySelector("a")?.getAttribute("href")).toBe("https://example.com"); - expect(prompt?.querySelector("script")).toBeNull(); + const prompt = getElement(container, ".cron-job-detail-value.chat-text", HTMLElement); + expect(prompt.querySelector("strong")?.textContent).toBe("Ship"); + expect(prompt.querySelector("a")?.getAttribute("href")).toBe("https://example.com"); + expect(prompt.querySelector("script")).toBeNull(); - const promptLink = prompt?.querySelector("a"); - promptLink?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const promptLink = getElement(prompt, "a", HTMLAnchorElement); + promptLink.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).not.toHaveBeenCalled(); - const row = container.querySelector(".cron-job"); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const row = getElement(container, ".cron-job", HTMLElement); + row.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onLoadRuns).toHaveBeenCalledWith("job-md"); const runBody = container.querySelector(".cron-run-entry__body.chat-text"); expect(runBody?.querySelector("strong")?.textContent).toBe("markdown"); - expect(runBody?.querySelector("table")).not.toBeNull(); + expect(runBody?.querySelectorAll("table")).toHaveLength(1); }); it("shows run errors in one place when no summary exists", () => { @@ -476,8 +512,7 @@ describe("cron view", () => { ); const editButton = getButtonByText(container, "Edit"); - expect(editButton).not.toBeUndefined(); - editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + editButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onEdit).toHaveBeenCalledWith(job); expect(onLoadRuns).toHaveBeenCalledWith("job-3"); @@ -485,8 +520,7 @@ describe("cron view", () => { expect(container.textContent).toContain("Save changes"); const cancelButton = getButtonByText(container, "Cancel"); - expect(cancelButton).not.toBeUndefined(); - cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cancelButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onCancelEdit).toHaveBeenCalledTimes(1); }); @@ -515,7 +549,6 @@ describe("cron view", () => { expect(container.textContent).toContain("Best effort delivery"); const staggerGroup = container.querySelector(".cron-stagger-group"); - expect(staggerGroup).not.toBeNull(); expect(staggerGroup?.textContent).toContain("Stagger window"); expect(staggerGroup?.textContent).toContain("Stagger unit"); expect(container.textContent).toContain( @@ -531,9 +564,8 @@ describe("cron view", () => { expect(container.textContent).toContain("Execution"); expect(container.textContent).toContain("Delivery"); - const checkboxLabel = container.querySelector(".cron-checkbox"); - expect(checkboxLabel).not.toBeNull(); - const firstElement = checkboxLabel?.firstElementChild; + const checkboxLabel = getElement(container, ".cron-checkbox", HTMLLabelElement); + const firstElement = checkboxLabel.firstElementChild; expect(firstElement?.tagName.toLowerCase()).toBe("input"); render( @@ -549,7 +581,6 @@ describe("cron view", () => { ); const agentInput = container.querySelector('input[placeholder="main or ops"]'); - expect(agentInput).not.toBeNull(); expect(agentInput instanceof HTMLInputElement).toBe(true); expect(agentInput instanceof HTMLInputElement ? agentInput.disabled : false).toBe(true); @@ -601,11 +632,8 @@ describe("cron view", () => { expect(container.textContent).toContain("Can't add job yet"); expect(container.textContent).toContain("Fix 3 fields to continue."); - const saveButton = Array.from(container.querySelectorAll("button")).find((btn) => - ["Add job", "Save changes"].includes(btn.textContent?.trim() ?? ""), - ); - expect(saveButton).not.toBeUndefined(); - expect(saveButton?.disabled).toBe(true); + const saveButton = getButtonByAnyText(container, ["Add job", "Save changes"]); + expect(saveButton.disabled).toBe(true); render( renderCron( @@ -666,24 +694,19 @@ describe("cron view", () => { ); const cloneButton = getButtonByText(container, "Clone"); - expect(cloneButton).not.toBeUndefined(); - cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cloneButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const enableButton = getButtonByText(container, "Disable"); - expect(enableButton).not.toBeUndefined(); - enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + enableButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const runButton = getButtonByText(container, "Run"); - expect(runButton).not.toBeUndefined(); - runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const runDueButton = getButtonByText(container, "Run if due"); - expect(runDueButton).not.toBeUndefined(); - runDueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + runDueButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); const removeButton = getButtonByText(container, "Remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClone).toHaveBeenCalledWith(actionJob); expect(onToggle).toHaveBeenCalledWith(actionJob, false); @@ -715,19 +738,28 @@ describe("cron view", () => { container, ); - expect(container.querySelector("datalist#cron-agent-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-model-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull(); - expect(container.querySelector("datalist#cron-delivery-account-suggestions")).not.toBeNull(); - expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull(); - expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull(); + expect(Array.from(container.querySelectorAll("datalist")).map((node) => node.id)).toEqual([ + "cron-agent-suggestions", + "cron-model-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]); expect( - container.querySelector('input[list="cron-delivery-account-suggestions"]'), - ).not.toBeNull(); + Array.from(container.querySelectorAll("input[list]")).map((node) => + node.getAttribute("list"), + ), + ).toEqual( + expect.arrayContaining([ + "cron-agent-suggestions", + "cron-model-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]), + ); + expect(container.querySelectorAll("input[list]")).toHaveLength(6); }); }); diff --git a/ui/src/ui/views/debug.test.ts b/ui/src/ui/views/debug.test.ts index eeaf9599da8..b7766c773ba 100644 --- a/ui/src/ui/views/debug.test.ts +++ b/ui/src/ui/views/debug.test.ts @@ -58,7 +58,10 @@ describe("renderDebug", () => { ); const command = container.querySelector(".callout .mono"); - expect(command?.textContent).toBe("openclaw security audit --deep"); + if (!command) { + throw new Error("expected debug security audit command"); + } + expect(command.textContent).toBe("openclaw security audit --deep"); expect(container.textContent).toContain("安全审计"); }); }); diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 07c5c446ba3..7df4e601f37 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -197,12 +197,20 @@ function renderInto(props: DreamingProps): HTMLDivElement { return container; } +function expectElement(container: Element, selector: string): Element { + const element = container.querySelector(selector); + expect(element).toBeInstanceOf(Element); + if (!(element instanceof Element)) { + throw new Error(`Expected element matching ${selector}`); + } + return element; +} + describe("dreaming view", () => { it("renders the active dream scene chrome and status", () => { const container = renderInto(buildProps({ dreamingOf: "reindexing old chats\u2026" })); - const svg = container.querySelector(".dreams__lobster svg"); - expect(svg).not.toBeNull(); + expectElement(container, ".dreams__lobster svg"); const zs = container.querySelectorAll(".dreams__z"); expect(zs.length).toBe(3); @@ -210,7 +218,7 @@ describe("dreaming view", () => { const stars = container.querySelectorAll(".dreams__star"); expect(stars.length).toBe(12); - expect(container.querySelector(".dreams__moon")).not.toBeNull(); + expectElement(container, ".dreams__moon"); const phases = [...container.querySelectorAll(".dreams__phase-name")].map((node) => node.textContent?.trim(), @@ -225,7 +233,7 @@ describe("dreaming view", () => { expect(buttons).not.toContain("Backfill"); expect(buttons).not.toContain("Reset"); expect(buttons).not.toContain("Clear Replayed"); - expect(container.querySelector(".dreams__bubble")).not.toBeNull(); + expectElement(container, ".dreams__bubble"); const text = container.querySelector(".dreams__bubble-text"); expect(text?.textContent).toBe("reindexing old chats\u2026"); const label = container.querySelector(".dreams__status-label"); @@ -243,7 +251,7 @@ describe("dreaming view", () => { const idleContainer = renderInto(buildProps({ active: false })); expect(idleContainer.querySelector(".dreams__bubble")).toBeNull(); expect(idleContainer.querySelector(".dreams__status-label")?.textContent).toBe("Dreaming Idle"); - expect(idleContainer.querySelector(".dreams--idle")).not.toBeNull(); + expectElement(idleContainer, ".dreams--idle"); const unknownPhaseContainer = renderInto(buildProps({ phases: undefined })); const statuses = [...unknownPhaseContainer.querySelectorAll(".dreams__phase-next")].map( @@ -286,9 +294,14 @@ describe("dreaming view", () => { content: "# ChatGPT Export: BA flight receipts process", }); const container = renderInto(buildProps({ onOpenWikiPage })); - container - .querySelectorAll(".dreams-diary__insight-actions .btn")[1] - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const openSourceButton = container.querySelectorAll( + ".dreams-diary__insight-actions .btn", + )[1]; + expect(openSourceButton).toBeInstanceOf(HTMLButtonElement); + if (!(openSourceButton instanceof HTMLButtonElement)) { + throw new Error("Expected imported source button"); + } + openSourceButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); expect(onOpenWikiPage).toHaveBeenCalledWith("sources/chatgpt-2026-04-10-alpha.md"); setDreamDiarySubTab("dreams"); @@ -314,9 +327,14 @@ describe("dreaming view", () => { }); rerender(); - container - .querySelectorAll(".dreams-diary__insight-actions .btn")[1] - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const openSourceButton = container.querySelectorAll( + ".dreams-diary__insight-actions .btn", + )[1]; + expect(openSourceButton).toBeInstanceOf(HTMLButtonElement); + if (!(openSourceButton instanceof HTMLButtonElement)) { + throw new Error("Expected imported source button"); + } + openSourceButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); await Promise.resolve(); @@ -324,9 +342,11 @@ describe("dreaming view", () => { "6001 total lines", ); - container - .querySelector(".dreams-diary__preview-header .btn") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const closePreviewButton = container.querySelector( + ".dreams-diary__preview-header .btn", + ); + expect(closePreviewButton).toBeInstanceOf(HTMLButtonElement); + closePreviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); setDreamDiarySubTab("dreams"); setDreamSubTab("scene"); }); @@ -360,9 +380,11 @@ describe("dreaming view", () => { expect(container.textContent).toContain("Memory Wiki is not enabled"); expect(container.textContent).toContain("plugins.entries.memory-wiki.enabled = true"); - container - .querySelector(".dreams-diary__empty-actions .btn") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const configButton = container.querySelector( + ".dreams-diary__empty-actions .btn", + ); + expect(configButton).toBeInstanceOf(HTMLButtonElement); + configButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onOpenConfig).toHaveBeenCalledTimes(1); setDreamDiarySubTab("dreams"); setDreamSubTab("scene"); @@ -375,8 +397,7 @@ describe("dreaming view", () => { const title = container.querySelector(".dreams-diary__title"); expect(title?.textContent).toContain("Dream Diary"); - const entry = container.querySelector(".dreams-diary__entry"); - expect(entry).not.toBeNull(); + expectElement(container, ".dreams-diary__entry"); const date = container.querySelector(".dreams-diary__date"); expect(date?.textContent).toContain("April 5, 2026"); const body = container.querySelector(".dreams-diary__para"); @@ -497,7 +518,7 @@ describe("dreaming view", () => { setDreamSubTab("diary"); setDreamDiarySubTab("dreams"); const emptyContainer = renderInto(buildProps({ dreamDiaryContent: null })); - expect(emptyContainer.querySelector(".dreams-diary__empty")).not.toBeNull(); + expect(emptyContainer.querySelectorAll(".dreams-diary__empty")).toHaveLength(1); expect(emptyContainer.querySelector(".dreams-diary__empty-text")?.textContent).toContain( "No dreams yet", ); diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts index d916219f861..c2ad7ad877e 100644 --- a/ui/src/ui/views/exec-approval.test.ts +++ b/ui/src/ui/views/exec-approval.test.ts @@ -50,12 +50,18 @@ function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDes async function getRenderedDialog() { const modal = container.querySelector("openclaw-modal-dialog"); - expect(modal).not.toBeNull(); - await modal!.updateComplete; + expect(modal).toBeInstanceOf(HTMLElement); + if (!modal) { + throw new Error("Expected openclaw-modal-dialog"); + } + await modal.updateComplete; await nextFrame(); - const dialog = modal!.shadowRoot?.querySelector("dialog"); - expect(dialog).not.toBeNull(); - return { modal: modal!, dialog: dialog! }; + const dialog = modal.shadowRoot?.querySelector("dialog"); + expect(dialog).toBeInstanceOf(HTMLDialogElement); + if (!(dialog instanceof HTMLDialogElement)) { + throw new Error("Expected rendered dialog"); + } + return { modal, dialog }; } function dispatchEscape(target: EventTarget) { @@ -242,7 +248,6 @@ describe("approval and confirmation modals", () => { ); const { dialog } = await getRenderedDialog(); - expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull(); dispatchEscape(dialog); @@ -263,7 +268,6 @@ describe("approval and confirmation modals", () => { ); const { dialog } = await getRenderedDialog(); - expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull(); dispatchEscape(dialog); diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index 14a472fbdfb..219f2dcf5ee 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -192,7 +192,6 @@ describe("renderLoginGate", () => { await Promise.resolve(); const alert = container.querySelector('[role="alert"]'); - expect(alert).not.toBeNull(); expect(alert?.dataset.kind).toBe("protocol-mismatch"); expect(alert?.textContent).toContain("Protocol mismatch"); expect(alert?.textContent).toContain("openclaw dashboard"); diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index d2a9140db7a..c0de9fa6ab5 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -191,7 +191,8 @@ describe("sessions view", () => { expect(toggle?.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".sessions-filter-bar")).toBeNull(); - toggle?.click(); + expect(toggle).toBeInstanceOf(HTMLButtonElement); + toggle!.click(); expect(onToggleFiltersCollapsed).toHaveBeenCalledTimes(1); }); @@ -492,8 +493,9 @@ describe("sessions view", () => { ); await Promise.resolve(); - const row = container.querySelector("tbody tr.session-data-row"); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const row = container.querySelector("tbody tr.session-data-row"); + expect(row).toBeInstanceOf(HTMLTableRowElement); + row!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).toHaveBeenCalledWith("agent:main:main"); const tokenCell = container.querySelector(".session-token-cell"); @@ -531,7 +533,8 @@ describe("sessions view", () => { expect(trigger?.getAttribute("aria-expanded")).toBe("false"); expect(container.querySelector(".session-checkpoint-toggle")).toBeNull(); - trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(trigger).toBeInstanceOf(HTMLButtonElement); + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).toHaveBeenCalledWith("agent:main:main"); }); @@ -623,9 +626,14 @@ describe("sessions view", () => { await Promise.resolve(); const rows = container.querySelectorAll("tbody tr.session-data-row"); - const checkbox = rows[0]?.querySelector("input[type=checkbox]"); - checkbox?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - rows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const checkbox = rows[0]?.querySelector("input[type=checkbox]"); + expect(checkbox).toBeInstanceOf(HTMLInputElement); + expect(rows[1]).toBeInstanceOf(HTMLTableRowElement); + if (!(checkbox instanceof HTMLInputElement) || !(rows[1] instanceof HTMLTableRowElement)) { + throw new Error("Expected checkpoint toggle row controls"); + } + checkbox.dispatchEvent(new MouseEvent("click", { bubbles: true })); + rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onToggleCheckpointDetails).not.toHaveBeenCalled(); }); @@ -727,8 +735,9 @@ describe("sessions view", () => { ); await Promise.resolve(); - const headerCheckbox = container.querySelector("thead input[type=checkbox]"); - headerCheckbox?.dispatchEvent(new Event("change", { bubbles: true })); + const headerCheckbox = container.querySelector("thead input[type=checkbox]"); + expect(headerCheckbox).toBeInstanceOf(HTMLInputElement); + headerCheckbox!.dispatchEvent(new Event("change", { bubbles: true })); expect(onDeselectPage).toHaveBeenCalledWith(["page-0"]); expect(onDeselectAll).not.toHaveBeenCalled(); diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index 25e1d6f6773..f8e73bb6c6a 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -148,7 +148,11 @@ describe("renderSkills", () => { expect(showModal).toHaveBeenCalledTimes(1); expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true); - container.querySelector(".md-preview-dialog__header .btn")?.click(); + const closeButton = container.querySelector( + ".md-preview-dialog__header .btn", + ); + expect(closeButton).toBeInstanceOf(HTMLButtonElement); + closeButton!.click(); expect(onDetailClose).toHaveBeenCalledTimes(1); @@ -178,10 +182,12 @@ describe("renderSkills", () => { expect(text).toContain("GitHub integration for OpenClaw"); expect(text).toContain("v1.2.3"); - container.querySelector(".list-item")?.click(); - container - .querySelector(".list-item .btn.btn--sm") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const resultItem = container.querySelector(".list-item"); + const installButton = container.querySelector(".list-item .btn.btn--sm"); + expect(resultItem).toBeInstanceOf(HTMLElement); + expect(installButton).toBeInstanceOf(HTMLButtonElement); + resultItem!.click(); + installButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClawHubDetailOpen).toHaveBeenCalledTimes(1); expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); @@ -234,9 +240,11 @@ describe("renderSkills", () => { expect(text).toContain("Platforms: macos, linux"); expect(text).toContain("Added search support"); - container - .querySelector(".md-preview-dialog__body .btn.primary") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const detailInstallButton = container.querySelector( + ".md-preview-dialog__body .btn.primary", + ); + expect(detailInstallButton).toBeInstanceOf(HTMLButtonElement); + detailInstallButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClawHubInstall).toHaveBeenCalledTimes(1); expect(onClawHubInstall).toHaveBeenCalledWith("github"); diff --git a/ui/src/ui/views/usage-render-details.test.ts b/ui/src/ui/views/usage-render-details.test.ts index c17053f4de8..a37a4720047 100644 --- a/ui/src/ui/views/usage-render-details.test.ts +++ b/ui/src/ui/views/usage-render-details.test.ts @@ -46,6 +46,15 @@ const baseUsage = { }, } satisfies NonNullable; +function expectFilteredUsage( + result: ReturnType, +): NonNullable> { + if (!result) { + throw new Error("Expected filtered usage result"); + } + return result; +} + describe("computeFilteredUsage", () => { it("returns undefined when no points match the range", () => { const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 2000 })]; @@ -60,10 +69,11 @@ describe("computeFilteredUsage", () => { makePoint({ timestamp: 3000, totalTokens: 300, cost: 0.3 }), ]; const result = computeFilteredUsage(baseUsage, points, 1000, 2000); - expect(result).toMatchObject({ + const filtered = expectFilteredUsage(result); + expect(filtered).toMatchObject({ totalTokens: 300, // 100 + 200 }); - expect(result?.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 + expect(filtered.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2 }); it("handles reversed range (end < start)", () => { @@ -81,18 +91,22 @@ describe("computeFilteredUsage", () => { makePoint({ timestamp: 2000, input: 0, output: 20 }), makePoint({ timestamp: 3000, input: 5, output: 15 }), ]; - const result = computeFilteredUsage(baseUsage, points, 1000, 3000); - expect(result!.messageCounts!.user).toBe(2); // points with input > 0 - expect(result!.messageCounts!.assistant).toBe(2); // points with output > 0 - expect(result!.messageCounts!.total).toBe(3); + const result = expectFilteredUsage(computeFilteredUsage(baseUsage, points, 1000, 3000)); + expect(result).toMatchObject({ + messageCounts: { + user: 2, // points with input > 0 + assistant: 2, // points with output > 0 + total: 3, + }, + }); }); it("computes duration from first to last filtered point", () => { const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 5000 })]; - const result = computeFilteredUsage(baseUsage, points, 1000, 5000); - expect(result!.durationMs).toBe(4000); - expect(result!.firstActivity).toBe(1000); - expect(result!.lastActivity).toBe(5000); + const result = expectFilteredUsage(computeFilteredUsage(baseUsage, points, 1000, 5000)); + expect(result.durationMs).toBe(4000); + expect(result.firstActivity).toBe(1000); + expect(result.lastActivity).toBe(5000); }); it("aggregates token types (input, output, cacheRead, cacheWrite)", () => { @@ -100,11 +114,11 @@ describe("computeFilteredUsage", () => { makePoint({ timestamp: 1000, input: 10, output: 20, cacheRead: 30, cacheWrite: 40 }), makePoint({ timestamp: 2000, input: 5, output: 15, cacheRead: 25, cacheWrite: 35 }), ]; - const result = computeFilteredUsage(baseUsage, points, 1000, 2000); - expect(result!.input).toBe(15); - expect(result!.output).toBe(35); - expect(result!.cacheRead).toBe(55); - expect(result!.cacheWrite).toBe(75); + const result = expectFilteredUsage(computeFilteredUsage(baseUsage, points, 1000, 2000)); + expect(result.input).toBe(15); + expect(result.output).toBe(35); + expect(result.cacheRead).toBe(55); + expect(result.cacheWrite).toBe(75); }); });