diff --git a/.github/workflows/formal-conformance.yml b/.github/workflows/formal-conformance.yml index a8ec86bfce7..8ba6d7e56b8 100644 --- a/.github/workflows/formal-conformance.yml +++ b/.github/workflows/formal-conformance.yml @@ -108,6 +108,7 @@ jobs: - name: Comment on PR (informational) if: steps.drift.outputs.drift == 'true' + continue-on-error: true uses: actions/github-script@v7 with: script: | diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index e6c0914f018..61861a84be9 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -33,19 +33,17 @@ jobs: - name: Checkout CLI uses: actions/checkout@v4 - - name: Setup pnpm (corepack retry) - run: | - set -euo pipefail - corepack enable - for attempt in 1 2 3; do - if corepack prepare pnpm@10.23.0 --activate; then - pnpm -v - exit 0 - fi - echo "corepack prepare failed (attempt $attempt/3). Retrying..." - sleep $((attempt * 10)) - done - exit 1 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + check-latest: true + + - name: Setup pnpm + cache store + uses: ./.github/actions/setup-pnpm-store-cache + with: + pnpm-version: "10.23.0" + cache-key-suffix: "node22" - name: Install pnpm deps (minimal) run: pnpm install --ignore-scripts --frozen-lockfile diff --git a/AGENTS.md b/AGENTS.md index 8a48c040243..3cca4e68c38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,19 @@ - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. +## GHSA (Repo Advisory) Patch/Publish + +- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/` +- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"` +- Private fork PRs must be closed: + `fork=$(gh api /repos/openclaw/openclaw/security-advisories/ | jq -r .private_fork.full_name)` + `gh pr list -R "$fork" --state open` (must be empty) +- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings) +- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json` +- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint) +- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs +- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing + ## Troubleshooting - Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`). diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1e1eeea36..f2f431d31d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. +- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. - Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. - Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. @@ -13,6 +15,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. +- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. - Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. @@ -21,13 +25,17 @@ Docs: https://docs.openclaw.ai - TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. - TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. - TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. +- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry. - Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. - Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n. - Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz. - Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz. - Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n. +- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168. - Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. - Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. +- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent. +- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07. - CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. - Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. @@ -91,6 +99,7 @@ Docs: https://docs.openclaw.ai - Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn. - Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla. - Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev. +- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729) - Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader. - Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman. - Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras. @@ -102,6 +111,7 @@ Docs: https://docs.openclaw.ai - Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz. - Ollama/Agents: avoid forcing `` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. +- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc. - Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard. - Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks. - Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors. @@ -203,6 +213,7 @@ Docs: https://docs.openclaw.ai - Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh. - Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable. - Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories. +- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker. - Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. - Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj. - BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. @@ -333,6 +344,7 @@ Docs: https://docs.openclaw.ai - Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445. - Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55. - Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica. +- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context. - Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini. - Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon. - Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..31763115ae0 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,6 +2087,7 @@ public struct CronJob: Codable, Sendable { public let name: String public let description: String? public let enabled: Bool + public let notify: Bool? public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int @@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, + notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2118,6 +2120,7 @@ public struct CronJob: Codable, Sendable { self.name = name self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms @@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable { case name case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2167,6 +2171,7 @@ public struct CronAddParams: Codable, Sendable { public let agentid: AnyCodable? public let description: String? public let enabled: Bool? + public let notify: Bool? public let deleteafterrun: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable @@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable { agentid: AnyCodable?, description: String?, enabled: Bool?, + notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable { self.agentid = agentid self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2202,6 +2209,7 @@ public struct CronAddParams: Codable, Sendable { case agentid = "agentId" case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case schedule case sessiontarget = "sessionTarget" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..31763115ae0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,6 +2087,7 @@ public struct CronJob: Codable, Sendable { public let name: String public let description: String? public let enabled: Bool + public let notify: Bool? public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int @@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, + notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2118,6 +2120,7 @@ public struct CronJob: Codable, Sendable { self.name = name self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms @@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable { case name case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2167,6 +2171,7 @@ public struct CronAddParams: Codable, Sendable { public let agentid: AnyCodable? public let description: String? public let enabled: Bool? + public let notify: Bool? public let deleteafterrun: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable @@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable { agentid: AnyCodable?, description: String?, enabled: Bool?, + notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable { self.agentid = agentid self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2202,6 +2209,7 @@ public struct CronAddParams: Codable, Sendable { case agentid = "agentId" case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case schedule case sessiontarget = "sessionTarget" diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b1e5ef9a10c..82d66c23e7c 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -27,6 +27,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - **Main session**: enqueue a system event, then run on the next heartbeat. - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. +- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`. ## Quick start (actionable) @@ -288,7 +289,7 @@ Notes: - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `everyMs` is milliseconds. - `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. -- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), +- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. @@ -333,10 +334,19 @@ Notes: enabled: true, // default true store: "~/.openclaw/cron/jobs.json", maxConcurrentRuns: 1, // default 1 + webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint + webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token }, } ``` +Webhook behavior: + +- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`. +- Payload is the cron finished event JSON. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- If `cron.webhookToken` is not set, no `Authorization` header is sent. + Disable cron entirely: - `cron.enabled: false` (config) diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 1b3fb0394a3..6bd278846c5 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -105,7 +105,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w docker: { binds: [ // hostPath:containerPath:mode - "~/FriendsShared:/data:ro", + "/home/user/FriendsShared:/data:ro", ], }, }, diff --git a/docs/experiments/plans/pty-process-supervision.md b/docs/experiments/plans/pty-process-supervision.md new file mode 100644 index 00000000000..352850c82f6 --- /dev/null +++ b/docs/experiments/plans/pty-process-supervision.md @@ -0,0 +1,192 @@ +--- +summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup" +owner: "openclaw" +status: "in-progress" +last_updated: "2026-02-15" +title: "PTY and Process Supervision Plan" +--- + +# PTY and Process Supervision Plan + +## 1. Problem and goal + +We need one reliable lifecycle for long-running command execution across: + +- `exec` foreground runs +- `exec` background runs +- `process` follow up actions (`poll`, `log`, `send-keys`, `paste`, `submit`, `kill`, `remove`) +- CLI agent runner subprocesses + +The goal is not just to support PTY. The goal is predictable ownership, cancellation, timeout, and cleanup with no unsafe process matching heuristics. + +## 2. Scope and boundaries + +- Keep implementation internal in `src/process/supervisor`. +- Do not create a new package for this. +- Keep current behavior compatibility where practical. +- Do not broaden scope to terminal replay or tmux style session persistence. + +## 3. Implemented in this branch + +### Supervisor baseline already present + +- Supervisor module is in place under `src/process/supervisor/*`. +- Exec runtime and CLI runner are already routed through supervisor spawn and wait. +- Registry finalization is idempotent. + +### This pass completed + +1. Explicit PTY command contract + +- `SpawnInput` is now a discriminated union in `src/process/supervisor/types.ts`. +- PTY runs require `ptyCommand` instead of reusing generic `argv`. +- Supervisor no longer rebuilds PTY command strings from argv joins in `src/process/supervisor/supervisor.ts`. +- Exec runtime now passes `ptyCommand` directly in `src/agents/bash-tools.exec-runtime.ts`. + +2. Process layer type decoupling + +- Supervisor types no longer import `SessionStdin` from agents. +- Process local stdin contract lives in `src/process/supervisor/types.ts` (`ManagedRunStdin`). +- Adapters now depend only on process level types: + - `src/process/supervisor/adapters/child.ts` + - `src/process/supervisor/adapters/pty.ts` + +3. Process tool lifecycle ownership improvement + +- `src/agents/bash-tools.process.ts` now requests cancellation through supervisor first. +- `process kill/remove` now use process-tree fallback termination when supervisor lookup misses. +- `remove` keeps deterministic remove behavior by dropping running session entries immediately after termination is requested. + +4. Single source watchdog defaults + +- Added shared defaults in `src/agents/cli-watchdog-defaults.ts`. +- `src/agents/cli-backends.ts` consumes the shared defaults. +- `src/agents/cli-runner/reliability.ts` consumes the same shared defaults. + +5. Dead helper cleanup + +- Removed unused `killSession` helper path from `src/agents/bash-tools.shared.ts`. + +6. Direct supervisor path tests added + +- Added `src/agents/bash-tools.process.supervisor.test.ts` to cover kill and remove routing through supervisor cancellation. + +7. Reliability gap fixes completed + +- `src/agents/bash-tools.process.ts` now falls back to real OS-level process termination when supervisor lookup misses. +- `src/process/supervisor/adapters/child.ts` now uses process-tree termination semantics for default cancel/timeout kill paths. +- Added shared process-tree utility in `src/process/kill-tree.ts`. + +8. PTY contract edge-case coverage added + +- Added `src/process/supervisor/supervisor.pty-command.test.ts` for verbatim PTY command forwarding and empty-command rejection. +- Added `src/process/supervisor/adapters/child.test.ts` for process-tree kill behavior in child adapter cancellation. + +## 4. Remaining gaps and decisions + +### Reliability status + +The two required reliability gaps for this pass are now closed: + +- `process kill/remove` now has a real OS termination fallback when supervisor lookup misses. +- child cancel/timeout now uses process-tree kill semantics for default kill path. +- Regression tests were added for both behaviors. + +### Durability and startup reconciliation + +Restart behavior is now explicitly defined as in-memory lifecycle only. + +- `reconcileOrphans()` remains a no-op in `src/process/supervisor/supervisor.ts` by design. +- Active runs are not recovered after process restart. +- This boundary is intentional for this implementation pass to avoid partial persistence risks. + +### Maintainability follow-ups + +1. `runExecProcess` in `src/agents/bash-tools.exec-runtime.ts` still handles multiple responsibilities and can be split into focused helpers in a follow-up. + +## 5. Implementation plan + +The implementation pass for required reliability and contract items is complete. + +Completed: + +- `process kill/remove` fallback real termination +- process-tree cancellation for child adapter default kill path +- regression tests for fallback kill and child adapter kill path +- PTY command edge-case tests under explicit `ptyCommand` +- explicit in-memory restart boundary with `reconcileOrphans()` no-op by design + +Optional follow-up: + +- split `runExecProcess` into focused helpers with no behavior drift + +## 6. File map + +### Process supervisor + +- `src/process/supervisor/types.ts` updated with discriminated spawn input and process local stdin contract. +- `src/process/supervisor/supervisor.ts` updated to use explicit `ptyCommand`. +- `src/process/supervisor/adapters/child.ts` and `src/process/supervisor/adapters/pty.ts` decoupled from agent types. +- `src/process/supervisor/registry.ts` idempotent finalize unchanged and retained. + +### Exec and process integration + +- `src/agents/bash-tools.exec-runtime.ts` updated to pass PTY command explicitly and keep fallback path. +- `src/agents/bash-tools.process.ts` updated to cancel via supervisor with real process-tree fallback termination. +- `src/agents/bash-tools.shared.ts` removed direct kill helper path. + +### CLI reliability + +- `src/agents/cli-watchdog-defaults.ts` added as shared baseline. +- `src/agents/cli-backends.ts` and `src/agents/cli-runner/reliability.ts` now consume same defaults. + +## 7. Validation run in this pass + +Unit tests: + +- `pnpm vitest src/process/supervisor/registry.test.ts` +- `pnpm vitest src/process/supervisor/supervisor.test.ts` +- `pnpm vitest src/process/supervisor/supervisor.pty-command.test.ts` +- `pnpm vitest src/process/supervisor/adapters/child.test.ts` +- `pnpm vitest src/agents/cli-backends.test.ts` +- `pnpm vitest src/agents/bash-tools.exec.pty-cleanup.test.ts` +- `pnpm vitest src/agents/bash-tools.process.poll-timeout.test.ts` +- `pnpm vitest src/agents/bash-tools.process.supervisor.test.ts` +- `pnpm vitest src/process/exec.test.ts` + +E2E targets: + +- `pnpm test:e2e src/agents/cli-runner.e2e.test.ts` +- `pnpm test:e2e src/agents/bash-tools.exec.pty-fallback.e2e.test.ts src/agents/bash-tools.exec.background-abort.e2e.test.ts src/agents/bash-tools.process.send-keys.e2e.test.ts` + +Typecheck note: + +- `pnpm tsgo` currently fails in this repo due to a pre-existing UI typing dependency issue (`@vitest/browser-playwright` resolution), unrelated to this process supervision work. + +## 8. Operational guarantees preserved + +- Exec env hardening behavior is unchanged. +- Approval and allowlist flow is unchanged. +- Output sanitization and output caps are unchanged. +- PTY adapter still guarantees wait settlement on forced kill and listener disposal. + +## 9. Definition of done + +1. Supervisor is lifecycle owner for managed runs. +2. PTY spawn uses explicit command contract with no argv reconstruction. +3. Process layer has no type dependency on agent layer for supervisor stdin contracts. +4. Watchdog defaults are single source. +5. Targeted unit and e2e tests remain green. +6. Restart durability boundary is explicitly documented or fully implemented. + +## 10. Summary + +The branch now has a coherent and safer supervision shape: + +- explicit PTY contract +- cleaner process layering +- supervisor driven cancellation path for process operations +- real fallback termination when supervisor lookup misses +- process-tree cancellation for child-run default kill paths +- unified watchdog defaults +- explicit in-memory restart boundary (no orphan reconciliation across restart in this pass) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index eeb1eaea7b5..d94551ca81f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2295,12 +2295,16 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway cron: { enabled: true, maxConcurrentRuns: 2, + webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https:// + webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false }, } ``` - `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. +- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`. +- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent. See [Cron Jobs](/automation/cron-jobs). diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index fe653e82d2a..fe27d2c51ad 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -76,7 +76,7 @@ Global and per-agent binds are **merged** (not replaced). Under `scope: "shared" - When set (including `[]`), it replaces `agents.defaults.sandbox.docker.binds` for the browser container. - When omitted, the browser container falls back to `agents.defaults.sandbox.docker.binds` (backwards compatible). -Example (read-only source + docker socket): +Example (read-only source + an extra data directory): ```json5 { @@ -84,7 +84,7 @@ Example (read-only source + docker socket): defaults: { sandbox: { docker: { - binds: ["/home/user/source:/source:ro", "/var/run/docker.sock:/var/run/docker.sock"], + binds: ["/home/user/source:/source:ro", "/var/data/myapp:/data:ro"], }, }, }, @@ -105,7 +105,8 @@ Example (read-only source + docker socket): Security notes: - Binds bypass the sandbox filesystem: they expose host paths with whatever mode you set (`:ro` or `:rw`). -- Sensitive mounts (e.g., `docker.sock`, secrets, SSH keys) should be `:ro` unless absolutely required. +- OpenClaw blocks dangerous bind sources (for example: `docker.sock`, `/etc`, `/proc`, `/sys`, `/dev`, and parent mounts that would expose them). +- Sensitive mounts (secrets, SSH keys, service credentials) should be `:ro` unless absolutely required. - Combine with `workspaceAccess: "ro"` if you only need read access to the workspace; bind modes stay independent. - See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for how binds interact with tool policy and elevated exec. diff --git a/docs/tools/web.md b/docs/tools/web.md index 859e6144c51..b0e295cd22a 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -224,6 +224,7 @@ Fetch a URL and extract readable content. enabled: true, maxChars: 50000, maxCharsCap: 50000, + maxResponseBytes: 2000000, timeoutSeconds: 30, cacheTtlMinutes: 15, maxRedirects: 3, @@ -256,6 +257,7 @@ Notes: - `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed. - `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`). - `maxChars` is clamped to `tools.web.fetch.maxCharsCap`. +- `web_fetch` caps the downloaded response body size to `tools.web.fetch.maxResponseBytes` before parsing; oversized responses are truncated and include a warning. - `web_fetch` is best-effort extraction; some sites will need the browser tool. - See [Firecrawl](/tools/firecrawl) for key setup and service details. - Responses are cached (default 15 minutes) to reduce repeated fetches. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 233a67c48b0..a0c9037cb07 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -83,6 +83,9 @@ Cron jobs panel notes: - For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs. - Channel/target fields appear when announce is selected. +- New job form includes a **Notify webhook** toggle (`notify` on the job). +- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config. +- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. ## Chat behavior @@ -93,6 +96,10 @@ Cron jobs panel notes: - Click **Stop** (calls `chat.abort`) - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session +- Abort partial retention: + - When a run is aborted, partial assistant text can still be shown in the UI + - Gateway persists aborted partial assistant text into transcript history when buffered output exists + - Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output ## Tailnet access (recommended) diff --git a/docs/web/webchat.md b/docs/web/webchat.md index a765f67598a..657e00ef8b2 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -25,6 +25,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. - `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run). +- Aborted runs can keep partial assistant output visible in the UI. +- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. diff --git a/package.json b/package.json index 5900994d2f8..caf3ddad6ec 100644 --- a/package.json +++ b/package.json @@ -201,13 +201,13 @@ "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260214.1", + "@typescript/native-preview": "7.0.0-dev.20260215.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", "ollama": "^0.6.3", "oxfmt": "0.32.0", "oxlint": "^1.47.0", - "oxlint-tsgolint": "^0.12.2", + "oxlint-tsgolint": "^0.13.0", "rolldown": "1.0.0-rc.4", "tsdown": "^0.20.3", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a7ca2fdf96..2ca50bca1b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,8 +262,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260214.1 - version: 7.0.0-dev.20260214.1 + specifier: 7.0.0-dev.20260215.1 + version: 7.0.0-dev.20260215.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -278,16 +278,16 @@ importers: version: 0.32.0 oxlint: specifier: ^1.47.0 - version: 1.47.0(oxlint-tsgolint@0.12.2) + version: 1.47.0(oxlint-tsgolint@0.13.0) oxlint-tsgolint: - specifier: ^0.12.2 - version: 0.12.2 + specifier: ^0.13.0 + version: 0.13.0 rolldown: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 6ce82561969..fc5081d19f2 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -165,7 +165,7 @@ const defaultWorkerBudget = unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), unitIsolated: 1, extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: 1, + gateway: 2, }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. diff --git a/src/acp/session-mapper.test.ts b/src/acp/session-mapper.test.ts index 859b1da7380..ac06dcf4b89 100644 --- a/src/acp/session-mapper.test.ts +++ b/src/acp/session-mapper.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js"; +import { createInMemorySessionStore } from "./session.js"; function createGateway(resolveLabelKey = "agent:main:label"): { gateway: GatewayClient; @@ -54,3 +55,26 @@ describe("acp session mapper", () => { expect(request).not.toHaveBeenCalled(); }); }); + +describe("acp session manager", () => { + const store = createInMemorySessionStore(); + + afterEach(() => { + store.clearAllSessionsForTest(); + }); + + it("tracks active runs and clears on cancel", () => { + const session = store.createSession({ + sessionKey: "acp:test", + cwd: "/tmp", + }); + const controller = new AbortController(); + store.setActiveRun(session.sessionId, "run-1", controller); + + expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId); + + const cancelled = store.cancelActiveRun(session.sessionId); + expect(cancelled).toBe(true); + expect(store.getSessionByRunId("run-1")).toBeUndefined(); + }); +}); diff --git a/src/acp/session.test.ts b/src/acp/session.test.ts deleted file mode 100644 index a38b58f1703..00000000000 --- a/src/acp/session.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, afterEach } from "vitest"; -import { createInMemorySessionStore } from "./session.js"; - -describe("acp session manager", () => { - const store = createInMemorySessionStore(); - - afterEach(() => { - store.clearAllSessionsForTest(); - }); - - it("tracks active runs and clears on cancel", () => { - const session = store.createSession({ - sessionKey: "acp:test", - cwd: "/tmp", - }); - const controller = new AbortController(); - store.setActiveRun(session.sessionId, "run-1", controller); - - expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId); - - const cancelled = store.cancelActiveRun(session.sessionId); - expect(cancelled).toBe(true); - expect(store.getSessionByRunId("run-1")).toBeUndefined(); - }); -}); diff --git a/src/agents/agent-paths.e2e.test.ts b/src/agents/agent-paths.e2e.test.ts index f455f82862c..f0df2cbbdbc 100644 --- a/src/agents/agent-paths.e2e.test.ts +++ b/src/agents/agent-paths.e2e.test.ts @@ -2,12 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; describe("resolveOpenClawAgentDir", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const env = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -15,21 +14,7 @@ describe("resolveOpenClawAgentDir", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + env.restore(); }); it("defaults to the multi-agent path when no overrides are set", async () => { diff --git a/src/agents/auth-profiles.chutes.e2e.test.ts b/src/agents/auth-profiles.chutes.e2e.test.ts index 317ce9c771a..c21f37ed1ca 100644 --- a/src/agents/auth-profiles.chutes.e2e.test.ts +++ b/src/agents/auth-profiles.chutes.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -10,10 +11,7 @@ import { import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js"; describe("auth-profiles (chutes)", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + let envSnapshot: ReturnType | undefined; let tempDir: string | null = null; afterEach(async () => { @@ -22,29 +20,17 @@ describe("auth-profiles (chutes)", () => { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousChutesClientId === undefined) { - delete process.env.CHUTES_CLIENT_ID; - } else { - process.env.CHUTES_CLIENT_ID = previousChutesClientId; - } + envSnapshot?.restore(); + envSnapshot = undefined; }); it("refreshes expired Chutes OAuth credentials", async () => { + envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "CHUTES_CLIENT_ID", + ]); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-")); process.env.OPENCLAW_STATE_DIR = tempDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agents", "main", "agent"); diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts index 692b67a01cf..79f22798949 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts @@ -1,30 +1,13 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { + ANTHROPIC_CFG, + ANTHROPIC_STORE, +} from "./auth-profiles.resolve-auth-profile-order.fixtures.js"; describe("resolveAuthProfileOrder", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; + const store = ANTHROPIC_STORE; + const cfg = ANTHROPIC_CFG; it("does not prioritize lastGood over round-robin ordering", () => { const order = resolveAuthProfileOrder({ diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts b/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts new file mode 100644 index 00000000000..bc7b5cf983d --- /dev/null +++ b/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts @@ -0,0 +1,26 @@ +import type { AuthProfileStore } from "./auth-profiles.js"; + +export const ANTHROPIC_STORE: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-work", + }, + }, +}; + +export const ANTHROPIC_CFG = { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "api_key" }, + "anthropic:work": { provider: "anthropic", mode: "api_key" }, + }, + }, +}; diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts index a6bd59b3bb6..0817f2280ea 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts @@ -2,30 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; describe("resolveAuthProfileOrder", () => { - const _store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const _cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; - it("normalizes z.ai aliases in auth.order", () => { const order = resolveAuthProfileOrder({ cfg: { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts index 55816522c27..2842fb48e15 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts @@ -2,30 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; describe("resolveAuthProfileOrder", () => { - const _store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const _cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; - it("orders by lastUsed when no explicit order exists", () => { const order = resolveAuthProfileOrder({ store: { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts index 0a4344bb6b1..c5ec9826e36 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts @@ -1,30 +1,13 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { + ANTHROPIC_CFG, + ANTHROPIC_STORE, +} from "./auth-profiles.resolve-auth-profile-order.fixtures.js"; describe("resolveAuthProfileOrder", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; + const store = ANTHROPIC_STORE; + const cfg = ANTHROPIC_CFG; it("uses stored profiles when no config exists", () => { const order = resolveAuthProfileOrder({ diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts index 9379d387913..ea15d462f01 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts @@ -3,13 +3,16 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "./types.js"; +import { captureEnv } from "../../test-utils/env.js"; import { resolveApiKeyForProfile } from "./oauth.js"; import { ensureAuthProfileStore } from "./store.js"; describe("resolveApiKeyForProfile fallback to main agent", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); let tmpDir: string; let mainAgentDir: string; let secondaryAgentDir: string; @@ -30,22 +33,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { afterEach(async () => { vi.unstubAllGlobals(); - // Restore original environment - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2af4e4a7f6a..d458df01d1e 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,17 +1,17 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { Type } from "@sinclair/typebox"; import path from "node:path"; import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; -import type { ProcessSession, SessionStdin } from "./bash-process-registry.js"; +import type { ProcessSession } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { mergePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; export { applyPathPrepend, normalizePathPrepend } from "../infra/path-prepend.js"; +import type { ManagedRun } from "../process/supervisor/index.js"; import { logWarn } from "../logger.js"; -import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js"; +import { getProcessSupervisor } from "../process/supervisor/index.js"; import { addSession, appendOutput, @@ -23,7 +23,6 @@ import { buildDockerExecArgs, chunkString, clampWithDefault, - killSession, readEnvInt, } from "./bash-tools.shared.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; @@ -147,26 +146,6 @@ export const execSchema = Type.Object({ ), }); -type PtyExitEvent = { exitCode: number; signal?: number }; -type PtyListener = (event: T) => void; -type PtyHandle = { - pid: number; - write: (data: string | Buffer) => void; - onData: (listener: PtyListener) => void; - onExit: (listener: PtyListener) => void; -}; -type PtySpawn = ( - file: string, - args: string[] | string, - options: { - name?: string; - cols?: number; - rows?: number; - cwd?: string; - env?: Record; - }, -) => PtyHandle; - export type ExecProcessOutcome = { status: "completed" | "failed"; exitCode: number | null; @@ -319,138 +298,10 @@ export async function runExecProcess(opts: { }): Promise { const startedAt = Date.now(); const sessionId = createSessionSlug(); - let child: ChildProcessWithoutNullStreams | null = null; - let pty: PtyHandle | null = null; - let stdin: SessionStdin | undefined; const execCommand = opts.execCommand ?? opts.command; + const supervisor = getProcessSupervisor(); - const spawnFallbacks = [ - { - label: "no-detach", - options: { detached: false }, - }, - ]; - - const handleSpawnFallback = (err: unknown, fallback: { label: string }) => { - const errText = formatSpawnError(err); - const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`; - logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`); - opts.warnings.push(warning); - }; - - const spawnShellChild = async ( - shell: string, - shellArgs: string[], - ): Promise => { - const { child: spawned } = await spawnWithFallback({ - argv: [shell, ...shellArgs, execCommand], - options: { - cwd: opts.workdir, - env: opts.env, - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }, - fallbacks: spawnFallbacks, - onFallback: handleSpawnFallback, - }); - return spawned as ChildProcessWithoutNullStreams; - }; - - // `exec` does not currently accept tool-provided stdin content. For non-PTY runs, - // keeping stdin open can cause commands like `wc -l` (or safeBins-hardened segments) - // to block forever waiting for input, leading to accidental backgrounding. - // For interactive flows, callers should use `pty: true` (stdin kept open). - const maybeCloseNonPtyStdin = () => { - if (opts.usePty) { - return; - } - try { - // Signal EOF immediately so stdin-only commands can terminate. - child?.stdin?.end(); - } catch { - // ignore stdin close errors - } - }; - - if (opts.sandbox) { - const { child: spawned } = await spawnWithFallback({ - argv: [ - "docker", - ...buildDockerExecArgs({ - containerName: opts.sandbox.containerName, - command: execCommand, - workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, - env: opts.env, - tty: opts.usePty, - }), - ], - options: { - cwd: opts.workdir, - env: process.env, - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }, - fallbacks: spawnFallbacks, - onFallback: handleSpawnFallback, - }); - child = spawned as ChildProcessWithoutNullStreams; - stdin = child.stdin; - maybeCloseNonPtyStdin(); - } else if (opts.usePty) { - const { shell, args: shellArgs } = getShellConfig(); - try { - const ptyModule = (await import("@lydell/node-pty")) as unknown as { - spawn?: PtySpawn; - default?: { spawn?: PtySpawn }; - }; - const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn; - if (!spawnPty) { - throw new Error("PTY support is unavailable (node-pty spawn not found)."); - } - pty = spawnPty(shell, [...shellArgs, execCommand], { - cwd: opts.workdir, - env: opts.env, - name: process.env.TERM ?? "xterm-256color", - cols: 120, - rows: 30, - }); - stdin = { - destroyed: false, - write: (data, cb) => { - try { - pty?.write(data); - cb?.(null); - } catch (err) { - cb?.(err as Error); - } - }, - end: () => { - try { - const eof = process.platform === "win32" ? "\x1a" : "\x04"; - pty?.write(eof); - } catch { - // ignore EOF errors - } - }, - }; - } catch (err) { - const errText = String(err); - const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; - logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); - opts.warnings.push(warning); - child = await spawnShellChild(shell, shellArgs); - stdin = child.stdin; - } - } else { - const { shell, args: shellArgs } = getShellConfig(); - child = await spawnShellChild(shell, shellArgs); - stdin = child.stdin; - maybeCloseNonPtyStdin(); - } - - const session = { + const session: ProcessSession = { id: sessionId, command: opts.command, scopeKey: opts.scopeKey, @@ -458,9 +309,9 @@ export async function runExecProcess(opts: { notifyOnExit: opts.notifyOnExit, notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true, exitNotified: false, - child: child ?? undefined, - stdin, - pid: child?.pid ?? pty?.pid, + child: undefined, + stdin: undefined, + pid: undefined, startedAt, cwd: opts.workdir, maxOutputChars: opts.maxOutput, @@ -477,59 +328,9 @@ export async function runExecProcess(opts: { exitSignal: undefined as NodeJS.Signals | number | null | undefined, truncated: false, backgrounded: false, - } satisfies ProcessSession; + }; addSession(session); - let settled = false; - let timeoutTimer: NodeJS.Timeout | null = null; - let timeoutFinalizeTimer: NodeJS.Timeout | null = null; - let timedOut = false; - const timeoutFinalizeMs = 1000; - let resolveFn: ((outcome: ExecProcessOutcome) => void) | null = null; - - const settle = (outcome: ExecProcessOutcome) => { - if (settled) { - return; - } - settled = true; - resolveFn?.(outcome); - }; - - const finalizeTimeout = () => { - if (session.exited) { - return; - } - markExited(session, null, "SIGKILL", "failed"); - maybeNotifyOnExit(session, "failed"); - const aggregated = session.aggregated.trim(); - const reason = `Command timed out after ${opts.timeoutSec} seconds`; - settle({ - status: "failed", - exitCode: null, - exitSignal: "SIGKILL", - durationMs: Date.now() - startedAt, - aggregated, - timedOut: true, - reason: aggregated ? `${aggregated}\n\n${reason}` : reason, - }); - }; - - const onTimeout = () => { - timedOut = true; - killSession(session); - if (!timeoutFinalizeTimer) { - timeoutFinalizeTimer = setTimeout(() => { - finalizeTimeout(); - }, timeoutFinalizeMs); - } - }; - - if (opts.timeoutSec > 0) { - timeoutTimer = setTimeout(() => { - onTimeout(); - }, opts.timeoutSec * 1000); - } - const emitUpdate = () => { if (!opts.onUpdate) { return; @@ -565,116 +366,208 @@ export async function runExecProcess(opts: { } }; - if (pty) { - const cursorResponse = buildCursorPositionResponse(); - pty.onData((data) => { - const raw = data.toString(); - const { cleaned, requests } = stripDsrRequests(raw); - if (requests > 0) { + const timeoutMs = + typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 + ? Math.floor(opts.timeoutSec * 1000) + : undefined; + + const spawnSpec: + | { + mode: "child"; + argv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open" | "pipe-closed"; + } + | { + mode: "pty"; + ptyCommand: string; + childFallbackArgv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open"; + } = (() => { + if (opts.sandbox) { + return { + mode: "child" as const, + argv: [ + "docker", + ...buildDockerExecArgs({ + containerName: opts.sandbox.containerName, + command: execCommand, + workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, + env: opts.env, + tty: opts.usePty, + }), + ], + env: process.env, + stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const), + }; + } + const { shell, args: shellArgs } = getShellConfig(); + const childArgv = [shell, ...shellArgs, execCommand]; + if (opts.usePty) { + return { + mode: "pty" as const, + ptyCommand: execCommand, + childFallbackArgv: childArgv, + env: opts.env, + stdinMode: "pipe-open" as const, + }; + } + return { + mode: "child" as const, + argv: childArgv, + env: opts.env, + stdinMode: "pipe-closed" as const, + }; + })(); + + let managedRun: ManagedRun | null = null; + let usingPty = spawnSpec.mode === "pty"; + const cursorResponse = buildCursorPositionResponse(); + + const onSupervisorStdout = (chunk: string) => { + if (usingPty) { + const { cleaned, requests } = stripDsrRequests(chunk); + if (requests > 0 && managedRun?.stdin) { for (let i = 0; i < requests; i += 1) { - pty.write(cursorResponse); + managedRun.stdin.write(cursorResponse); } } handleStdout(cleaned); - }); - } else if (child) { - child.stdout.on("data", handleStdout); - child.stderr.on("data", handleStderr); - } + return; + } + handleStdout(chunk); + }; - const promise = new Promise((resolve) => { - resolveFn = resolve; - const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => { - if (timeoutTimer) { - clearTimeout(timeoutTimer); - } - if (timeoutFinalizeTimer) { - clearTimeout(timeoutFinalizeTimer); + try { + const spawnBase = { + runId: sessionId, + sessionId: opts.sessionKey?.trim() || sessionId, + backendId: opts.sandbox ? "exec-sandbox" : "exec-host", + scopeKey: opts.scopeKey, + cwd: opts.workdir, + env: spawnSpec.env, + timeoutMs, + captureOutput: false, + onStdout: onSupervisorStdout, + onStderr: handleStderr, + }; + managedRun = + spawnSpec.mode === "pty" + ? await supervisor.spawn({ + ...spawnBase, + mode: "pty", + ptyCommand: spawnSpec.ptyCommand, + }) + : await supervisor.spawn({ + ...spawnBase, + mode: "child", + argv: spawnSpec.argv, + stdinMode: spawnSpec.stdinMode, + }); + } catch (err) { + if (spawnSpec.mode === "pty") { + const warning = `Warning: PTY spawn failed (${String(err)}); retrying without PTY for \`${opts.command}\`.`; + logWarn( + `exec: PTY spawn failed (${String(err)}); retrying without PTY for "${opts.command}".`, + ); + opts.warnings.push(warning); + usingPty = false; + try { + managedRun = await supervisor.spawn({ + runId: sessionId, + sessionId: opts.sessionKey?.trim() || sessionId, + backendId: "exec-host", + scopeKey: opts.scopeKey, + mode: "child", + argv: spawnSpec.childFallbackArgv, + cwd: opts.workdir, + env: spawnSpec.env, + stdinMode: "pipe-open", + timeoutMs, + captureOutput: false, + onStdout: handleStdout, + onStderr: handleStderr, + }); + } catch (retryErr) { + markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); + throw retryErr; } + } else { + markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); + throw err; + } + } + session.stdin = managedRun.stdin; + session.pid = managedRun.pid; + + const promise = managedRun + .wait() + .then((exit): ExecProcessOutcome => { const durationMs = Date.now() - startedAt; - const wasSignal = exitSignal != null; - const isSuccess = code === 0 && !wasSignal && !timedOut; - const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; - markExited(session, code, exitSignal, status); + const status: "completed" | "failed" = + exit.exitCode === 0 && exit.reason === "exit" ? "completed" : "failed"; + markExited(session, exit.exitCode, exit.exitSignal, status); maybeNotifyOnExit(session, status); if (!session.child && session.stdin) { session.stdin.destroyed = true; } - - if (settled) { - return; - } const aggregated = session.aggregated.trim(); - if (!isSuccess) { - const reason = timedOut - ? `Command timed out after ${opts.timeoutSec} seconds` - : wasSignal && exitSignal - ? `Command aborted by signal ${exitSignal}` - : code === null - ? "Command aborted before exit code was captured" - : `Command exited with code ${code}`; - const message = aggregated ? `${aggregated}\n\n${reason}` : reason; - settle({ - status: "failed", - exitCode: code ?? null, - exitSignal: exitSignal ?? null, + if (status === "completed") { + return { + status: "completed", + exitCode: exit.exitCode ?? 0, + exitSignal: exit.exitSignal, durationMs, aggregated, - timedOut, - reason: message, - }); - return; + timedOut: false, + }; } - settle({ - status: "completed", - exitCode: code ?? 0, - exitSignal: exitSignal ?? null, + const reason = + exit.reason === "overall-timeout" + ? `Command timed out after ${opts.timeoutSec} seconds` + : exit.reason === "no-output-timeout" + ? "Command timed out waiting for output" + : exit.exitSignal != null + ? `Command aborted by signal ${exit.exitSignal}` + : exit.exitCode == null + ? "Command aborted before exit code was captured" + : `Command exited with code ${exit.exitCode}`; + return { + status: "failed", + exitCode: exit.exitCode, + exitSignal: exit.exitSignal, durationMs, aggregated, + timedOut: exit.timedOut, + reason: aggregated ? `${aggregated}\n\n${reason}` : reason, + }; + }) + .catch((err): ExecProcessOutcome => { + markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); + const aggregated = session.aggregated.trim(); + const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err); + return { + status: "failed", + exitCode: null, + exitSignal: null, + durationMs: Date.now() - startedAt, + aggregated, timedOut: false, - }); - }; - - if (pty) { - pty.onExit((event) => { - const rawSignal = event.signal ?? null; - const normalizedSignal = rawSignal === 0 ? null : rawSignal; - handleExit(event.exitCode ?? null, normalizedSignal); - }); - } else if (child) { - child.once("close", (code, exitSignal) => { - handleExit(code, exitSignal); - }); - - child.once("error", (err) => { - if (timeoutTimer) { - clearTimeout(timeoutTimer); - } - if (timeoutFinalizeTimer) { - clearTimeout(timeoutFinalizeTimer); - } - markExited(session, null, null, "failed"); - maybeNotifyOnExit(session, "failed"); - const aggregated = session.aggregated.trim(); - const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err); - settle({ - status: "failed", - exitCode: null, - exitSignal: null, - durationMs: Date.now() - startedAt, - aggregated, - timedOut, - reason: message, - }); - }); - } - }); + reason: message, + }; + }); return { session, startedAt, pid: session.pid ?? undefined, promise, - kill: () => killSession(session), + kill: () => { + managedRun?.cancel("manual-cancel"); + }, }; } diff --git a/src/agents/bash-tools.exec.pty-cleanup.test.ts b/src/agents/bash-tools.exec.pty-cleanup.test.ts new file mode 100644 index 00000000000..613c8fd0353 --- /dev/null +++ b/src/agents/bash-tools.exec.pty-cleanup.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { resetProcessRegistryForTests } from "./bash-process-registry"; + +afterEach(() => { + resetProcessRegistryForTests(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +test("exec disposes PTY listeners after normal exit", async () => { + const disposeData = vi.fn(); + const disposeExit = vi.fn(); + + vi.doMock("@lydell/node-pty", () => ({ + spawn: () => { + return { + pid: 0, + write: vi.fn(), + onData: (listener: (value: string) => void) => { + setTimeout(() => listener("ok"), 0); + return { dispose: disposeData }; + }, + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { + setTimeout(() => listener({ exitCode: 0 }), 0); + return { dispose: disposeExit }; + }, + kill: vi.fn(), + }; + }, + })); + + const { createExecTool } = await import("./bash-tools.exec"); + const tool = createExecTool({ allowBackground: false }); + const result = await tool.execute("toolcall", { + command: "echo ok", + pty: true, + }); + + expect(result.details.status).toBe("completed"); + expect(disposeData).toHaveBeenCalledTimes(1); + expect(disposeExit).toHaveBeenCalledTimes(1); +}); + +test("exec tears down PTY resources on timeout", async () => { + const disposeData = vi.fn(); + const disposeExit = vi.fn(); + const kill = vi.fn(); + + vi.doMock("@lydell/node-pty", () => ({ + spawn: () => { + return { + pid: 0, + write: vi.fn(), + onData: () => ({ dispose: disposeData }), + onExit: () => ({ dispose: disposeExit }), + kill, + }; + }, + })); + + const { createExecTool } = await import("./bash-tools.exec"); + const tool = createExecTool({ allowBackground: false }); + await expect( + tool.execute("toolcall", { + command: "sleep 5", + pty: true, + timeout: 0.01, + }), + ).rejects.toThrow("Command timed out"); + expect(kill).toHaveBeenCalledTimes(1); + expect(disposeData).toHaveBeenCalledTimes(1); + expect(disposeExit).toHaveBeenCalledTimes(1); +}); diff --git a/src/agents/bash-tools.exec.pty-fallback-failure.test.ts b/src/agents/bash-tools.exec.pty-fallback-failure.test.ts new file mode 100644 index 00000000000..7f3be417387 --- /dev/null +++ b/src/agents/bash-tools.exec.pty-fallback-failure.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { listRunningSessions, resetProcessRegistryForTests } from "./bash-process-registry"; + +const { supervisorSpawnMock } = vi.hoisted(() => ({ + supervisorSpawnMock: vi.fn(), +})); + +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: (...args: unknown[]) => supervisorSpawnMock(...args), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), +})); + +afterEach(() => { + resetProcessRegistryForTests(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +test("exec cleans session state when PTY fallback spawn also fails", async () => { + supervisorSpawnMock + .mockRejectedValueOnce(new Error("pty spawn failed")) + .mockRejectedValueOnce(new Error("child fallback failed")); + + const { createExecTool } = await import("./bash-tools.exec"); + const tool = createExecTool({ allowBackground: false }); + + await expect( + tool.execute("toolcall", { + command: "echo ok", + pty: true, + }), + ).rejects.toThrow("child fallback failed"); + + expect(listRunningSessions()).toHaveLength(0); +}); diff --git a/src/agents/bash-tools.process.supervisor.test.ts b/src/agents/bash-tools.process.supervisor.test.ts new file mode 100644 index 00000000000..e6d026595f4 --- /dev/null +++ b/src/agents/bash-tools.process.supervisor.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProcessSession } from "./bash-process-registry.js"; +import { + addSession, + getFinishedSession, + getSession, + resetProcessRegistryForTests, +} from "./bash-process-registry.js"; +import { createProcessTool } from "./bash-tools.process.js"; + +const { supervisorMock } = vi.hoisted(() => ({ + supervisorMock: { + spawn: vi.fn(), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }, +})); + +const { killProcessTreeMock } = vi.hoisted(() => ({ + killProcessTreeMock: vi.fn(), +})); + +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => supervisorMock, +})); + +vi.mock("../process/kill-tree.js", () => ({ + killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args), +})); + +function createBackgroundSession(id: string, pid?: number): ProcessSession { + return { + id, + command: "sleep 999", + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10_000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + pid, + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; +} + +describe("process tool supervisor cancellation", () => { + beforeEach(() => { + supervisorMock.spawn.mockReset(); + supervisorMock.cancel.mockReset(); + supervisorMock.cancelScope.mockReset(); + supervisorMock.reconcileOrphans.mockReset(); + supervisorMock.getRecord.mockReset(); + killProcessTreeMock.mockReset(); + }); + + afterEach(() => { + resetProcessRegistryForTests(); + }); + + it("routes kill through supervisor when run is managed", async () => { + supervisorMock.getRecord.mockReturnValue({ + runId: "sess", + state: "running", + }); + addSession(createBackgroundSession("sess")); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "kill", + sessionId: "sess", + }); + + expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel"); + expect(getSession("sess")).toBeDefined(); + expect(getSession("sess")?.exited).toBe(false); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Termination requested for session sess.", + }); + }); + + it("remove drops running session immediately when cancellation is requested", async () => { + supervisorMock.getRecord.mockReturnValue({ + runId: "sess", + state: "running", + }); + addSession(createBackgroundSession("sess")); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "remove", + sessionId: "sess", + }); + + expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel"); + expect(getSession("sess")).toBeUndefined(); + expect(getFinishedSession("sess")).toBeUndefined(); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Removed session sess (termination requested).", + }); + }); + + it("falls back to process-tree kill when supervisor record is missing", async () => { + supervisorMock.getRecord.mockReturnValue(undefined); + addSession(createBackgroundSession("sess-fallback", 4242)); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "kill", + sessionId: "sess-fallback", + }); + + expect(killProcessTreeMock).toHaveBeenCalledWith(4242); + expect(getSession("sess-fallback")).toBeUndefined(); + expect(getFinishedSession("sess-fallback")).toBeDefined(); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Killed session sess-fallback.", + }); + }); + + it("fails remove when no supervisor record and no pid is available", async () => { + supervisorMock.getRecord.mockReturnValue(undefined); + addSession(createBackgroundSession("sess-no-pid")); + const processTool = createProcessTool(); + + const result = await processTool.execute("toolcall", { + action: "remove", + sessionId: "sess-no-pid", + }); + + expect(killProcessTreeMock).not.toHaveBeenCalled(); + expect(getSession("sess-no-pid")).toBeDefined(); + expect(result.details).toMatchObject({ status: "failed" }); + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Unable to remove session sess-no-pid: no active supervisor run or process id.", + }); + }); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index b5966ab79b0..38a8ac357ab 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -1,7 +1,10 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { formatDurationCompact } from "../infra/format-time/format-duration.ts"; +import { killProcessTree } from "../process/kill-tree.js"; +import { getProcessSupervisor } from "../process/supervisor/index.js"; import { + type ProcessSession, deleteSession, drainSession, getFinishedSession, @@ -11,13 +14,7 @@ import { markExited, setJobTtlMs, } from "./bash-process-registry.js"; -import { - deriveSessionName, - killSession, - pad, - sliceLogLines, - truncateMiddle, -} from "./bash-tools.shared.js"; +import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js"; import { encodeKeySequence, encodePaste } from "./pty-keys.js"; export type ProcessToolDefaults = { @@ -65,8 +62,9 @@ const processSchema = Type.Object({ offset: Type.Optional(Type.Number({ description: "Log offset" })), limit: Type.Optional(Type.Number({ description: "Log length" })), timeout: Type.Optional( - Type.Union([Type.Number(), Type.String()], { + Type.Number({ description: "For poll: wait up to this many milliseconds before returning", + minimum: 0, }), ), }); @@ -106,9 +104,28 @@ export function createProcessTool( setJobTtlMs(defaults.cleanupMs); } const scopeKey = defaults?.scopeKey; + const supervisor = getProcessSupervisor(); const isInScope = (session?: { scopeKey?: string } | null) => !scopeKey || session?.scopeKey === scopeKey; + const cancelManagedSession = (sessionId: string) => { + const record = supervisor.getRecord(sessionId); + if (!record || record.state === "exited") { + return false; + } + supervisor.cancel(sessionId, "manual-cancel"); + return true; + }; + + const terminateSessionFallback = (session: ProcessSession) => { + const pid = session.pid ?? session.child?.pid; + if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0) { + return false; + } + killProcessTree(pid); + return true; + }; + return { name: "process", label: "process", @@ -138,7 +155,7 @@ export function createProcessTool( eof?: boolean; offset?: number; limit?: number; - timeout?: number | string; + timeout?: unknown; }; if (params.action === "list") { @@ -522,10 +539,25 @@ export function createProcessTool( if (!scopedSession.backgrounded) { return failText(`Session ${params.sessionId} is not backgrounded.`); } - killSession(scopedSession); - markExited(scopedSession, null, "SIGKILL", "failed"); + const canceled = cancelManagedSession(scopedSession.id); + if (!canceled) { + const terminated = terminateSessionFallback(scopedSession); + if (!terminated) { + return failText( + `Unable to terminate session ${params.sessionId}: no active supervisor run or process id.`, + ); + } + markExited(scopedSession, null, "SIGKILL", "failed"); + } return { - content: [{ type: "text", text: `Killed session ${params.sessionId}.` }], + content: [ + { + type: "text", + text: canceled + ? `Termination requested for session ${params.sessionId}.` + : `Killed session ${params.sessionId}.`, + }, + ], details: { status: "failed", name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, @@ -554,10 +586,30 @@ export function createProcessTool( case "remove": { if (scopedSession) { - killSession(scopedSession); - markExited(scopedSession, null, "SIGKILL", "failed"); + const canceled = cancelManagedSession(scopedSession.id); + if (canceled) { + // Keep remove semantics deterministic: drop from process registry now. + scopedSession.backgrounded = false; + deleteSession(params.sessionId); + } else { + const terminated = terminateSessionFallback(scopedSession); + if (!terminated) { + return failText( + `Unable to remove session ${params.sessionId}: no active supervisor run or process id.`, + ); + } + markExited(scopedSession, null, "SIGKILL", "failed"); + deleteSession(params.sessionId); + } return { - content: [{ type: "text", text: `Removed session ${params.sessionId}.` }], + content: [ + { + type: "text", + text: canceled + ? `Removed session ${params.sessionId} (termination requested).` + : `Removed session ${params.sessionId}.`, + }, + ], details: { status: "failed", name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 99a7a4b792f..07b12266006 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -1,11 +1,9 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync, statSync } from "node:fs"; import fs from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; -import { killProcessTree } from "./shell-utils.js"; const CHUNK_LIMIT = 8 * 1024; @@ -115,13 +113,6 @@ export async function resolveSandboxWorkdir(params: { } } -export function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams }) { - const pid = session.pid ?? session.child?.pid; - if (pid) { - killProcessTree(pid); - } -} - export function resolveWorkdir(workdir: string, warnings: string[]) { const current = safeCwd(); const fallback = current ?? homedir(); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts new file mode 100644 index 00000000000..c78dfdb87fc --- /dev/null +++ b/src/agents/cli-backends.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveCliBackendConfig } from "./cli-backends.js"; + +describe("resolveCliBackendConfig reliability merge", () => { + it("deep-merges reliability watchdog overrides for codex", () => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "codex-cli": { + command: "codex", + reliability: { + watchdog: { + resume: { + noOutputTimeoutMs: 42_000, + }, + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const resolved = resolveCliBackendConfig("codex-cli", cfg); + + expect(resolved).not.toBeNull(); + 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); + }); +}); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 5f6b2253fb2..2f1db0f87a6 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -1,5 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; +import { + CLI_FRESH_WATCHDOG_DEFAULTS, + CLI_RESUME_WATCHDOG_DEFAULTS, +} from "./cli-watchdog-defaults.js"; import { normalizeProviderId } from "./model-selection.js"; export type ResolvedCliBackend = { @@ -49,6 +53,12 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = { systemPromptMode: "append", systemPromptWhen: "first", clearEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"], + reliability: { + watchdog: { + fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS }, + resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS }, + }, + }, serialize: true, }; @@ -73,6 +83,12 @@ const DEFAULT_CODEX_BACKEND: CliBackendConfig = { sessionMode: "existing", imageArg: "--image", imageMode: "repeat", + reliability: { + watchdog: { + fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS }, + resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS }, + }, + }, serialize: true, }; @@ -96,6 +112,10 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) if (!override) { return { ...base }; } + const baseFresh = base.reliability?.watchdog?.fresh ?? {}; + const baseResume = base.reliability?.watchdog?.resume ?? {}; + const overrideFresh = override.reliability?.watchdog?.fresh ?? {}; + const overrideResume = override.reliability?.watchdog?.resume ?? {}; return { ...base, ...override, @@ -106,6 +126,22 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) sessionIdFields: override.sessionIdFields ?? base.sessionIdFields, sessionArgs: override.sessionArgs ?? base.sessionArgs, resumeArgs: override.resumeArgs ?? base.resumeArgs, + reliability: { + ...base.reliability, + ...override.reliability, + watchdog: { + ...base.reliability?.watchdog, + ...override.reliability?.watchdog, + fresh: { + ...baseFresh, + ...overrideFresh, + }, + resume: { + ...baseResume, + ...overrideResume, + }, + }, + }, }; } diff --git a/src/agents/cli-runner.e2e.test.ts b/src/agents/cli-runner.e2e.test.ts index 1383be1edb3..16f563d9e7c 100644 --- a/src/agents/cli-runner.e2e.test.ts +++ b/src/agents/cli-runner.e2e.test.ts @@ -3,50 +3,69 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { CliBackendConfig } from "../config/types.js"; import { runCliAgent } from "./cli-runner.js"; -import { cleanupResumeProcesses, cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js"; +import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js"; -const runCommandWithTimeoutMock = vi.fn(); -const runExecMock = vi.fn(); +const supervisorSpawnMock = vi.fn(); -vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), - runExec: (...args: unknown[]) => runExecMock(...args), +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: (...args: unknown[]) => supervisorSpawnMock(...args), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), })); -describe("runCliAgent resume cleanup", () => { +type MockRunExit = { + 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; +}; + +function createManagedRun(exit: MockRunExit, pid = 1234) { + return { + runId: "run-supervisor", + pid, + startedAtMs: Date.now(), + stdin: undefined, + wait: vi.fn().mockResolvedValue(exit), + cancel: vi.fn(), + }; +} + +describe("runCliAgent with process supervisor", () => { beforeEach(() => { - runCommandWithTimeoutMock.mockReset(); - runExecMock.mockReset(); + supervisorSpawnMock.mockReset(); }); - it("kills stale resume processes for codex sessions", async () => { - const selfPid = process.pid; - - runExecMock - .mockResolvedValueOnce({ - stdout: " 1 999 S /bin/launchd\n", + it("runs CLI through supervisor and returns payload", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: "ok", stderr: "", - }) // cleanupSuspendedCliProcesses (ps) — ppid 999 != selfPid, no match - .mockResolvedValueOnce({ - stdout: [ - ` ${selfPid + 1} ${selfPid} codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`, - ` ${selfPid + 2} 999 codex exec resume thread-123 --color never --sandbox read-only --skip-git-repo-check`, - ].join("\n"), - stderr: "", - }) // cleanupResumeProcesses (ps) - .mockResolvedValueOnce({ stdout: "", stderr: "" }) // cleanupResumeProcesses (kill -TERM) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (kill -9) - runCommandWithTimeoutMock.mockResolvedValueOnce({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); + timedOut: false, + noOutputTimedOut: false, + }), + ); - await runCliAgent({ + const result = await runCliAgent({ sessionId: "s1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", @@ -58,28 +77,80 @@ describe("runCliAgent resume cleanup", () => { cliSessionId: "thread-123", }); - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } + expect(result.payloads?.[0]?.text).toBe("ok"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { + argv?: string[]; + mode?: string; + timeoutMs?: number; + noOutputTimeoutMs?: number; + replaceExistingScope?: boolean; + scopeKey?: string; + }; + expect(input.mode).toBe("child"); + expect(input.argv?.[0]).toBe("codex"); + expect(input.timeoutMs).toBe(1_000); + expect(input.noOutputTimeoutMs).toBeGreaterThanOrEqual(1_000); + expect(input.replaceExistingScope).toBe(true); + expect(input.scopeKey).toContain("thread-123"); + }); - expect(runExecMock).toHaveBeenCalledTimes(4); + it("fails with timeout when no-output watchdog trips", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "no-output-timeout", + exitCode: null, + exitSignal: "SIGKILL", + durationMs: 200, + stdout: "", + stderr: "", + timedOut: true, + noOutputTimedOut: true, + }), + ); - // Second call: cleanupResumeProcesses ps - const psCall = runExecMock.mock.calls[1] ?? []; - expect(psCall[0]).toBe("ps"); + await expect( + runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-2", + cliSessionId: "thread-123", + }), + ).rejects.toThrow("produced no output"); + }); - // Third call: TERM, only the child PID - const termCall = runExecMock.mock.calls[2] ?? []; - expect(termCall[0]).toBe("kill"); - const termArgs = termCall[1] as string[]; - expect(termArgs).toEqual(["-TERM", String(selfPid + 1)]); + it("fails with timeout when overall timeout trips", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "overall-timeout", + exitCode: null, + exitSignal: "SIGKILL", + durationMs: 200, + stdout: "", + stderr: "", + timedOut: true, + noOutputTimedOut: false, + }), + ); - // Fourth call: KILL, only the child PID - const killCall = runExecMock.mock.calls[3] ?? []; - expect(killCall[0]).toBe("kill"); - const killArgs = killCall[1] as string[]; - expect(killArgs).toEqual(["-9", String(selfPid + 1)]); + await expect( + runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-3", + cliSessionId: "thread-123", + }), + ).rejects.toThrow("exceeded timeout"); }); it("falls back to per-agent workspace when workspaceDir is missing", async () => { @@ -94,14 +165,18 @@ describe("runCliAgent resume cleanup", () => { }, } satisfies OpenClawConfig; - runExecMock.mockResolvedValue({ stdout: "", stderr: "" }); - runCommandWithTimeoutMock.mockResolvedValueOnce({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 25, + stdout: "ok", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + ); try { await runCliAgent({ @@ -114,264 +189,33 @@ describe("runCliAgent resume cleanup", () => { provider: "codex-cli", model: "gpt-5.2-codex", timeoutMs: 1_000, - runId: "run-1", + runId: "run-4", }); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } - const options = runCommandWithTimeoutMock.mock.calls[0]?.[1] as { cwd?: string }; - expect(options.cwd).toBe(path.resolve(fallbackWorkspace)); + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { cwd?: string }; + expect(input.cwd).toBe(path.resolve(fallbackWorkspace)); }); +}); - it("throws when sessionKey is malformed", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-")); - const mainWorkspace = path.join(tempDir, "workspace-main"); - const researchWorkspace = path.join(tempDir, "workspace-research"); - await fs.mkdir(mainWorkspace, { recursive: true }); - await fs.mkdir(researchWorkspace, { recursive: true }); - const cfg = { - agents: { - defaults: { - workspace: mainWorkspace, +describe("resolveCliNoOutputTimeoutMs", () => { + it("uses backend-configured resume watchdog override", () => { + const timeoutMs = resolveCliNoOutputTimeoutMs({ + backend: { + command: "codex", + reliability: { + watchdog: { + resume: { + noOutputTimeoutMs: 42_000, + }, + }, }, - list: [{ id: "research", workspace: researchWorkspace }], }, - } satisfies OpenClawConfig; - - try { - await expect( - runCliAgent({ - sessionId: "s1", - sessionKey: "agent::broken", - agentId: "research", - sessionFile: "/tmp/session.jsonl", - workspaceDir: undefined as unknown as string, - config: cfg, - prompt: "hi", - provider: "codex-cli", - model: "gpt-5.2-codex", - timeoutMs: 1_000, - runId: "run-2", - }), - ).rejects.toThrow("Malformed agent session key"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); - }); -}); - -describe("cleanupSuspendedCliProcesses", () => { - beforeEach(() => { - runExecMock.mockReset(); - }); - - it("skips when no session tokens are configured", async () => { - await cleanupSuspendedCliProcesses( - { - command: "tool", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).not.toHaveBeenCalled(); - }); - - it("matches sessionArg-based commands", async () => { - const selfPid = process.pid; - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` 40 ${selfPid} T+ claude --session-id thread-1 -p`, - ` 41 ${selfPid} S claude --session-id thread-2 -p`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupSuspendedCliProcesses( - { - command: "claude", - sessionArg: "--session-id", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(2); - const killCall = runExecMock.mock.calls[1] ?? []; - expect(killCall[0]).toBe("kill"); - expect(killCall[1]).toEqual(["-9", "40"]); - }); - - it("matches resumeArgs with positional session id", async () => { - const selfPid = process.pid; - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` 50 ${selfPid} T codex exec resume thread-99 --color never --sandbox read-only`, - ` 51 ${selfPid} T codex exec resume other --color never --sandbox read-only`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupSuspendedCliProcesses( - { - command: "codex", - resumeArgs: ["exec", "resume", "{sessionId}", "--color", "never", "--sandbox", "read-only"], - } as CliBackendConfig, - 1, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(2); - const killCall = runExecMock.mock.calls[1] ?? []; - expect(killCall[0]).toBe("kill"); - expect(killCall[1]).toEqual(["-9", "50", "51"]); - }); - - it("only kills child processes of current process (ppid validation)", async () => { - const selfPid = process.pid; - const childPid = selfPid + 1; - const unrelatedPid = 9999; - - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` ${childPid} ${selfPid} T claude --session-id thread-1 -p`, - ` ${unrelatedPid} 100 T claude --session-id thread-2 -p`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupSuspendedCliProcesses( - { - command: "claude", - sessionArg: "--session-id", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(2); - const killCall = runExecMock.mock.calls[1] ?? []; - expect(killCall[0]).toBe("kill"); - // Only childPid killed; unrelatedPid (ppid=100) excluded - expect(killCall[1]).toEqual(["-9", String(childPid)]); - }); - - it("skips all processes when none are children of current process", async () => { - runExecMock.mockResolvedValueOnce({ - stdout: [ - " 200 100 T claude --session-id thread-1 -p", - " 201 100 T claude --session-id thread-2 -p", - ].join("\n"), - stderr: "", + timeoutMs: 120_000, + useResume: true, }); - - await cleanupSuspendedCliProcesses( - { - command: "claude", - sessionArg: "--session-id", - } as CliBackendConfig, - 0, - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - // Only ps called — no kill because no matching ppid - expect(runExecMock).toHaveBeenCalledTimes(1); - }); -}); - -describe("cleanupResumeProcesses", () => { - beforeEach(() => { - runExecMock.mockReset(); - }); - - it("only kills resume processes owned by current process", async () => { - const selfPid = process.pid; - - runExecMock - .mockResolvedValueOnce({ - stdout: [ - ` ${selfPid + 1} ${selfPid} codex exec resume abc-123`, - ` ${selfPid + 2} 999 codex exec resume abc-123`, - ].join("\n"), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }) - .mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await cleanupResumeProcesses( - { - command: "codex", - resumeArgs: ["exec", "resume", "{sessionId}"], - } as CliBackendConfig, - "abc-123", - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - expect(runExecMock).toHaveBeenCalledTimes(3); - - const termCall = runExecMock.mock.calls[1] ?? []; - expect(termCall[0]).toBe("kill"); - expect(termCall[1]).toEqual(["-TERM", String(selfPid + 1)]); - - const killCall = runExecMock.mock.calls[2] ?? []; - expect(killCall[0]).toBe("kill"); - expect(killCall[1]).toEqual(["-9", String(selfPid + 1)]); - }); - - it("skips kill when no resume processes match ppid", async () => { - runExecMock.mockResolvedValueOnce({ - stdout: [" 300 100 codex exec resume abc-123", " 301 200 codex exec resume abc-123"].join( - "\n", - ), - stderr: "", - }); - - await cleanupResumeProcesses( - { - command: "codex", - resumeArgs: ["exec", "resume", "{sessionId}"], - } as CliBackendConfig, - "abc-123", - ); - - if (process.platform === "win32") { - expect(runExecMock).not.toHaveBeenCalled(); - return; - } - - // Only ps called — no kill because no matching ppid - expect(runExecMock).toHaveBeenCalledTimes(1); + expect(timeoutMs).toBe(42_000); }); }); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 68dbf0d5c22..5160611e8e5 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -6,20 +6,20 @@ import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; import { shouldLogVerbose } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { runCommandWithTimeout } from "../process/exec.js"; +import { getProcessSupervisor } from "../process/supervisor/index.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; import { appendImagePathsToPrompt, + buildCliSupervisorScopeKey, buildCliArgs, buildSystemPrompt, - cleanupResumeProcesses, - cleanupSuspendedCliProcesses, enqueueCliRun, normalizeCliModel, parseCliJson, parseCliJsonl, + resolveCliNoOutputTimeoutMs, resolvePromptInput, resolveSessionIdToSend, resolveSystemPromptUsage, @@ -226,19 +226,32 @@ export async function runCliAgent(params: { } return next; })(); - - // Cleanup suspended processes that have accumulated (regardless of sessionId) - await cleanupSuspendedCliProcesses(backend); - if (useResume && cliSessionIdToSend) { - await cleanupResumeProcesses(backend, cliSessionIdToSend); - } - - const result = await runCommandWithTimeout([backend.command, ...args], { + const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({ + backend, timeoutMs: params.timeoutMs, + useResume, + }); + const supervisor = getProcessSupervisor(); + const scopeKey = buildCliSupervisorScopeKey({ + backend, + backendId: backendResolved.id, + cliSessionId: useResume ? cliSessionIdToSend : undefined, + }); + + const managedRun = await supervisor.spawn({ + sessionId: params.sessionId, + backendId: backendResolved.id, + scopeKey, + replaceExistingScope: Boolean(useResume && scopeKey), + mode: "child", + argv: [backend.command, ...args], + timeoutMs: params.timeoutMs, + noOutputTimeoutMs, cwd: workspaceDir, env, input: stdinPayload, }); + const result = await managedRun.wait(); const stdout = result.stdout.trim(); const stderr = result.stderr.trim(); @@ -259,7 +272,28 @@ export async function runCliAgent(params: { } } - if (result.code !== 0) { + if (result.exitCode !== 0 || result.reason !== "exit") { + if (result.reason === "no-output-timeout" || result.noOutputTimedOut) { + const timeoutReason = `CLI produced no output for ${Math.round(noOutputTimeoutMs / 1000)}s and was terminated.`; + log.warn( + `cli watchdog timeout: provider=${params.provider} model=${modelId} session=${cliSessionIdToSend ?? params.sessionId} noOutputTimeoutMs=${noOutputTimeoutMs} pid=${managedRun.pid ?? "unknown"}`, + ); + throw new FailoverError(timeoutReason, { + reason: "timeout", + provider: params.provider, + model: modelId, + status: resolveFailoverStatus("timeout"), + }); + } + if (result.reason === "overall-timeout") { + const timeoutReason = `CLI exceeded timeout (${Math.round(params.timeoutMs / 1000)}s) and was terminated.`; + throw new FailoverError(timeoutReason, { + reason: "timeout", + provider: params.provider, + model: modelId, + status: resolveFailoverStatus("timeout"), + }); + } const err = stderr || stdout || "CLI failed."; const reason = classifyFailoverReason(err) ?? "unknown"; const status = resolveFailoverStatus(reason); diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 71fd5d8babf..c7d2a3e07c3 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -11,230 +11,26 @@ import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import { resolveCliName } from "../../cli/cli-name.js"; import { runExec } from "../../process/exec.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; -import { escapeRegExp, isRecord } from "../../utils.js"; +import { isRecord } from "../../utils.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; import { detectRuntimeShell } from "../shell-utils.js"; import { buildSystemPromptParams } from "../system-prompt-params.js"; import { buildAgentSystemPrompt } from "../system-prompt.js"; +export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js"; const CLI_RUN_QUEUE = new Map>(); - -function buildLooseArgOrderRegex(tokens: string[]): RegExp { - // Scan `ps` output lines. Keep matching flexible, but require whitespace arg boundaries - // to avoid substring matches like `codexx` or `/path/to/codexx`. - const [head, ...rest] = tokens.map((t) => String(t ?? "").trim()).filter(Boolean); - if (!head) { - return /$^/; - } - - const headEscaped = escapeRegExp(head); - const headFragment = `(?:^|\\s)(?:${headEscaped}|\\S+\\/${headEscaped})(?=\\s|$)`; - const restFragments = rest.map((t) => `(?:^|\\s)${escapeRegExp(t)}(?=\\s|$)`); - return new RegExp([headFragment, ...restFragments].join(".*")); -} - -async function psWithFallback(argsA: string[], argsB: string[]): Promise { - try { - const { stdout } = await runExec("ps", argsA); - return stdout; - } catch { - // fallthrough - } - const { stdout } = await runExec("ps", argsB); - return stdout; -} - -export async function cleanupResumeProcesses( - backend: CliBackendConfig, - sessionId: string, -): Promise { - if (process.platform === "win32") { - return; - } - const resumeArgs = backend.resumeArgs ?? []; - if (resumeArgs.length === 0) { - return; - } - if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) { - return; - } - const commandToken = path.basename(backend.command ?? "").trim(); - if (!commandToken) { - return; - } - - const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId)); - const pattern = [commandToken, ...resumeTokens] - .filter(Boolean) - .map((token) => escapeRegExp(token)) - .join(".*"); - if (!pattern) { - return; - } - - try { - const stdout = await psWithFallback( - ["-axww", "-o", "pid=,ppid=,command="], - ["-ax", "-o", "pid=,ppid=,command="], - ); - const patternRegex = buildLooseArgOrderRegex([commandToken, ...resumeTokens]); - const toKill: number[] = []; - - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed); - if (!match) { - continue; - } - const pid = Number(match[1]); - const ppid = Number(match[2]); - const cmd = match[3] ?? ""; - if (!Number.isFinite(pid)) { - continue; - } - if (ppid !== process.pid) { - continue; - } - if (!patternRegex.test(cmd)) { - continue; - } - toKill.push(pid); - } - - if (toKill.length > 0) { - const pidArgs = toKill.map((pid) => String(pid)); - try { - await runExec("kill", ["-TERM", ...pidArgs]); - } catch { - // ignore - } - await new Promise((resolve) => setTimeout(resolve, 250)); - try { - await runExec("kill", ["-9", ...pidArgs]); - } catch { - // ignore - } - } - } catch { - // ignore errors - best effort cleanup - } -} - -function buildSessionMatchers(backend: CliBackendConfig): RegExp[] { - const commandToken = path.basename(backend.command ?? "").trim(); - if (!commandToken) { - return []; - } - const matchers: RegExp[] = []; - const sessionArg = backend.sessionArg?.trim(); - const sessionArgs = backend.sessionArgs ?? []; - const resumeArgs = backend.resumeArgs ?? []; - - const addMatcher = (args: string[]) => { - if (args.length === 0) { - return; - } - const tokens = [commandToken, ...args]; - const pattern = tokens - .map((token, index) => { - const tokenPattern = tokenToRegex(token); - return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`; - }) - .join(""); - matchers.push(new RegExp(pattern)); - }; - - if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) { - addMatcher(sessionArgs); - } else if (sessionArg) { - addMatcher([sessionArg, "{sessionId}"]); - } - - if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) { - addMatcher(resumeArgs); - } - - return matchers; -} - -function tokenToRegex(token: string): string { - if (!token.includes("{sessionId}")) { - return escapeRegExp(token); - } - const parts = token.split("{sessionId}").map((part) => escapeRegExp(part)); - return parts.join("\\S+"); -} - -/** - * Cleanup suspended OpenClaw CLI processes that have accumulated. - * Only cleans up if there are more than the threshold (default: 10). - */ -export async function cleanupSuspendedCliProcesses( - backend: CliBackendConfig, - threshold = 10, -): Promise { - if (process.platform === "win32") { - return; - } - const matchers = buildSessionMatchers(backend); - if (matchers.length === 0) { - return; - } - - try { - const stdout = await psWithFallback( - ["-axww", "-o", "pid=,ppid=,stat=,command="], - ["-ax", "-o", "pid=,ppid=,stat=,command="], - ); - const suspended: number[] = []; - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const match = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed); - if (!match) { - continue; - } - const pid = Number(match[1]); - const ppid = Number(match[2]); - const stat = match[3] ?? ""; - const command = match[4] ?? ""; - if (!Number.isFinite(pid)) { - continue; - } - if (ppid !== process.pid) { - continue; - } - if (!stat.includes("T")) { - continue; - } - if (!matchers.some((matcher) => matcher.test(command))) { - continue; - } - suspended.push(pid); - } - - if (suspended.length > threshold) { - // Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL. - await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]); - } - } catch { - // ignore errors - best effort cleanup - } -} export function enqueueCliRun(key: string, task: () => Promise): Promise { const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve(); const chained = prior.catch(() => undefined).then(task); - const tracked = chained.finally(() => { - if (CLI_RUN_QUEUE.get(key) === tracked) { - CLI_RUN_QUEUE.delete(key); - } - }); + // Keep queue continuity even when a run rejects, without emitting unhandled rejections. + const tracked = chained + .catch(() => undefined) + .finally(() => { + if (CLI_RUN_QUEUE.get(key) === tracked) { + CLI_RUN_QUEUE.delete(key); + } + }); CLI_RUN_QUEUE.set(key, tracked); return chained; } diff --git a/src/agents/cli-runner/reliability.ts b/src/agents/cli-runner/reliability.ts new file mode 100644 index 00000000000..cd1fefa9378 --- /dev/null +++ b/src/agents/cli-runner/reliability.ts @@ -0,0 +1,88 @@ +import path from "node:path"; +import type { CliBackendConfig } from "../../config/types.js"; +import { + CLI_FRESH_WATCHDOG_DEFAULTS, + CLI_RESUME_WATCHDOG_DEFAULTS, + CLI_WATCHDOG_MIN_TIMEOUT_MS, +} from "../cli-watchdog-defaults.js"; + +function pickWatchdogProfile( + backend: CliBackendConfig, + useResume: boolean, +): { + noOutputTimeoutMs?: number; + noOutputTimeoutRatio: number; + minMs: number; + maxMs: number; +} { + const defaults = useResume ? CLI_RESUME_WATCHDOG_DEFAULTS : CLI_FRESH_WATCHDOG_DEFAULTS; + const configured = useResume + ? backend.reliability?.watchdog?.resume + : backend.reliability?.watchdog?.fresh; + + const ratio = (() => { + const value = configured?.noOutputTimeoutRatio; + if (typeof value !== "number" || !Number.isFinite(value)) { + return defaults.noOutputTimeoutRatio; + } + return Math.max(0.05, Math.min(0.95, value)); + })(); + const minMs = (() => { + const value = configured?.minMs; + if (typeof value !== "number" || !Number.isFinite(value)) { + return defaults.minMs; + } + return Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(value)); + })(); + const maxMs = (() => { + const value = configured?.maxMs; + if (typeof value !== "number" || !Number.isFinite(value)) { + return defaults.maxMs; + } + return Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(value)); + })(); + + return { + noOutputTimeoutMs: + typeof configured?.noOutputTimeoutMs === "number" && + Number.isFinite(configured.noOutputTimeoutMs) + ? Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, Math.floor(configured.noOutputTimeoutMs)) + : undefined, + noOutputTimeoutRatio: ratio, + minMs: Math.min(minMs, maxMs), + maxMs: Math.max(minMs, maxMs), + }; +} + +export function resolveCliNoOutputTimeoutMs(params: { + backend: CliBackendConfig; + timeoutMs: number; + useResume: boolean; +}): number { + const profile = pickWatchdogProfile(params.backend, params.useResume); + // Keep watchdog below global timeout in normal cases. + const cap = Math.max(CLI_WATCHDOG_MIN_TIMEOUT_MS, params.timeoutMs - 1_000); + if (profile.noOutputTimeoutMs !== undefined) { + return Math.min(profile.noOutputTimeoutMs, cap); + } + const computed = Math.floor(params.timeoutMs * profile.noOutputTimeoutRatio); + const bounded = Math.min(profile.maxMs, Math.max(profile.minMs, computed)); + return Math.min(bounded, cap); +} + +export function buildCliSupervisorScopeKey(params: { + backend: CliBackendConfig; + backendId: string; + cliSessionId?: string; +}): string | undefined { + const commandToken = path + .basename(params.backend.command ?? "") + .trim() + .toLowerCase(); + const backendToken = params.backendId.trim().toLowerCase(); + const sessionToken = params.cliSessionId?.trim(); + if (!sessionToken) { + return undefined; + } + return `cli:${backendToken}:${commandToken}:${sessionToken}`; +} diff --git a/src/agents/cli-watchdog-defaults.ts b/src/agents/cli-watchdog-defaults.ts new file mode 100644 index 00000000000..c96f87e30b0 --- /dev/null +++ b/src/agents/cli-watchdog-defaults.ts @@ -0,0 +1,13 @@ +export const CLI_WATCHDOG_MIN_TIMEOUT_MS = 1_000; + +export const CLI_FRESH_WATCHDOG_DEFAULTS = { + noOutputTimeoutRatio: 0.8, + minMs: 180_000, + maxMs: 600_000, +} as const; + +export const CLI_RESUME_WATCHDOG_DEFAULTS = { + noOutputTimeoutRatio: 0.3, + minMs: 60_000, + maxMs: 180_000, +} as const; diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts new file mode 100644 index 00000000000..091df223e56 --- /dev/null +++ b/src/agents/context.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { applyConfiguredContextWindows } from "./context.js"; +import { createSessionManagerRuntimeRegistry } from "./pi-extensions/session-manager-runtime-registry.js"; + +describe("applyConfiguredContextWindows", () => { + it("overrides discovered cache values with explicit models.providers contextWindow", () => { + const cache = new Map([["anthropic/claude-opus-4-6", 1_000_000]]); + applyConfiguredContextWindows({ + cache, + modelsConfig: { + providers: { + openrouter: { + models: [{ id: "anthropic/claude-opus-4-6", contextWindow: 200_000 }], + }, + }, + }, + }); + + expect(cache.get("anthropic/claude-opus-4-6")).toBe(200_000); + }); + + it("adds config-only model context windows and ignores invalid entries", () => { + const cache = new Map(); + applyConfiguredContextWindows({ + cache, + modelsConfig: { + providers: { + openrouter: { + models: [ + { id: "custom/model", contextWindow: 150_000 }, + { id: "bad/model", contextWindow: 0 }, + { id: "", contextWindow: 300_000 }, + ], + }, + }, + }, + }); + + expect(cache.get("custom/model")).toBe(150_000); + expect(cache.has("bad/model")).toBe(false); + }); +}); + +describe("createSessionManagerRuntimeRegistry", () => { + it("stores, reads, and clears values by object identity", () => { + const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); + const key = {}; + expect(registry.get(key)).toBeNull(); + registry.set(key, { value: 1 }); + expect(registry.get(key)).toEqual({ value: 1 }); + registry.set(key, null); + expect(registry.get(key)).toBeNull(); + }); + + it("ignores non-object keys", () => { + const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); + registry.set(null, { value: 1 }); + registry.set(123, { value: 1 }); + expect(registry.get(null)).toBeNull(); + expect(registry.get(123)).toBeNull(); + }); +}); diff --git a/src/agents/context.ts b/src/agents/context.ts index b3683e235f2..c919dbf9095 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -6,13 +6,52 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; type ModelEntry = { id: string; contextWindow?: number }; +type ConfigModelEntry = { id?: string; contextWindow?: number }; +type ProviderConfigEntry = { models?: ConfigModelEntry[] }; +type ModelsConfig = { providers?: Record }; + +export function applyConfiguredContextWindows(params: { + cache: Map; + modelsConfig: ModelsConfig | undefined; +}) { + const providers = params.modelsConfig?.providers; + if (!providers || typeof providers !== "object") { + return; + } + for (const provider of Object.values(providers)) { + if (!Array.isArray(provider?.models)) { + continue; + } + for (const model of provider.models) { + const modelId = typeof model?.id === "string" ? model.id : undefined; + const contextWindow = + typeof model?.contextWindow === "number" ? model.contextWindow : undefined; + if (!modelId || !contextWindow || contextWindow <= 0) { + continue; + } + params.cache.set(modelId, contextWindow); + } + } +} const MODEL_CACHE = new Map(); const loadPromise = (async () => { + let cfg: ReturnType | undefined; + try { + cfg = loadConfig(); + } catch { + // If config can't be loaded, leave cache empty. + return; + } + + try { + await ensureOpenClawModelsJson(cfg); + } catch { + // Continue with best-effort discovery/overrides. + } + try { const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js"); - const cfg = loadConfig(); - await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); @@ -26,9 +65,16 @@ const loadPromise = (async () => { } } } catch { - // If pi-ai isn't available, leave cache empty; lookup will fall back. + // If model discovery fails, continue with config overrides only. } -})(); + + applyConfiguredContextWindows({ + cache: MODEL_CACHE, + modelsConfig: cfg.models as ModelsConfig | undefined, + }); +})().catch(() => { + // Keep lookup best-effort. +}); export function lookupContextTokens(modelId?: string): number | undefined { if (!modelId) { diff --git a/src/agents/model-auth.e2e.test.ts b/src/agents/model-auth.e2e.test.ts index 7385f18ee3c..f3439c6feb9 100644 --- a/src/agents/model-auth.e2e.test.ts +++ b/src/agents/model-auth.e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; @@ -15,9 +16,11 @@ const oauthFixture = { describe("getApiKeyForModel", () => { it("migrates legacy oauth.json into auth-profiles.json", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-")); try { @@ -73,30 +76,18 @@ describe("getApiKeyForModel", () => { }, }); } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("suggests openai-codex when only Codex OAuth is configured", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousOpenAiKey = process.env.OPENAI_API_KEY; + const envSnapshot = captureEnv([ + "OPENAI_API_KEY", + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); try { @@ -137,26 +128,7 @@ describe("getApiKeyForModel", () => { } expect(String(error)).toContain("openai-codex/gpt-5.3-codex"); } finally { - if (previousOpenAiKey === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = previousOpenAiKey; - } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); diff --git a/src/agents/model-scan.e2e.test.ts b/src/agents/model-scan.e2e.test.ts index 574ad51224a..59f50861ad6 100644 --- a/src/agents/model-scan.e2e.test.ts +++ b/src/agents/model-scan.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { scanOpenRouterModels } from "./model-scan.js"; function createFetchFixture(payload: unknown): typeof fetch { @@ -66,7 +67,7 @@ describe("scanOpenRouterModels", () => { it("requires an API key when probing", async () => { const fetchImpl = createFetchFixture({ data: [] }); - const previousKey = process.env.OPENROUTER_API_KEY; + const envSnapshot = captureEnv(["OPENROUTER_API_KEY"]); try { delete process.env.OPENROUTER_API_KEY; await expect( @@ -77,11 +78,7 @@ describe("scanOpenRouterModels", () => { }), ).rejects.toThrow(/Missing OpenRouter API key/); } finally { - if (previousKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousKey; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts index 72309c3e5b4..c5e9ac64369 100644 --- a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts +++ b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.e2e.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { installModelsConfigTestHooks, withModelsTempHome as withTempHome, @@ -12,7 +13,7 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("auto-injects github-copilot provider when token is present", async () => { await withTempHome(async (home) => { - const previous = process.env.COPILOT_GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "gh-token"; const fetchMock = vi.fn().mockResolvedValue({ ok: true, @@ -36,20 +37,14 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } + envSnapshot.restore(); } }); }); it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => { await withTempHome(async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - const previousGh = process.env.GH_TOKEN; - const previousGithub = process.env.GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "copilot-token"; process.env.GH_TOKEN = "gh-token"; process.env.GITHUB_TOKEN = "github-token"; @@ -70,9 +65,7 @@ describe("models-config", () => { const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; expect(opts?.headers?.Authorization).toBe("Bearer copilot-token"); } finally { - process.env.COPILOT_GITHUB_TOKEN = previous; - process.env.GH_TOKEN = previousGh; - process.env.GITHUB_TOKEN = previousGithub; + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts index ee0e4580de7..8458f492f18 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js"; +import { captureEnv } from "../test-utils/env.js"; import { installModelsConfigTestHooks, withModelsTempHome as withTempHome, @@ -13,7 +14,7 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("falls back to default baseUrl when token exchange fails", async () => { await withTempHome(async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "gh-token"; const fetchMock = vi.fn().mockResolvedValue({ ok: false, @@ -33,20 +34,14 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_COPILOT_API_BASE_URL); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } + envSnapshot.restore(); } }); }); it("uses agentDir override auth profiles for copilot injection", async () => { await withTempHome(async (home) => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - const previousGh = process.env.GH_TOKEN; - const previousGithub = process.env.GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; @@ -91,21 +86,7 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } - if (previousGh === undefined) { - delete process.env.GH_TOKEN; - } else { - process.env.GH_TOKEN = previousGh; - } - if (previousGithub === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previousGithub; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.providers.minimax.test.ts b/src/agents/models-config.providers.minimax.test.ts index 7832e483bce..94b7994a6cd 100644 --- a/src/agents/models-config.providers.minimax.test.ts +++ b/src/agents/models-config.providers.minimax.test.ts @@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; describe("MiniMax implicit provider (#15275)", () => { it("should use anthropic-messages API for API-key provider", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.MINIMAX_API_KEY; + const envSnapshot = captureEnv(["MINIMAX_API_KEY"]); process.env.MINIMAX_API_KEY = "test-key"; try { @@ -16,11 +17,7 @@ describe("MiniMax implicit provider (#15275)", () => { expect(providers?.minimax?.api).toBe("anthropic-messages"); expect(providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); } finally { - if (previous === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = previous; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.providers.nvidia.test.ts b/src/agents/models-config.providers.nvidia.test.ts index 42a46ebe4a1..a9920a3cba2 100644 --- a/src/agents/models-config.providers.nvidia.test.ts +++ b/src/agents/models-config.providers.nvidia.test.ts @@ -2,13 +2,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveApiKeyForProvider } from "./model-auth.js"; import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js"; describe("NVIDIA provider", () => { it("should include nvidia when NVIDIA_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.NVIDIA_API_KEY; + const envSnapshot = captureEnv(["NVIDIA_API_KEY"]); process.env.NVIDIA_API_KEY = "test-key"; try { @@ -16,17 +17,13 @@ describe("NVIDIA provider", () => { expect(providers?.nvidia).toBeDefined(); expect(providers?.nvidia?.models?.length).toBeGreaterThan(0); } finally { - if (previous === undefined) { - delete process.env.NVIDIA_API_KEY; - } else { - process.env.NVIDIA_API_KEY = previous; - } + envSnapshot.restore(); } }); it("resolves the nvidia api key value from env", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.NVIDIA_API_KEY; + const envSnapshot = captureEnv(["NVIDIA_API_KEY"]); process.env.NVIDIA_API_KEY = "nvidia-test-api-key"; try { @@ -39,11 +36,7 @@ describe("NVIDIA provider", () => { expect(auth.mode).toBe("api-key"); expect(auth.source).toContain("NVIDIA_API_KEY"); } finally { - if (previous === undefined) { - delete process.env.NVIDIA_API_KEY; - } else { - process.env.NVIDIA_API_KEY = previous; - } + envSnapshot.restore(); } }); diff --git a/src/agents/models-config.providers.qianfan.e2e.test.ts b/src/agents/models-config.providers.qianfan.e2e.test.ts index 17527262897..06f47787464 100644 --- a/src/agents/models-config.providers.qianfan.e2e.test.ts +++ b/src/agents/models-config.providers.qianfan.e2e.test.ts @@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; describe("Qianfan provider", () => { it("should include qianfan when QIANFAN_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.QIANFAN_API_KEY; + const envSnapshot = captureEnv(["QIANFAN_API_KEY"]); process.env.QIANFAN_API_KEY = "test-key"; try { @@ -15,11 +16,7 @@ describe("Qianfan provider", () => { expect(providers?.qianfan).toBeDefined(); expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY"); } finally { - if (previous === undefined) { - delete process.env.QIANFAN_API_KEY; - } else { - process.env.QIANFAN_API_KEY = previous; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts index b858a234a24..92b5d19dddf 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.e2e.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { installModelsConfigTestHooks, @@ -13,9 +14,7 @@ installModelsConfigTestHooks({ restoreFetch: true }); describe("models-config", () => { it("uses the first github-copilot profile when env tokens are missing", async () => { await withTempHome(async (home) => { - const previous = process.env.COPILOT_GITHUB_TOKEN; - const previousGh = process.env.GH_TOKEN; - const previousGithub = process.env.GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]); delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; @@ -61,28 +60,14 @@ describe("models-config", () => { const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; expect(opts?.headers?.Authorization).toBe("Bearer alpha-token"); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } - if (previousGh === undefined) { - delete process.env.GH_TOKEN; - } else { - process.env.GH_TOKEN = previousGh; - } - if (previousGithub === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previousGithub; - } + envSnapshot.restore(); } }); }); it("does not override explicit github-copilot provider config", async () => { await withTempHome(async () => { - const previous = process.env.COPILOT_GITHUB_TOKEN; + const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN"]); process.env.COPILOT_GITHUB_TOKEN = "gh-token"; const fetchMock = vi.fn().mockResolvedValue({ ok: true, @@ -115,11 +100,7 @@ describe("models-config", () => { expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://copilot.local"); } finally { - if (previous === undefined) { - delete process.env.COPILOT_GITHUB_TOKEN; - } else { - process.env.COPILOT_GITHUB_TOKEN = previous; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index e17f1980939..61c1f4ec16c 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import "./test-helpers/fast-core-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; @@ -18,8 +19,7 @@ describe("gateway tool", () => { it("schedules SIGUSR1 restart", async () => { vi.useFakeTimers(); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousProfile = process.env.OPENCLAW_PROFILE; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_PROFILE"]); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_PROFILE = "isolated"; @@ -60,16 +60,8 @@ describe("gateway tool", () => { } finally { kill.mockRestore(); vi.useRealTimers(); - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - } else { - process.env.OPENCLAW_PROFILE = previousProfile; - } + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); } }); diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index df8e1bb7186..b9a9c56dd2b 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -783,7 +783,7 @@ describe("sessions tools", () => { text?: string; }; expect(details.status).toBe("ok"); - expect(details.text).toContain("tokens 1k (in 12 / out 1k)"); + expect(details.text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); expect(details.text).toContain("prompt/cache 197k"); expect(details.text).not.toContain("1.0k io"); } finally { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts index c9b7175717a..ecd32cab749 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts @@ -33,21 +33,37 @@ vi.mock("../gateway/call.js", () => { }; }); +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + describe("sessions_spawn thinking defaults", () => { it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); const result = await tool.execute("call-1", { task: "hello" }); expect(result.details).toMatchObject({ status: "accepted" }); - const { callGateway } = await import("../gateway/call.js"); - const calls = (callGateway as unknown as ReturnType).mock.calls; - - const agentCall = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "agent"); - const thinkingPatch = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel); + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); expect(agentCall?.params?.thinking).toBe("high"); expect(thinkingPatch?.params?.thinkingLevel).toBe("high"); @@ -58,15 +74,12 @@ describe("sessions_spawn thinking defaults", () => { const result = await tool.execute("call-2", { task: "hello", thinking: "low" }); expect(result.details).toMatchObject({ status: "accepted" }); - const { callGateway } = await import("../gateway/call.js"); - const calls = (callGateway as unknown as ReturnType).mock.calls; - - const agentCall = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "agent"); - const thinkingPatch = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel); + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); expect(agentCall?.params?.thinking).toBe("low"); expect(thinkingPatch?.params?.thinkingLevel).toBe("low"); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index b1c697064f5..2e568714b71 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, @@ -10,6 +9,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -19,13 +31,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call6", { task: "do thing", @@ -57,13 +66,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call9", { task: "do thing", @@ -78,7 +84,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows cross-agent spawning when configured", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -109,13 +115,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call7", { task: "do thing", @@ -132,7 +135,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows any agent when allowlist is *", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -163,13 +166,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call8", { task: "do thing", @@ -186,7 +186,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn normalizes allowlisted agent ids", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -217,13 +217,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call10", { task: "do thing", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index 002683386be..e82d4e2dc6a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -1,16 +1,140 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; -import { sleep } from "../utils.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; +import { sleep } from "../utils.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +vi.mock("./pi-embedded.js", () => ({ + isEmbeddedPiRunActive: () => false, + isEmbeddedPiRunStreaming: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, +})); + const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + +type GatewayRequest = { method?: string; params?: unknown }; +type AgentWaitCall = { runId?: string; timeoutMs?: number }; + +function setupSessionsSpawnGatewayMock(opts: { + includeSessionsList?: boolean; + includeChatHistory?: boolean; + onAgentSubagentSpawn?: (params: unknown) => void; + onSessionsPatch?: (params: unknown) => void; + onSessionsDelete?: (params: unknown) => void; + agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; +}): { + calls: Array; + waitCalls: Array; + getChild: () => { runId?: string; sessionKey?: string }; +} { + const calls: Array = []; + const waitCalls: Array = []; + let agentCallCount = 0; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + + callGatewayMock.mockImplementation(async (optsUnknown: unknown) => { + const request = optsUnknown as GatewayRequest; + calls.push(request); + + if (request.method === "sessions.list" && opts.includeSessionsList) { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params?.sessionKey ?? ""; + opts.onAgentSubagentSpawn?.(params); + } + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + + if (request.method === "agent.wait") { + const params = request.params as AgentWaitCall | undefined; + waitCalls.push(params ?? {}); + const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 }; + return { + runId: params?.runId ?? "run-1", + ...res, + }; + } + + if (request.method === "sessions.patch") { + opts.onSessionsPatch?.(request.params); + return { ok: true }; + } + + if (request.method === "sessions.delete") { + opts.onSessionsDelete?.(request.params); + return { ok: true }; + } + + if (request.method === "chat.history" && opts.includeChatHistory) { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + + return {}; + }); + + return { + calls, + waitCalls, + getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), + }; +} + +const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`timed out waiting for condition (timeoutMs=${timeoutMs})`); + } + await sleep(10); + } +}; + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -19,84 +143,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - let patchParams: { key?: string; label?: string } = {}; + const patchCalls: Array<{ key?: string; label?: string }> = []; - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.list") { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.patch") { - const params = request.params as { key?: string; label?: string } | undefined; - patchParams = { key: params?.key, label: params?.label }; - return { ok: true }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeSessionsList: true, + includeChatHistory: true, + onSessionsPatch: (params) => { + const rec = params as { key?: string; label?: string } | undefined; + patchCalls.push({ key: rec?.key, label: rec?.label }); + }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call2", { task: "do thing", @@ -108,11 +169,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -121,18 +183,19 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - await sleep(0); - await sleep(0); - await sleep(0); + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => patchCalls.some((call) => call.label === "my-task")); + await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); // Cleanup should patch the label - expect(patchParams.key).toBe(childSessionKey); - expect(patchParams.label).toBe("my-task"); + const labelPatch = patchCalls.find((call) => call.label === "my-task"); + expect(labelPatch?.key).toBe(child.sessionKey); + expect(labelPatch?.label).toBe("my-task"); // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((c) => c.method === "agent"); + const agentCalls = ctx.calls.filter((c) => c.method === "agent"); expect(agentCalls).toHaveLength(2); // First call: subagent spawn @@ -145,71 +208,31 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.message).toContain("subagent task"); // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); it("sessions_spawn runs cleanup via lifecycle events", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 1000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call1", { task: "do thing", @@ -221,13 +244,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } vi.useFakeTimers(); try { emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -241,10 +265,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { vi.useRealTimers(); } - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const first = agentCalls[0]?.params as @@ -259,7 +283,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.deliver).toBe(false); expect(first?.channel).toBe("discord"); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); const second = agentCalls[1]?.params as | { @@ -272,7 +296,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.deliver).toBe(true); expect(second?.message).toContain("subagent task"); - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); @@ -281,74 +305,25 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 3000, - endedAt: 4000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeChatHistory: true, + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, + agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call1b", { task: "do thing", @@ -360,16 +335,20 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await sleep(0); - await sleep(0); - await sleep(0); + const child = ctx.getChild(); + if (!child.runId) { + throw new Error("missing child runId"); + } + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => Boolean(deletedKey)); - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); // First call: subagent spawn @@ -382,7 +361,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.deliver).toBe(true); // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); // Session should be deleted @@ -428,13 +407,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-timeout", { task: "do thing", @@ -446,9 +422,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await sleep(0); - await sleep(0); - await sleep(0); + await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2); const mainAgentCall = calls .filter((call) => call.method === "agent") @@ -500,14 +474,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", agentAccountId: "kev", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-announce-account", { task: "do thing", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 7d3cd00d62d..288f3b44611 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, @@ -11,6 +10,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -46,13 +58,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call3", { task: "do thing", @@ -93,13 +102,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-thinking", { task: "do thing", @@ -126,13 +132,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-thinking-invalid", { task: "do thing", @@ -166,13 +169,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-default-model", { task: "do thing", @@ -207,13 +207,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-runtime-default-model", { task: "do thing", @@ -255,13 +252,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:research:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-agent-model", { task: "do thing", @@ -271,7 +265,9 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { modelApplied: true, }); - const patchCall = calls.find((call) => call.method === "sessions.patch"); + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); expect(patchCall?.params).toMatchObject({ model: "opencode/claude", }); @@ -287,7 +283,11 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { const request = opts as { method?: string; params?: unknown }; calls.push(request); if (request.method === "sessions.patch") { - throw new Error("invalid model: bad-model"); + const model = (request.params as { model?: unknown } | undefined)?.model; + if (model === "bad-model") { + throw new Error("invalid model: bad-model"); + } + return { ok: true }; } if (request.method === "agent") { agentCallCount += 1; @@ -307,13 +307,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call4", { task: "do thing", @@ -345,13 +342,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call5", { task: "do thing", diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts index 20097404db5..2e51e8a2952 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts @@ -5,31 +5,6 @@ vi.mock("../../utils.js", () => ({ resolveUserPath: vi.fn((p: string) => p), })); -vi.mock("../auth-profiles.js", () => ({ - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, - ), - hasNonzeroUsage: vi.fn(() => false), -})); - vi.mock("../pi-embedded-helpers.js", async () => { return { isCompactionFailureError: (msg?: string) => { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 407788564ab..6a872721859 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -1,5 +1,31 @@ import { vi } from "vitest"; +vi.mock("../auth-profiles.js", () => ({ + isProfileInCooldown: vi.fn(() => false), + markAuthProfileFailure: vi.fn(async () => {}), + markAuthProfileGood: vi.fn(async () => {}), + markAuthProfileUsed: vi.fn(async () => {}), +})); + +vi.mock("../usage.js", () => ({ + normalizeUsage: vi.fn((usage?: unknown) => + usage && typeof usage === "object" ? usage : undefined, + ), + derivePromptTokens: vi.fn( + (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { + if (!usage) { + return undefined; + } + const input = usage.input ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const sum = input + cacheRead + cacheWrite; + return sum > 0 ? sum : undefined; + }, + ), + hasNonzeroUsage: vi.fn(() => false), +})); + vi.mock("./run/attempt.js", () => ({ runEmbeddedAttempt: vi.fn(), })); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index ded9da42c02..20944a29bad 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,31 +1,6 @@ import "./run.overflow-compaction.mocks.shared.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, - ), -})); - vi.mock("../workspace-run.js", () => ({ resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ workspaceDir: params.workspaceDir, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a7c3d17dcbc..9833a3ac529 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -968,6 +968,32 @@ export async function runEmbeddedAttempt( ); } + if (hookRunner?.hasHooks("llm_input")) { + hookRunner + .runLlmInput( + { + runId: params.runId, + sessionId: params.sessionId, + provider: params.provider, + model: params.modelId, + systemPrompt: systemPromptText, + prompt: effectivePrompt, + historyMessages: activeSession.messages, + imagesCount: imageResult.images.length, + }, + { + agentId: hookAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`llm_input hook failed: ${String(err)}`); + }); + } + // Only pass images option if there are actually images to pass // This avoids potential issues with models that don't expect the images parameter if (imageResult.images.length > 0) { @@ -1117,6 +1143,31 @@ export async function runEmbeddedAttempt( ) .map((entry) => ({ toolName: entry.toolName, meta: entry.meta })); + if (hookRunner?.hasHooks("llm_output")) { + hookRunner + .runLlmOutput( + { + runId: params.runId, + sessionId: params.sessionId, + provider: params.provider, + model: params.modelId, + assistantTexts, + lastAssistant, + usage: getUsageTotals(), + }, + { + agentId: hookAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`llm_output hook failed: ${String(err)}`); + }); + } + return { aborted, timedOut, diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts new file mode 100644 index 00000000000..64975e8c72c --- /dev/null +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -0,0 +1,28 @@ +type SubscribeEmbeddedPiSession = + typeof import("./pi-embedded-subscribe.js").subscribeEmbeddedPiSession; +type PiSession = Parameters[0]["session"]; + +export function createStubSessionHarness(): { + session: PiSession; + emit: (evt: unknown) => void; +} { + let handler: ((evt: unknown) => void) | undefined; + const session = { + subscribe: (fn: (evt: unknown) => void) => { + handler = fn; + return () => {}; + }, + } as unknown as PiSession; + + return { session, emit: (evt: unknown) => handler?.(evt) }; +} + +export function extractAgentEventPayloads(calls: Array): Array> { + return calls + .map((call) => { + const first = call?.[0] as { data?: unknown } | undefined; + const data = first?.data; + return data && typeof data === "object" ? (data as Record) : undefined; + }) + .filter((value): value is Record => Boolean(value)); +} diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts index 30336ed38ec..020d7e939d4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts @@ -8,13 +8,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("calls onBlockReplyFlush before tool_execution_start to preserve message boundaries", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts index 690a1d7abf4..c268c11ff86 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - function setupTextEndSubscription() { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts index 60460571309..1a909ae2746 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts @@ -8,13 +8,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not call onBlockReplyFlush when callback is not provided", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts index 00138a7f9ab..a68984b272d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not duplicate when text_end repeats full content", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts index 827c58193fd..ee7037a24c0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts @@ -1,40 +1,22 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - -type SessionEventHandler = (evt: unknown) => void; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not emit duplicate block replies when text_end repeats", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "text_end", }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -43,7 +25,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -51,7 +33,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -63,16 +45,10 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["Hello block"]); }); it("does not duplicate assistantTexts when message_end repeats", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", }); @@ -81,22 +57,16 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", }); @@ -110,22 +80,16 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessageWithNewline }); - handler?.({ type: "message_end", message: assistantMessageTrimmed }); + emit({ type: "message_end", message: assistantMessageWithNewline }); + emit({ type: "message_end", message: assistantMessageTrimmed }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", reasoningMode: "on", }); @@ -138,37 +102,31 @@ describe("subscribeEmbeddedPiSession", () => { ], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("populates assistantTexts for non-streaming models with chunking enabled", () => { // Non-streaming models (e.g. zai/glm-4.7): no text_delta events; message_end // must still populate assistantTexts so providers can deliver a final reply. - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", blockReplyChunking: { minChars: 50, maxChars: 200 }, // Chunking enabled }); // Simulate non-streaming model: only message_start and message_end, no text_delta - handler?.({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_start", message: { role: "assistant" } }); const assistantMessage = { role: "assistant", content: [{ type: "text", text: "Response from non-streaming model" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Response from non-streaming model"]); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts index d8fcf94c91e..7ce844c55a9 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("emits block replies on text_end and does not duplicate on message_end", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts index ad7bdfd81cb..76a51a89197 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts @@ -1,41 +1,28 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createStubSessionHarness, + extractAgentEventPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("filters to and suppresses output without a start tag", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", enforceFinalTag: true, onPartialReply, onAgentEvent, }); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -50,8 +37,8 @@ describe("subscribeEmbeddedPiSession", () => { onPartialReply.mockReset(); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -63,18 +50,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(onPartialReply).not.toHaveBeenCalled(); }); it("emits agent events on message_end even without tags", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", enforceFinalTag: true, onAgentEvent, @@ -85,12 +66,10 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_start", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("Hello world"); expect(payloads[0]?.delta).toBe("Hello world"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts index 37532c48a86..3b04100219b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("includes canvas action metadata in tool summaries", async () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts index 8b4d539465c..0bb70f3d8b5 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("keeps assistantTexts to the final answer when block replies are disabled", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts index d8d868541ad..507ca49da7b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("keeps indented fenced blocks intact", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts index f786b104f1f..b3d800af04b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("reopens fenced blocks when splitting inside them", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts index 19cbeaa2a40..f6eeb24a27d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("splits long single-line fenced blocks with reopen/close", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts index 59973be7e21..6c1bd3f0b13 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts @@ -1,32 +1,16 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("streams soft chunks with paragraph preference", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -39,7 +23,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = "First block line\n\nSecond block line"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -53,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0][0].text).toBe("First block line"); @@ -61,18 +45,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["First block line", "Second block line"]); }); it("avoids splitting inside fenced code blocks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -85,7 +63,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = "Intro\n\n```bash\nline1\nline2\n```\n\nOutro"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -99,7 +77,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(3); expect(onBlockReply.mock.calls[0][0].text).toBe("Intro"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index b53ffa62e53..1371a697d75 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -1,5 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createStubSessionHarness, + extractAgentEventPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; type StubSession = { @@ -186,18 +190,12 @@ describe("subscribeEmbeddedPiSession", () => { }); it("emits agent events on message_end for non-streaming assistant text", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onAgentEvent, }); @@ -207,12 +205,10 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_start", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("Hello world"); expect(payloads[0]?.delta).toBe("Hello world"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts index a28d55358b4..bb0fff53264 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("suppresses message_end block replies when the message tool already sent", async () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts index 2f961082555..319baf58bf8 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("waits for multiple compaction retries before resolving", async () => { const listeners: SessionEventHandler[] = []; const session = { diff --git a/src/agents/pi-extensions/session-manager-runtime-registry.test.ts b/src/agents/pi-extensions/session-manager-runtime-registry.test.ts deleted file mode 100644 index 59e004fab7a..00000000000 --- a/src/agents/pi-extensions/session-manager-runtime-registry.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js"; - -describe("createSessionManagerRuntimeRegistry", () => { - it("stores, reads, and clears values by object identity", () => { - const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); - const key = {}; - expect(registry.get(key)).toBeNull(); - registry.set(key, { value: 1 }); - expect(registry.get(key)).toEqual({ value: 1 }); - registry.set(key, null); - expect(registry.get(key)).toBeNull(); - }); - - it("ignores non-object keys", () => { - const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); - registry.set(null, { value: 1 }); - registry.set(123, { value: 1 }); - expect(registry.get(null)).toBeNull(); - expect(registry.get(123)).toBeNull(); - }); -}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts index 6104fc16936..51ccca68c42 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts @@ -102,7 +102,10 @@ describe("createOpenClawCodingTools", () => { execute, }; - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); + const wrapped = __testing.wrapToolParamNormalization(tool, [ + { keys: ["path", "file_path"], label: "path (path or file_path)" }, + { keys: ["content"], label: "content" }, + ]); await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); expect(execute).toHaveBeenCalledWith( @@ -115,9 +118,21 @@ describe("createOpenClawCodingTools", () => { await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( /Missing required parameter/, ); + await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( /Missing required parameter/, ); + await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Missing required parameters: path \(path or file_path\), content/, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); }); }); diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 3798c6dd8b1..71e9bb72348 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -87,6 +87,12 @@ type RequiredParamGroup = { label?: string; }; +const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying."; + +function parameterValidationError(message: string): Error { + return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`); +} + export const CLAUDE_PARAM_GROUPS = { read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }], write: [ @@ -245,9 +251,10 @@ export function assertRequiredParams( toolName: string, ): void { if (!record || typeof record !== "object") { - throw new Error(`Missing parameters for ${toolName}`); + throw parameterValidationError(`Missing parameters for ${toolName}`); } + const missingLabels: string[] = []; for (const group of groups) { const satisfied = group.keys.some((key) => { if (!(key in record)) { @@ -265,9 +272,15 @@ export function assertRequiredParams( if (!satisfied) { const label = group.label ?? group.keys.join(" or "); - throw new Error(`Missing required parameter: ${label}`); + missingLabels.push(label); } } + + if (missingLabels.length > 0) { + const joined = missingLabels.join(", "); + const noun = missingLabels.length === 1 ? "parameter" : "parameters"; + throw parameterValidationError(`Missing required ${noun}: ${joined}`); + } } // Generic wrapper to normalize parameters for any tool diff --git a/src/agents/pi-tools.safe-bins.e2e.test.ts b/src/agents/pi-tools.safe-bins.e2e.test.ts index 665059035d2..f022a84abc1 100644 --- a/src/agents/pi-tools.safe-bins.e2e.test.ts +++ b/src/agents/pi-tools.safe-bins.e2e.test.ts @@ -4,8 +4,9 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import { captureEnv } from "../test-utils/env.js"; -const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const bundledPluginsDirSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]); beforeAll(() => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join( @@ -15,32 +16,18 @@ beforeAll(() => { }); afterAll(() => { - if (previousBundledPluginsDir === undefined) { - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - } else { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; - } + bundledPluginsDirSnapshot.restore(); }); vi.mock("../infra/shell-env.js", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - getShellPathFromLoginShell: vi.fn(() => "/usr/bin:/bin"), + getShellPathFromLoginShell: vi.fn(() => null), resolveShellEnvFallbackTimeoutMs: vi.fn(() => 500), }; }); -vi.mock("../plugins/tools.js", () => ({ - getPluginToolMeta: () => undefined, - resolvePluginTools: () => [], -})); - -vi.mock("../infra/shell-env.js", async (importOriginal) => { - const mod = await importOriginal(); - return { ...mod, getShellPathFromLoginShell: () => null }; -}); - vi.mock("../plugins/tools.js", () => ({ resolvePluginTools: () => [], getPluginToolMeta: () => undefined, @@ -109,20 +96,16 @@ describe("createOpenClawCodingTools safeBins", () => { expect(execTool).toBeDefined(); const marker = `safe-bins-${Date.now()}`; - const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; - process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; + const envSnapshot = captureEnv(["OPENCLAW_SHELL_ENV_TIMEOUT_MS"]); const result = await (async () => { try { + process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; return await execTool!.execute("call1", { command: `echo ${marker}`, workdir: tmpDir, }); } finally { - if (prevShellEnvTimeoutMs === undefined) { - delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; - } else { - process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs; - } + envSnapshot.restore(); } })(); const text = result.content.find((content) => content.type === "text")?.text ?? ""; diff --git a/src/agents/sandbox-create-args.e2e.test.ts b/src/agents/sandbox-create-args.e2e.test.ts index 5200572c86e..ccb9b3395ad 100644 --- a/src/agents/sandbox-create-args.e2e.test.ts +++ b/src/agents/sandbox-create-args.e2e.test.ts @@ -94,7 +94,7 @@ describe("buildSandboxCreateArgs", () => { ); }); - it("emits -v flags for custom binds", () => { + it("emits -v flags for safe custom binds", () => { const cfg: SandboxDockerConfig = { image: "openclaw-sandbox:bookworm-slim", containerPrefix: "openclaw-sbx-", @@ -103,7 +103,7 @@ describe("buildSandboxCreateArgs", () => { tmpfs: [], network: "none", capDrop: [], - binds: ["/home/user/source:/source:rw", "/var/run/docker.sock:/var/run/docker.sock"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }; const args = buildSandboxCreateArgs({ @@ -124,7 +124,116 @@ describe("buildSandboxCreateArgs", () => { } } expect(vFlags).toContain("/home/user/source:/source:rw"); - expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock"); + expect(vFlags).toContain("/var/data/myapp:/data:ro"); + }); + + it("throws on dangerous bind mounts (Docker socket)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/var/run/docker.sock:/var/run/docker.sock"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on dangerous bind mounts (parent path)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/run:/run"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous-parent", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on network host mode", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "host", + capDrop: [], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-host", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/network mode "host" is blocked/); + }); + + it("throws on seccomp unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + seccompProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-seccomp", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/seccomp profile "unconfined" is blocked/); + }); + + it("throws on apparmor unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + apparmorProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-apparmor", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/apparmor profile "unconfined" is blocked/); }); it("omits -v flags when binds is empty or undefined", () => { diff --git a/src/agents/sandbox-skills.e2e.test.ts b/src/agents/sandbox-skills.e2e.test.ts index ae37f2a9fe9..0280c5d529a 100644 --- a/src/agents/sandbox-skills.e2e.test.ts +++ b/src/agents/sandbox-skills.e2e.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { captureFullEnv } from "../test-utils/env.js"; import { resolveSandboxContext } from "./sandbox.js"; vi.mock("./sandbox/docker.js", () => ({ @@ -27,30 +28,15 @@ async function writeSkill(params: { dir: string; name: string; description: stri ); } -function restoreEnv(snapshot: Record) { - for (const key of Object.keys(process.env)) { - if (!(key in snapshot)) { - delete process.env[key]; - } - } - for (const [key, value] of Object.entries(snapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - describe("sandbox skill mirroring", () => { - let envSnapshot: Record; + let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = { ...process.env }; + envSnapshot = captureFullEnv(); }); afterEach(() => { - restoreEnv(envSnapshot); + envSnapshot.restore(); }); const runContext = async (workspaceAccess: "none" | "ro") => { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index f79885d8a13..f87f7d5f5b4 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -111,6 +111,7 @@ import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; +import { validateSandboxSecurity } from "./validate-sandbox-security.js"; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; @@ -240,6 +241,9 @@ export function buildSandboxCreateArgs(params: { labels?: Record; configHash?: string; }) { + // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. + validateSandboxSecurity(params.cfg); + const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "openclaw.sandbox=1"); diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts new file mode 100644 index 00000000000..91668c74e76 --- /dev/null +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -0,0 +1,146 @@ +import { mkdtempSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + getBlockedBindReasonStringOnly, + validateBindMounts, + validateNetworkMode, + validateSeccompProfile, + validateApparmorProfile, + validateSandboxSecurity, +} from "./validate-sandbox-security.js"; + +describe("getBlockedBindReasonStringOnly", () => { + it("blocks ancestor mounts that would expose the Docker socket", () => { + expect(getBlockedBindReasonStringOnly("/run:/run")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + expect(getBlockedBindReasonStringOnly("/var/run:/var/run:ro")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + expect(getBlockedBindReasonStringOnly("/var:/var")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + }); +}); + +describe("validateBindMounts", () => { + it("allows legitimate project directory mounts", () => { + expect(() => + validateBindMounts([ + "/home/user/source:/source:rw", + "/home/user/projects:/projects:ro", + "/var/data/myapp:/data", + "/opt/myapp/config:/config:ro", + ]), + ).not.toThrow(); + }); + + it("allows undefined or empty binds", () => { + expect(() => validateBindMounts(undefined)).not.toThrow(); + expect(() => validateBindMounts([])).not.toThrow(); + }); + + it("blocks /etc mount", () => { + expect(() => validateBindMounts(["/etc/passwd:/mnt/passwd:ro"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks /proc mount", () => { + expect(() => validateBindMounts(["/proc:/proc:ro"])).toThrow(/blocked path "\/proc"/); + }); + + it("blocks Docker socket mounts (/var/run + /run)", () => { + expect(() => validateBindMounts(["/var/run/docker.sock:/var/run/docker.sock"])).toThrow( + /docker\.sock/, + ); + expect(() => validateBindMounts(["/run/docker.sock:/run/docker.sock"])).toThrow(/docker\.sock/); + }); + + it("blocks parent mounts that would expose the Docker socket", () => { + expect(() => validateBindMounts(["/run:/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var/run:/var/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var:/var"])).toThrow(/blocked path/); + }); + + it("blocks paths with .. traversal to dangerous directories", () => { + expect(() => validateBindMounts(["/home/user/../../etc/shadow:/mnt/shadow"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks paths with double slashes normalizing to dangerous dirs", () => { + expect(() => validateBindMounts(["//etc//passwd:/mnt/passwd"])).toThrow(/blocked path "\/etc"/); + }); + + it("blocks symlink escapes into blocked directories", () => { + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const link = join(dir, "etc-link"); + symlinkSync("/etc", link); + expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/); + }); + + it("rejects non-absolute source paths (relative or named volumes)", () => { + expect(() => validateBindMounts(["../etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["myvol:/mnt"])).toThrow(/non-absolute/); + }); +}); + +describe("validateNetworkMode", () => { + it("allows bridge/none/custom/undefined", () => { + expect(() => validateNetworkMode("bridge")).not.toThrow(); + expect(() => validateNetworkMode("none")).not.toThrow(); + expect(() => validateNetworkMode("my-custom-network")).not.toThrow(); + expect(() => validateNetworkMode(undefined)).not.toThrow(); + }); + + it("blocks host mode (case-insensitive)", () => { + expect(() => validateNetworkMode("host")).toThrow(/network mode "host" is blocked/); + expect(() => validateNetworkMode("HOST")).toThrow(/network mode "HOST" is blocked/); + }); +}); + +describe("validateSeccompProfile", () => { + it("allows custom profile paths/undefined", () => { + expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow(); + expect(() => validateSeccompProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateSeccompProfile("unconfined")).toThrow( + /seccomp profile "unconfined" is blocked/, + ); + expect(() => validateSeccompProfile("Unconfined")).toThrow( + /seccomp profile "Unconfined" is blocked/, + ); + }); +}); + +describe("validateApparmorProfile", () => { + it("allows named profile/undefined", () => { + expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow(); + expect(() => validateApparmorProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateApparmorProfile("unconfined")).toThrow( + /apparmor profile "unconfined" is blocked/, + ); + }); +}); + +describe("validateSandboxSecurity", () => { + it("passes with safe config", () => { + expect(() => + validateSandboxSecurity({ + binds: ["/home/user/src:/src:rw"], + network: "none", + seccompProfile: "/tmp/seccomp.json", + apparmorProfile: "openclaw-sandbox", + }), + ).not.toThrow(); + }); +}); diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts new file mode 100644 index 00000000000..57498e54401 --- /dev/null +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -0,0 +1,208 @@ +/** + * Sandbox security validation — blocks dangerous Docker configurations. + * + * Threat model: local-trusted config, but protect against foot-guns and config injection. + * Enforced at runtime when creating sandbox containers. + */ + +import { existsSync, realpathSync } from "node:fs"; +import { posix } from "node:path"; + +// Targeted denylist: host paths that should never be exposed inside sandbox containers. +// Exported for reuse in security audit collectors. +export const BLOCKED_HOST_PATHS = [ + "/etc", + "/private/etc", + "/proc", + "/sys", + "/dev", + "/root", + "/boot", + "/var/run/docker.sock", + "/private/var/run/docker.sock", + "/run/docker.sock", +]; + +const BLOCKED_NETWORK_MODES = new Set(["host"]); +const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); +const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); + +export type BlockedBindReason = + | { kind: "targets"; blockedPath: string } + | { kind: "covers"; blockedPath: string } + | { kind: "non_absolute"; sourcePath: string }; + +/** + * Parse the host/source path from a Docker bind mount string. + * Format: `source:target[:mode]` + */ +export function parseBindSourcePath(bind: string): string { + const trimmed = bind.trim(); + const firstColon = trimmed.indexOf(":"); + if (firstColon <= 0) { + // No colon or starts with colon — treat as source. + return trimmed; + } + return trimmed.slice(0, firstColon); +} + +/** + * Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`. + */ +export function normalizeHostPath(raw: string): string { + const trimmed = raw.trim(); + return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; +} + +/** + * String-only blocked-path check (no filesystem I/O). + * Blocks: + * - binds that target blocked paths (equal or under) + * - binds that cover blocked paths (ancestor mounts like /run or /var) + * - non-absolute source paths (relative / volume names) because they are hard to validate safely + */ +export function getBlockedBindReasonStringOnly(bind: string): BlockedBindReason | null { + const sourceRaw = parseBindSourcePath(bind); + if (!sourceRaw.startsWith("/")) { + return { kind: "non_absolute", sourcePath: sourceRaw }; + } + + const normalized = normalizeHostPath(sourceRaw); + + for (const blocked of BLOCKED_HOST_PATHS) { + if (normalized === blocked || normalized.startsWith(blocked + "/")) { + return { kind: "targets", blockedPath: blocked }; + } + // Ancestor mounts: mounting /run exposes /run/docker.sock. + if (normalized === "/") { + return { kind: "covers", blockedPath: blocked }; + } + if (blocked.startsWith(normalized + "/")) { + return { kind: "covers", blockedPath: blocked }; + } + } + + return null; +} + +function tryRealpathAbsolute(path: string): string { + if (!path.startsWith("/")) { + return path; + } + if (!existsSync(path)) { + return path; + } + try { + // Use native when available (keeps platform semantics); normalize for prefix checks. + return normalizeHostPath(realpathSync.native(path)); + } catch { + return path; + } +} + +function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error { + if (params.reason.kind === "non_absolute") { + return new Error( + `Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` + + `"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`, + ); + } + const verb = params.reason.kind === "covers" ? "covers" : "targets"; + return new Error( + `Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` + + "Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " + + "Use project-specific paths instead (e.g. /home/user/myproject).", + ); +} + +/** + * Validate bind mounts — throws if any source path is dangerous. + * Includes a symlink/realpath pass when the source path exists. + */ +export function validateBindMounts(binds: string[] | undefined): void { + if (!binds?.length) { + return; + } + + for (const rawBind of binds) { + const bind = rawBind.trim(); + if (!bind) { + continue; + } + + // Fast string-only check (covers .., //, ancestor/descendant logic). + const blocked = getBlockedBindReasonStringOnly(bind); + if (blocked) { + throw formatBindBlockedError({ bind, reason: blocked }); + } + + // Symlink escape hardening: resolve existing absolute paths and re-check. + const sourceRaw = parseBindSourcePath(bind); + const sourceNormalized = normalizeHostPath(sourceRaw); + const sourceReal = tryRealpathAbsolute(sourceNormalized); + if (sourceReal !== sourceNormalized) { + for (const blockedPath of BLOCKED_HOST_PATHS) { + if (sourceReal === blockedPath || sourceReal.startsWith(blockedPath + "/")) { + throw formatBindBlockedError({ + bind, + reason: { kind: "targets", blockedPath }, + }); + } + if (sourceReal === "/") { + throw formatBindBlockedError({ + bind, + reason: { kind: "covers", blockedPath }, + }); + } + if (blockedPath.startsWith(sourceReal + "/")) { + throw formatBindBlockedError({ + bind, + reason: { kind: "covers", blockedPath }, + }); + } + } + } + } +} + +export function validateNetworkMode(network: string | undefined): void { + if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) { + throw new Error( + `Sandbox security: network mode "${network}" is blocked. ` + + 'Network "host" mode bypasses container network isolation. ' + + 'Use "bridge" or "none" instead.', + ); + } +} + +export function validateSeccompProfile(profile: string | undefined): void { + if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: seccomp profile "${profile}" is blocked. ` + + "Disabling seccomp removes syscall filtering and weakens sandbox isolation. " + + "Use a custom seccomp profile file or omit this setting.", + ); + } +} + +export function validateApparmorProfile(profile: string | undefined): void { + if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: apparmor profile "${profile}" is blocked. ` + + "Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " + + "Use a named AppArmor profile or omit this setting.", + ); + } +} + +export function validateSandboxSecurity(cfg: { + binds?: string[]; + network?: string; + seccompProfile?: string; + apparmorProfile?: string; +}): void { + validateBindMounts(cfg.binds); + validateNetworkMode(cfg.network); + validateSeccompProfile(cfg.seccompProfile); + validateApparmorProfile(cfg.apparmorProfile); +} diff --git a/src/agents/sanitize-for-prompt.test.ts b/src/agents/sanitize-for-prompt.test.ts new file mode 100644 index 00000000000..b79a1250bab --- /dev/null +++ b/src/agents/sanitize-for-prompt.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; + +describe("sanitizeForPromptLiteral (OC-19 hardening)", () => { + it("strips ASCII control chars (CR/LF/NUL/tab)", () => { + expect(sanitizeForPromptLiteral("/tmp/a\nb\rc\x00d\te")).toBe("/tmp/abcde"); + }); + + it("strips Unicode line/paragraph separators", () => { + expect(sanitizeForPromptLiteral(`/tmp/a\u2028b\u2029c`)).toBe("/tmp/abc"); + }); + + it("strips Unicode format chars (bidi override)", () => { + // U+202E RIGHT-TO-LEFT OVERRIDE (Cf) can spoof rendered text. + expect(sanitizeForPromptLiteral(`/tmp/a\u202Eb`)).toBe("/tmp/ab"); + }); + + it("preserves ordinary Unicode + spaces", () => { + const value = "/tmp/my project/日本語-folder.v2"; + expect(sanitizeForPromptLiteral(value)).toBe(value); + }); +}); + +describe("buildAgentSystemPrompt uses sanitized workspace/sandbox strings", () => { + it("sanitizes workspaceDir (no newlines / separators)", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/project\nINJECT\u2028MORE", + }); + expect(prompt).toContain("Your working directory is: /tmp/projectINJECTMORE"); + expect(prompt).not.toContain("Your working directory is: /tmp/project\n"); + expect(prompt).not.toContain("\u2028"); + }); + + it("sanitizes sandbox workspace/mount/url strings", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/test", + sandboxInfo: { + enabled: true, + containerWorkspaceDir: "/work\u2029space", + workspaceDir: "/host\nspace", + workspaceAccess: "read-write", + agentWorkspaceMount: "/mnt\u2028mount", + browserNoVncUrl: "http://example.test/\nui", + }, + }); + expect(prompt).toContain("Sandbox container workdir: /workspace"); + expect(prompt).toContain("Sandbox host workspace: /hostspace"); + expect(prompt).toContain("(mounted at /mntmount)"); + expect(prompt).toContain("Sandbox browser observer (noVNC): http://example.test/ui"); + expect(prompt).not.toContain("\nui"); + }); +}); diff --git a/src/agents/sanitize-for-prompt.ts b/src/agents/sanitize-for-prompt.ts new file mode 100644 index 00000000000..7692cf306da --- /dev/null +++ b/src/agents/sanitize-for-prompt.ts @@ -0,0 +1,18 @@ +/** + * Sanitize untrusted strings before embedding them into an LLM prompt. + * + * Threat model (OC-19): attacker-controlled directory names (or other runtime strings) + * that contain newline/control characters can break prompt structure and inject + * arbitrary instructions. + * + * Strategy (Option 3 hardening): + * - Strip Unicode "control" (Cc) + "format" (Cf) characters (includes CR/LF/NUL, bidi marks, zero-width chars). + * - Strip explicit line/paragraph separators (Zl/Zp): U+2028/U+2029. + * + * Notes: + * - This is intentionally lossy; it trades edge-case path fidelity for prompt integrity. + * - If you need lossless representation, escape instead of stripping. + */ +export function sanitizeForPromptLiteral(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, ""); +} diff --git a/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts index 44a8e0218a5..dad26e0fb74 100644 --- a/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("buildWorkspaceSkillsPrompt", () => { it("applies bundled allowlist without affecting workspace skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); diff --git a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts index cc85f1f5701..af9c651fc80 100644 --- a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("buildWorkspaceSkillsPrompt", () => { it("prefers workspace skills over managed skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); diff --git a/src/agents/skills.e2e-test-helpers.ts b/src/agents/skills.e2e-test-helpers.ts new file mode 100644 index 00000000000..43f6fb70398 --- /dev/null +++ b/src/agents/skills.e2e-test-helpers.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function writeSkill(params: { + dir: string; + name: string; + description: string; + metadata?: string; + body?: string; +}) { + const { dir, name, description, metadata, body } = params; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} +--- + +${body ?? `# ${name}\n`} +`, + "utf-8", + ); +} diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index 857bed643ea..e97b5ab68cd 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -12,6 +12,9 @@ import { normalizeStringList, parseFrontmatterBool, resolveOpenClawManifestBlock, + resolveOpenClawManifestInstall, + resolveOpenClawManifestOs, + resolveOpenClawManifestRequires, } from "../../shared/frontmatter.js"; export function parseFrontmatter(content: string): ParsedSkillFrontmatter { @@ -83,15 +86,9 @@ export function resolveOpenClawMetadata( if (!metadataObj) { return undefined; } - const requiresRaw = - typeof metadataObj.requires === "object" && metadataObj.requires !== null - ? (metadataObj.requires as Record) - : undefined; - const installRaw = Array.isArray(metadataObj.install) ? (metadataObj.install as unknown[]) : []; - const install = installRaw - .map((entry) => parseInstallSpec(entry)) - .filter((entry): entry is SkillInstallSpec => Boolean(entry)); - const osRaw = normalizeStringList(metadataObj.os); + const requires = resolveOpenClawManifestRequires(metadataObj); + const install = resolveOpenClawManifestInstall(metadataObj, parseInstallSpec); + const osRaw = resolveOpenClawManifestOs(metadataObj); return { always: typeof metadataObj.always === "boolean" ? metadataObj.always : undefined, emoji: typeof metadataObj.emoji === "string" ? metadataObj.emoji : undefined, @@ -99,14 +96,7 @@ export function resolveOpenClawMetadata( skillKey: typeof metadataObj.skillKey === "string" ? metadataObj.skillKey : undefined, primaryEnv: typeof metadataObj.primaryEnv === "string" ? metadataObj.primaryEnv : undefined, os: osRaw.length > 0 ? osRaw : undefined, - requires: requiresRaw - ? { - bins: normalizeStringList(requiresRaw.bins), - anyBins: normalizeStringList(requiresRaw.anyBins), - env: normalizeStringList(requiresRaw.env), - config: normalizeStringList(requiresRaw.config), - } - : undefined, + requires: requires, install: install.length > 0 ? install : undefined, }; } diff --git a/src/agents/subagent-registry.persistence.e2e.test.ts b/src/agents/subagent-registry.persistence.e2e.test.ts index 9b3f5348c42..4a6620c4e57 100644 --- a/src/agents/subagent-registry.persistence.e2e.test.ts +++ b/src/agents/subagent-registry.persistence.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { initSubagentRegistry, registerSubagentRun, @@ -29,7 +30,7 @@ vi.mock("./subagent-announce.js", () => ({ })); describe("subagent registry persistence", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -39,11 +40,7 @@ describe("subagent registry persistence", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } + envSnapshot.restore(); }); it("persists runs to disk and resumes after restart", async () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 71686c9a657..8e728e4ed7c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -5,6 +5,7 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { DEFAULT_CLI_NAME } from "../cli/cli-name.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; /** * Controls which hardcoded sections are included in the system prompt. @@ -364,13 +365,17 @@ export function buildAgentSystemPrompt(params: { const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; const sandboxContainerWorkspace = params.sandboxInfo?.containerWorkspaceDir?.trim(); + const sanitizedWorkspaceDir = sanitizeForPromptLiteral(params.workspaceDir); + const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace + ? sanitizeForPromptLiteral(sandboxContainerWorkspace) + : ""; const displayWorkspaceDir = - params.sandboxInfo?.enabled && sandboxContainerWorkspace - ? sandboxContainerWorkspace - : params.workspaceDir; + params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + ? sanitizedSandboxContainerWorkspace + : sanitizedWorkspaceDir; const workspaceGuidance = - params.sandboxInfo?.enabled && sandboxContainerWorkspace - ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${params.workspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.` + params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${sanitizedWorkspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.` : "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise."; const safetySection = [ "## Safety", @@ -490,21 +495,21 @@ export function buildAgentSystemPrompt(params: { "Some tools may be unavailable due to sandbox policy.", "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", params.sandboxInfo.containerWorkspaceDir - ? `Sandbox container workdir: ${params.sandboxInfo.containerWorkspaceDir}` + ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` : "", params.sandboxInfo.workspaceDir - ? `Sandbox host workspace: ${params.sandboxInfo.workspaceDir}` + ? `Sandbox host workspace: ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` : "", params.sandboxInfo.workspaceAccess ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ params.sandboxInfo.agentWorkspaceMount - ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` + ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` : "" }` : "", params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", params.sandboxInfo.browserNoVncUrl - ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` + ? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}` : "", params.sandboxInfo.hostBrowserAllowed === true ? "Host browser control: allowed." diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index d69bf949796..6bc57e386d1 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -219,7 +219,8 @@ JOB SCHEMA (for add action): "payload": { ... }, // Required: what to execute "delivery": { ... }, // Optional: announce summary (isolated only) "sessionTarget": "main" | "isolated", // Required - "enabled": true | false // Optional, default true + "enabled": true | false, // Optional, default true + "notify": true | false // Optional webhook opt-in; set true for user-facing reminders } SCHEDULE TYPES (schedule.kind): @@ -246,6 +247,7 @@ DELIVERY (isolated-only, top-level): CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn" +- For reminders users should be notified about, set notify=true. Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. WAKE MODES (for wake action): @@ -292,6 +294,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con "payload", "delivery", "enabled", + "notify", "description", "deleteAfterRun", "agentId", diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index d5daf9d5de7..2b58753777c 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -192,9 +192,7 @@ describe("image tool implicit imageModel config", () => { }); const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true }); expect(tool).not.toBeNull(); - expect(tool?.description).toContain( - "Only use this tool when the image was NOT already provided", - ); + expect(tool?.description).toContain("Only use this tool when images were NOT already provided"); }); it("allows workspace images outside default local media roots", async () => { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 896b7447138..3d63623b778 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -26,6 +26,7 @@ import { const DEFAULT_PROMPT = "Describe the image."; const ANTHROPIC_IMAGE_PRIMARY = "anthropic/claude-opus-4-6"; const ANTHROPIC_IMAGE_FALLBACK = "anthropic/claude-opus-4-5"; +const DEFAULT_MAX_IMAGES = 20; export const __testing = { decodeDataUrl, @@ -182,15 +183,21 @@ function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undef return undefined; } -function buildImageContext(prompt: string, base64: string, mimeType: string): Context { +function buildImageContext( + prompt: string, + images: Array<{ base64: string; mimeType: string }>, +): Context { + const content: Array< + { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } + > = [{ type: "text", text: prompt }]; + for (const img of images) { + content.push({ type: "image", data: img.base64, mimeType: img.mimeType }); + } return { messages: [ { role: "user", - content: [ - { type: "text", text: prompt }, - { type: "image", data: base64, mimeType }, - ], + content, timestamp: Date.now(), }, ], @@ -242,8 +249,7 @@ async function runImagePrompt(params: { imageModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; - base64: string; - mimeType: string; + images: Array<{ base64: string; mimeType: string }>; }): Promise<{ text: string; provider: string; @@ -285,9 +291,11 @@ async function runImagePrompt(params: { }); const apiKey = requireApiKey(apiKeyInfo, model.provider); authStorage.setRuntimeApiKey(model.provider, apiKey); - const imageDataUrl = `data:${params.mimeType};base64,${params.base64}`; + // MiniMax VLM only supports a single image; use the first one. if (model.provider === "minimax") { + const first = params.images[0]; + const imageDataUrl = `data:${first.mimeType};base64,${first.base64}`; const text = await minimaxUnderstandImage({ apiKey, prompt: params.prompt, @@ -297,7 +305,7 @@ async function runImagePrompt(params: { return { text, provider: model.provider, model: model.id }; } - const context = buildImageContext(params.prompt, params.base64, params.mimeType); + const context = buildImageContext(params.prompt, params.images); const message = await complete(model, context, { apiKey, maxTokens: resolveImageToolMaxTokens(model.maxTokens), @@ -350,8 +358,8 @@ export function createImageTool(options?: { // If model has native vision, images in the prompt are auto-injected // so this tool is only needed when image wasn't provided in the prompt const description = options?.modelHasVision - ? "Analyze an image with a vision model. Only use this tool when the image was NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." - : "Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL."; + ? "Analyze one or more images with a vision model. Pass a single image path/URL or an array of up to 20. Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." + : "Analyze one or more images with the configured image model (agents.defaults.imageModel). Pass a single image path/URL or an array of up to 20. Provide a prompt describing what to analyze."; const localRoots = (() => { const roots = getDefaultLocalRoots(); @@ -368,44 +376,47 @@ export function createImageTool(options?: { description, parameters: Type.Object({ prompt: Type.Optional(Type.String()), - image: Type.String(), + image: Type.Union([Type.String(), Type.Array(Type.String())]), model: Type.Optional(Type.String()), maxBytesMb: Type.Optional(Type.Number()), + maxImages: Type.Optional(Type.Number()), }), execute: async (_toolCallId, args) => { const record = args && typeof args === "object" ? (args as Record) : {}; - const imageRawInput = typeof record.image === "string" ? record.image.trim() : ""; - const imageRaw = imageRawInput.startsWith("@") - ? imageRawInput.slice(1).trim() - : imageRawInput; - if (!imageRaw) { + + // MARK: - Normalize image input (string | string[]) + const rawImageInput = record.image; + const imageInputs: string[] = (() => { + if (typeof rawImageInput === "string") { + return [rawImageInput]; + } + if (Array.isArray(rawImageInput)) { + return rawImageInput.filter((v): v is string => typeof v === "string"); + } + return []; + })(); + if (imageInputs.length === 0) { throw new Error("image required"); } - // The tool accepts file paths, file/data URLs, or http(s) URLs. In some - // agent/model contexts, images can be referenced as pseudo-URIs like - // `image:0` (e.g. "first image in the prompt"). We don't have access to a - // shared image registry here, so fail gracefully instead of attempting to - // `fs.readFile("image:0")` and producing a noisy ENOENT. - const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); - const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); - const isFileUrl = /^file:/i.test(imageRaw); - const isHttpUrl = /^https?:\/\//i.test(imageRaw); - const isDataUrl = /^data:/i.test(imageRaw); - if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + // MARK: - Enforce max images cap + const maxImagesRaw = typeof record.maxImages === "number" ? record.maxImages : undefined; + const maxImages = + typeof maxImagesRaw === "number" && Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 + ? Math.floor(maxImagesRaw) + : DEFAULT_MAX_IMAGES; + if (imageInputs.length > maxImages) { return { content: [ { type: "text", - text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + text: `Too many images: ${imageInputs.length} provided, maximum is ${maxImages}. Please reduce the number of images.`, }, ], - details: { - error: "unsupported_image_reference", - image: imageRawInput, - }, + details: { error: "too_many_images", count: imageInputs.length, max: maxImages }, }; } + const promptRaw = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() @@ -419,73 +430,136 @@ export function createImageTool(options?: { options?.sandbox && options?.sandbox.root.trim() ? { root: options.sandbox.root.trim(), bridge: options.sandbox.bridge } : null; - const isUrl = isHttpUrl; - if (sandboxConfig && isUrl) { - throw new Error("Sandboxed image tool does not allow remote URLs."); - } - const resolvedImage = (() => { - if (sandboxConfig) { + // MARK: - Load and resolve each image + const loadedImages: Array<{ + base64: string; + mimeType: string; + resolvedImage: string; + rewrittenFrom?: string; + }> = []; + + for (const imageRawInput of imageInputs) { + const trimmed = imageRawInput.trim(); + const imageRaw = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!imageRaw) { + throw new Error("image required (empty string in array)"); + } + + // The tool accepts file paths, file/data URLs, or http(s) URLs. In some + // agent/model contexts, images can be referenced as pseudo-URIs like + // `image:0` (e.g. "first image in the prompt"). We don't have access to a + // shared image registry here, so fail gracefully instead of attempting to + // `fs.readFile("image:0")` and producing a noisy ENOENT. + const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); + const isFileUrl = /^file:/i.test(imageRaw); + const isHttpUrl = /^https?:\/\//i.test(imageRaw); + const isDataUrl = /^data:/i.test(imageRaw); + if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + return { + content: [ + { + type: "text", + text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + }, + ], + details: { + error: "unsupported_image_reference", + image: imageRawInput, + }, + }; + } + + if (sandboxConfig && isHttpUrl) { + throw new Error("Sandboxed image tool does not allow remote URLs."); + } + + const resolvedImage = (() => { + if (sandboxConfig) { + return imageRaw; + } + if (imageRaw.startsWith("~")) { + return resolveUserPath(imageRaw); + } return imageRaw; - } - if (imageRaw.startsWith("~")) { - return resolveUserPath(imageRaw); - } - return imageRaw; - })(); - const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl - ? { resolved: "" } - : sandboxConfig - ? await resolveSandboxedImagePath({ - sandbox: sandboxConfig, - imagePath: resolvedImage, - }) - : { - resolved: resolvedImage.startsWith("file://") - ? resolvedImage.slice("file://".length) - : resolvedImage, - }; - const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; + })(); + const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl + ? { resolved: "" } + : sandboxConfig + ? await resolveSandboxedImagePath({ + sandbox: sandboxConfig, + imagePath: resolvedImage, + }) + : { + resolved: resolvedImage.startsWith("file://") + ? resolvedImage.slice("file://".length) + : resolvedImage, + }; + const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; - const media = isDataUrl - ? decodeDataUrl(resolvedImage) - : sandboxConfig - ? await loadWebMedia(resolvedPath ?? resolvedImage, { - maxBytes, - sandboxValidated: true, - readFile: (filePath) => - sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), - }) - : await loadWebMedia(resolvedPath ?? resolvedImage, { - maxBytes, - localRoots, - }); - if (media.kind !== "image") { - throw new Error(`Unsupported media type: ${media.kind}`); + const media = isDataUrl + ? decodeDataUrl(resolvedImage) + : sandboxConfig + ? await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + sandboxValidated: true, + readFile: (filePath) => + sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), + }) + : await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + localRoots, + }); + if (media.kind !== "image") { + throw new Error(`Unsupported media type: ${media.kind}`); + } + + const mimeType = + ("contentType" in media && media.contentType) || + ("mimeType" in media && media.mimeType) || + "image/png"; + const base64 = media.buffer.toString("base64"); + loadedImages.push({ + base64, + mimeType, + resolvedImage, + ...(resolvedPathInfo.rewrittenFrom + ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } + : {}), + }); } - const mimeType = - ("contentType" in media && media.contentType) || - ("mimeType" in media && media.mimeType) || - "image/png"; - const base64 = media.buffer.toString("base64"); + // MARK: - Run image prompt with all loaded images const result = await runImagePrompt({ cfg: options?.config, agentDir, imageModelConfig, modelOverride, prompt: promptRaw, - base64, - mimeType, + images: loadedImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })), }); + + const imageDetails = + loadedImages.length === 1 + ? { + image: loadedImages[0].resolvedImage, + ...(loadedImages[0].rewrittenFrom + ? { rewrittenFrom: loadedImages[0].rewrittenFrom } + : {}), + } + : { + images: loadedImages.map((img) => ({ + image: img.resolvedImage, + ...(img.rewrittenFrom ? { rewrittenFrom: img.rewrittenFrom } : {}), + })), + }; + return { content: [{ type: "text", text: result.text }], details: { model: `${result.provider}/${result.model}`, - image: resolvedImage, - ...(resolvedPathInfo.rewrittenFrom - ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } - : {}), + ...imageDetails, attempts: result.attempts, }, }; diff --git a/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts b/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts deleted file mode 100644 index 85535cedfe5..00000000000 --- a/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("../../memory/index.js", () => { - return { - getMemorySearchManager: async () => { - return { - manager: { - search: async () => { - throw new Error("openai embeddings failed: 429 insufficient_quota"); - }, - readFile: async () => { - throw new Error("path required"); - }, - status: () => ({ - files: 0, - chunks: 0, - dirty: true, - workspaceDir: "/tmp", - dbPath: "/tmp/index.sqlite", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - }), - }, - }; - }, - }; -}); - -import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; - -describe("memory tools", () => { - it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { - const cfg = { agents: { list: [{ id: "main", default: true }] } }; - const tool = createMemorySearchTool({ config: cfg }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("tool missing"); - } - - const result = await tool.execute("call_1", { query: "hello" }); - expect(result.details).toEqual({ - results: [], - disabled: true, - error: "openai embeddings failed: 429 insufficient_quota", - }); - }); - - it("does not throw when memory_get fails", async () => { - const cfg = { agents: { list: [{ id: "main", default: true }] } }; - const tool = createMemoryGetTool({ config: cfg }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("tool missing"); - } - - const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); - expect(result.details).toEqual({ - path: "memory/NOPE.md", - text: "", - disabled: true, - error: "path required", - }); - }); -}); diff --git a/src/agents/tools/memory-tool.citations.e2e.test.ts b/src/agents/tools/memory-tool.e2e.test.ts similarity index 69% rename from src/agents/tools/memory-tool.citations.e2e.test.ts rename to src/agents/tools/memory-tool.e2e.test.ts index 8e4d5c1b7fd..38e2caab24d 100644 --- a/src/agents/tools/memory-tool.citations.e2e.test.ts +++ b/src/agents/tools/memory-tool.e2e.test.ts @@ -1,18 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let backend: "builtin" | "qmd" = "builtin"; +let searchImpl: () => Promise = async () => [ + { + path: "MEMORY.md", + startLine: 5, + endLine: 7, + score: 0.9, + snippet: "@@ -5,3 @@\nAssistant: noted", + source: "memory" as const, + }, +]; +let readFileImpl: () => Promise = async () => ""; + const stubManager = { - search: vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 5, - endLine: 7, - score: 0.9, - snippet: "@@ -5,3 @@\nAssistant: noted", - source: "memory" as const, - }, - ]), - readFile: vi.fn(), + search: vi.fn(async () => await searchImpl()), + readFile: vi.fn(async () => await readFileImpl()), status: () => ({ backend, files: 1, @@ -37,9 +40,21 @@ vi.mock("../../memory/index.js", () => { }; }); -import { createMemorySearchTool } from "./memory-tool.js"; +import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; beforeEach(() => { + backend = "builtin"; + searchImpl = async () => [ + { + path: "MEMORY.md", + startLine: 5, + endLine: 7, + score: 0.9, + snippet: "@@ -5,3 @@\nAssistant: noted", + source: "memory" as const, + }, + ]; + readFileImpl = async () => ""; vi.clearAllMocks(); }); @@ -121,3 +136,46 @@ describe("memory search citations", () => { expect(details.results[0]?.snippet).not.toMatch(/Source:/); }); }); + +describe("memory tools", () => { + it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { + searchImpl = async () => { + throw new Error("openai embeddings failed: 429 insufficient_quota"); + }; + + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + const tool = createMemorySearchTool({ config: cfg }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("tool missing"); + } + + const result = await tool.execute("call_1", { query: "hello" }); + expect(result.details).toEqual({ + results: [], + disabled: true, + error: "openai embeddings failed: 429 insufficient_quota", + }); + }); + + it("does not throw when memory_get fails", async () => { + readFileImpl = async () => { + throw new Error("path required"); + }; + + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + const tool = createMemoryGetTool({ config: cfg }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("tool missing"); + } + + const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); + expect(result.details).toEqual({ + path: "memory/NOPE.md", + text: "", + disabled: true, + error: "path required", + }); + }); +}); diff --git a/src/agents/tools/sessions-announce-target.e2e.test.ts b/src/agents/tools/sessions-announce-target.e2e.test.ts deleted file mode 100644 index fe28be7dff9..00000000000 --- a/src/agents/tools/sessions-announce-target.e2e.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js"); - -const installRegistry = async () => { - const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "discord", - source: "test", - plugin: { - id: "discord", - meta: { - id: "discord", - label: "Discord", - selectionLabel: "Discord", - docsPath: "/channels/discord", - blurb: "Discord test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "whatsapp", - source: "test", - plugin: { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp test stub.", - preferSessionLookupForAnnounceTarget: true, - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - ]), - ); -}; - -describe("resolveAnnounceTarget", () => { - beforeEach(async () => { - callGatewayMock.mockReset(); - await installRegistry(); - }); - - it("derives non-WhatsApp announce targets from the session key", async () => { - const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); - const target = await resolveAnnounceTarget({ - sessionKey: "agent:main:discord:group:dev", - displayKey: "agent:main:discord:group:dev", - }); - expect(target).toEqual({ channel: "discord", to: "channel:dev" }); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("hydrates WhatsApp accountId from sessions.list when available", async () => { - const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); - callGatewayMock.mockResolvedValueOnce({ - sessions: [ - { - key: "agent:main:whatsapp:group:123@g.us", - deliveryContext: { - channel: "whatsapp", - to: "123@g.us", - accountId: "work", - }, - }, - ], - }); - - const target = await resolveAnnounceTarget({ - sessionKey: "agent:main:whatsapp:group:123@g.us", - displayKey: "agent:main:whatsapp:group:123@g.us", - }); - expect(target).toEqual({ - channel: "whatsapp", - to: "123@g.us", - accountId: "work", - }); - expect(callGatewayMock).toHaveBeenCalledTimes(1); - const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; - expect(first).toBeDefined(); - expect(first?.method).toBe("sessions.list"); - }); -}); diff --git a/src/agents/tools/sessions-helpers.e2e.test.ts b/src/agents/tools/sessions-helpers.e2e.test.ts deleted file mode 100644 index 887cc1f4670..00000000000 --- a/src/agents/tools/sessions-helpers.e2e.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; - -describe("sanitizeTextContent", () => { - it("strips minimax tool call XML and downgraded markers", () => { - const input = - 'Hello payload ' + - "[Tool Call: foo (ID: 1)] world"; - const result = sanitizeTextContent(input).trim(); - expect(result).toBe("Hello world"); - expect(result).not.toContain("invoke"); - expect(result).not.toContain("Tool Call"); - }); - - it("strips thinking tags", () => { - const input = "Before secret after"; - const result = sanitizeTextContent(input).trim(); - expect(result).toBe("Before after"); - }); -}); - -describe("extractAssistantText", () => { - it("sanitizes blocks without injecting newlines", () => { - const message = { - role: "assistant", - content: [ - { type: "text", text: "Hi " }, - { type: "text", text: "secretthere" }, - ], - }; - expect(extractAssistantText(message)).toBe("Hi there"); - }); - - it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { - const message = { - role: "assistant", - stopReason: "error", - errorMessage: "500 Internal Server Error", - content: [{ type: "text", text: "500 Internal Server Error" }], - }; - expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); - }); - - it("keeps normal status text that mentions billing", () => { - const message = { - role: "assistant", - content: [ - { - type: "text", - text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", - }, - ], - }; - expect(extractAssistantText(message)).toBe( - "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", - ); - }); -}); diff --git a/src/agents/tools/sessions-list-tool.gating.e2e.test.ts b/src/agents/tools/sessions-list-tool.gating.e2e.test.ts deleted file mode 100644 index 636c2c5a1c3..00000000000 --- a/src/agents/tools/sessions-list-tool.gating.e2e.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, - }; -}); - -import { createSessionsListTool } from "./sessions-list-tool.js"; - -describe("sessions_list gating", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ - path: "/tmp/sessions.json", - sessions: [ - { key: "agent:main:main", kind: "direct" }, - { key: "agent:other:main", kind: "direct" }, - ], - }); - }); - - it("filters out other agents when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - expect(result.details).toMatchObject({ - count: 1, - sessions: [{ key: "agent:main:main" }], - }); - }); -}); diff --git a/src/agents/tools/sessions-send-tool.gating.e2e.test.ts b/src/agents/tools/sessions-send-tool.gating.e2e.test.ts deleted file mode 100644 index 76a242c9898..00000000000 --- a/src/agents/tools/sessions-send-tool.gating.e2e.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, - }; -}); - -import { createSessionsSendTool } from "./sessions-send-tool.js"; - -describe("sessions_send gating", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - }); - - it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsSendTool({ - agentSessionKey: "agent:main:main", - agentChannel: "whatsapp", - }); - - const result = await tool.execute("call1", { - sessionKey: "agent:other:main", - message: "hi", - timeoutSeconds: 0, - }); - - expect(callGatewayMock).not.toHaveBeenCalled(); - expect(result.details).toMatchObject({ status: "forbidden" }); - }); -}); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 11486c025e3..867aa85c9d9 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -28,6 +28,8 @@ const SessionsSpawnToolSchema = Type.Object({ model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + // Back-compat: older callers used timeoutSeconds for this tool. + timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -97,9 +99,15 @@ export function createSessionsSpawnTool(opts?: { }); // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived // by default and should not inherit the main agent 600s timeout. + const timeoutSecondsCandidate = + typeof params.runTimeoutSeconds === "number" + ? params.runTimeoutSeconds + : typeof params.timeoutSeconds === "number" + ? params.timeoutSeconds + : undefined; const runTimeoutSeconds = - typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) - ? Math.max(0, Math.floor(params.runTimeoutSeconds)) + typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate) + ? Math.max(0, Math.floor(timeoutSecondsCandidate)) : 0; let modelWarning: string | undefined; let modelApplied = false; diff --git a/src/agents/tools/sessions.e2e.test.ts b/src/agents/tools/sessions.e2e.test.ts new file mode 100644 index 00000000000..f94be78d57f --- /dev/null +++ b/src/agents/tools/sessions.e2e.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsListTool } from "./sessions-list-tool.js"; +import { createSessionsSendTool } from "./sessions-send-tool.js"; + +const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js"); + +const installRegistry = async () => { + const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "whatsapp", + source: "test", + plugin: { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp test stub.", + preferSessionLookupForAnnounceTarget: true, + }, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); +}; + +describe("sanitizeTextContent", () => { + it("strips minimax tool call XML and downgraded markers", () => { + const input = + 'Hello payload ' + + "[Tool Call: foo (ID: 1)] world"; + const result = sanitizeTextContent(input).trim(); + expect(result).toBe("Hello world"); + expect(result).not.toContain("invoke"); + expect(result).not.toContain("Tool Call"); + }); + + it("strips thinking tags", () => { + const input = "Before secret after"; + const result = sanitizeTextContent(input).trim(); + expect(result).toBe("Before after"); + }); +}); + +describe("extractAssistantText", () => { + it("sanitizes blocks without injecting newlines", () => { + const message = { + role: "assistant", + content: [ + { type: "text", text: "Hi " }, + { type: "text", text: "secretthere" }, + ], + }; + expect(extractAssistantText(message)).toBe("Hi there"); + }); + + it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { + const message = { + role: "assistant", + stopReason: "error", + errorMessage: "500 Internal Server Error", + content: [{ type: "text", text: "500 Internal Server Error" }], + }; + expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); + }); + + it("keeps normal status text that mentions billing", () => { + const message = { + role: "assistant", + content: [ + { + type: "text", + text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + }, + ], + }; + expect(extractAssistantText(message)).toBe( + "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + ); + }); +}); + +describe("resolveAnnounceTarget", () => { + beforeEach(async () => { + callGatewayMock.mockReset(); + await installRegistry(); + }); + + it("derives non-WhatsApp announce targets from the session key", async () => { + const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:discord:group:dev", + displayKey: "agent:main:discord:group:dev", + }); + expect(target).toEqual({ channel: "discord", to: "channel:dev" }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("hydrates WhatsApp accountId from sessions.list when available", async () => { + const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); + callGatewayMock.mockResolvedValueOnce({ + sessions: [ + { + key: "agent:main:whatsapp:group:123@g.us", + deliveryContext: { + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }, + }, + ], + }); + + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:whatsapp:group:123@g.us", + displayKey: "agent:main:whatsapp:group:123@g.us", + }); + expect(target).toEqual({ + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; + expect(first).toBeDefined(); + expect(first?.method).toBe("sessions.list"); + }); +}); + +describe("sessions_list gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ + path: "/tmp/sessions.json", + sessions: [ + { key: "agent:main:main", kind: "direct" }, + { key: "agent:other:main", kind: "direct" }, + ], + }); + }); + + it("filters out other agents when tools.agentToAgent.enabled is false", async () => { + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + expect(result.details).toMatchObject({ + count: 1, + sessions: [{ key: "agent:main:main" }], + }); + }); +}); + +describe("sessions_send gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { + const tool = createSessionsSendTool({ + agentSessionKey: "agent:main:main", + agentChannel: "whatsapp", + }); + + const result = await tool.execute("call1", { + sessionKey: "agent:other:main", + message: "hi", + timeoutSeconds: 0, + }); + + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ status: "forbidden" }); + }); +}); diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 09716e2cd46..a9ef9d5ba45 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -1,5 +1,8 @@ export type ExtractMode = "markdown" | "text"; +const READABILITY_MAX_HTML_CHARS = 1_000_000; +const READABILITY_MAX_ESTIMATED_NESTING_DEPTH = 3_000; + let readabilityDepsPromise: | Promise<{ Readability: typeof import("@mozilla/readability").Readability; @@ -107,6 +110,100 @@ export function truncateText( return { text: value.slice(0, maxChars), truncated: true }; } +function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boolean { + // Cheap heuristic to skip Readability+DOM parsing on pathological HTML (deep nesting => stack/memory blowups). + // Not an HTML parser; tuned to catch attacker-controlled "
..." cases. + const voidTags = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ]); + + let depth = 0; + const len = html.length; + for (let i = 0; i < len; i++) { + if (html.charCodeAt(i) !== 60) { + continue; // '<' + } + const next = html.charCodeAt(i + 1); + if (next === 33 || next === 63) { + continue; // or + } + + let j = i + 1; + let closing = false; + if (html.charCodeAt(j) === 47) { + closing = true; + j += 1; + } + + while (j < len && html.charCodeAt(j) <= 32) { + j += 1; + } + + const nameStart = j; + while (j < len) { + const c = html.charCodeAt(j); + const isNameChar = + (c >= 65 && c <= 90) || // A-Z + (c >= 97 && c <= 122) || // a-z + (c >= 48 && c <= 57) || // 0-9 + c === 58 || // : + c === 45; // - + if (!isNameChar) { + break; + } + j += 1; + } + + const tagName = html.slice(nameStart, j).toLowerCase(); + if (!tagName) { + continue; + } + + if (closing) { + depth = Math.max(0, depth - 1); + continue; + } + + if (voidTags.has(tagName)) { + continue; + } + + // Best-effort self-closing detection: scan a short window for "/>". + let selfClosing = false; + for (let k = j; k < len && k < j + 200; k++) { + const c = html.charCodeAt(k); + if (c === 62) { + if (html.charCodeAt(k - 1) === 47) { + selfClosing = true; + } + break; + } + } + if (selfClosing) { + continue; + } + + depth += 1; + if (depth > maxDepth) { + return true; + } + } + return false; +} + export async function extractReadableContent(params: { html: string; url: string; @@ -120,6 +217,12 @@ export async function extractReadableContent(params: { } return rendered; }; + if ( + params.html.length > READABILITY_MAX_HTML_CHARS || + exceedsEstimatedHtmlNestingDepth(params.html, READABILITY_MAX_ESTIMATED_NESTING_DEPTH) + ) { + return fallback(); + } try { const { Readability, parseHTML } = await loadReadabilityDeps(); const { document } = parseHTML(params.html); diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts new file mode 100644 index 00000000000..2755fd0b1c7 --- /dev/null +++ b/src/agents/tools/web-fetch.response-limit.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; +import { createWebFetchTool } from "./web-tools.js"; + +// Avoid dynamic-importing heavy readability deps in this unit test suite. +vi.mock("./web-fetch-utils.js", async () => { + const actual = + await vi.importActual("./web-fetch-utils.js"); + return { + ...actual, + extractReadableContent: vi.fn().mockResolvedValue({ + title: "HTML Page", + text: "HTML Page\n\nContent here.", + }), + }; +}); + +const lookupMock = vi.fn(); +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const baseToolConfig = { + config: { + tools: { + web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxResponseBytes: 1024 } }, + }, + }, +} as const; + +describe("web_fetch response size limits", () => { + const priorFetch = global.fetch; + + beforeEach(() => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); + }); + + afterEach(() => { + // @ts-expect-error restore + global.fetch = priorFetch; + lookupMock.mockReset(); + vi.restoreAllMocks(); + }); + + it("caps response bytes and does not hang on endless streams", async () => { + const chunk = new TextEncoder().encode("
hi
"); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + }); + const response = new Response(stream, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + + const fetchSpy = vi.fn().mockResolvedValue(response); + // @ts-expect-error mock fetch + global.fetch = fetchSpy; + + const tool = createWebFetchTool(baseToolConfig); + const result = await tool?.execute?.("call", { url: "https://example.com/stream" }); + + expect(result?.details?.warning).toContain("Response body truncated"); + }); +}); diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index a703aa54f3a..fdb5ade5172 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -33,8 +33,12 @@ export { extractReadableContent } from "./web-fetch-utils.js"; const EXTRACT_MODES = ["markdown", "text"] as const; const DEFAULT_FETCH_MAX_CHARS = 50_000; +const DEFAULT_FETCH_MAX_RESPONSE_BYTES = 2_000_000; +const FETCH_MAX_RESPONSE_BYTES_MIN = 32_000; +const FETCH_MAX_RESPONSE_BYTES_MAX = 10_000_000; const DEFAULT_FETCH_MAX_REDIRECTS = 3; const DEFAULT_ERROR_MAX_CHARS = 4_000; +const DEFAULT_ERROR_MAX_BYTES = 64_000; const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000; const DEFAULT_FETCH_USER_AGENT = @@ -108,6 +112,18 @@ function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number { return Math.max(100, Math.floor(raw)); } +function resolveFetchMaxResponseBytes(fetch?: WebFetchConfig): number { + const raw = + fetch && "maxResponseBytes" in fetch && typeof fetch.maxResponseBytes === "number" + ? fetch.maxResponseBytes + : undefined; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { + return DEFAULT_FETCH_MAX_RESPONSE_BYTES; + } + const value = Math.floor(raw); + return Math.min(FETCH_MAX_RESPONSE_BYTES_MAX, Math.max(FETCH_MAX_RESPONSE_BYTES_MIN, value)); +} + function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig { if (!fetch || typeof fetch !== "object") { return undefined; @@ -409,15 +425,7 @@ export async function fetchFirecrawlContent(params: { }; } -async function runWebFetch(params: { - url: string; - extractMode: ExtractMode; - maxChars: number; - maxRedirects: number; - timeoutSeconds: number; - cacheTtlMs: number; - userAgent: string; - readabilityEnabled: boolean; +type FirecrawlRuntimeParams = { firecrawlEnabled: boolean; firecrawlApiKey?: string; firecrawlBaseUrl: string; @@ -426,7 +434,72 @@ async function runWebFetch(params: { firecrawlProxy: "auto" | "basic" | "stealth"; firecrawlStoreInCache: boolean; firecrawlTimeoutSeconds: number; -}): Promise> { +}; + +type WebFetchRuntimeParams = FirecrawlRuntimeParams & { + url: string; + extractMode: ExtractMode; + maxChars: number; + maxResponseBytes: number; + maxRedirects: number; + timeoutSeconds: number; + cacheTtlMs: number; + userAgent: string; + readabilityEnabled: boolean; +}; + +function toFirecrawlContentParams( + params: FirecrawlRuntimeParams & { url: string; extractMode: ExtractMode }, +): Parameters[0] | null { + if (!params.firecrawlEnabled || !params.firecrawlApiKey) { + return null; + } + return { + url: params.url, + extractMode: params.extractMode, + apiKey: params.firecrawlApiKey, + baseUrl: params.firecrawlBaseUrl, + onlyMainContent: params.firecrawlOnlyMainContent, + maxAgeMs: params.firecrawlMaxAgeMs, + proxy: params.firecrawlProxy, + storeInCache: params.firecrawlStoreInCache, + timeoutSeconds: params.firecrawlTimeoutSeconds, + }; +} + +async function maybeFetchFirecrawlWebFetchPayload( + params: WebFetchRuntimeParams & { + urlToFetch: string; + finalUrlFallback: string; + statusFallback: number; + cacheKey: string; + tookMs: number; + }, +): Promise | null> { + const firecrawlParams = toFirecrawlContentParams({ + ...params, + url: params.urlToFetch, + extractMode: params.extractMode, + }); + if (!firecrawlParams) { + return null; + } + + const firecrawl = await fetchFirecrawlContent(firecrawlParams); + const payload = buildFirecrawlWebFetchPayload({ + firecrawl, + rawUrl: params.url, + finalUrlFallback: params.finalUrlFallback, + statusFallback: params.statusFallback, + extractMode: params.extractMode, + maxChars: params.maxChars, + tookMs: params.tookMs, + }); + writeCache(FETCH_CACHE, params.cacheKey, payload, params.cacheTtlMs); + return payload; +} + +async function runWebFetch(params: WebFetchRuntimeParams): Promise> { const cacheKey = normalizeCacheKey( `fetch:${params.url}:${params.extractMode}:${params.maxChars}`, ); @@ -477,28 +550,15 @@ async function runWebFetch(params: { if (error instanceof SsrFBlockedError) { throw error; } - if (params.firecrawlEnabled && params.firecrawlApiKey) { - const firecrawl = await fetchFirecrawlContent({ - url: finalUrl, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); - const payload = buildFirecrawlWebFetchPayload({ - firecrawl, - rawUrl: params.url, - finalUrlFallback: finalUrl, - statusFallback: 200, - extractMode: params.extractMode, - maxChars: params.maxChars, - tookMs: Date.now() - start, - }); - writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); + const payload = await maybeFetchFirecrawlWebFetchPayload({ + ...params, + urlToFetch: finalUrl, + finalUrlFallback: finalUrl, + statusFallback: 200, + cacheKey, + tookMs: Date.now() - start, + }); + if (payload) { return payload; } throw error; @@ -506,31 +566,19 @@ async function runWebFetch(params: { try { if (!res.ok) { - if (params.firecrawlEnabled && params.firecrawlApiKey) { - const firecrawl = await fetchFirecrawlContent({ - url: params.url, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); - const payload = buildFirecrawlWebFetchPayload({ - firecrawl, - rawUrl: params.url, - finalUrlFallback: finalUrl, - statusFallback: res.status, - extractMode: params.extractMode, - maxChars: params.maxChars, - tookMs: Date.now() - start, - }); - writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); + const payload = await maybeFetchFirecrawlWebFetchPayload({ + ...params, + urlToFetch: params.url, + finalUrlFallback: finalUrl, + statusFallback: res.status, + cacheKey, + tookMs: Date.now() - start, + }); + if (payload) { return payload; } - const rawDetail = await readResponseText(res); + const rawDetailResult = await readResponseText(res, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); + const rawDetail = rawDetailResult.text; const detail = formatWebFetchErrorDetail({ detail: rawDetail, contentType: res.headers.get("content-type"), @@ -542,7 +590,11 @@ async function runWebFetch(params: { const contentType = res.headers.get("content-type") ?? "application/octet-stream"; const normalizedContentType = normalizeContentType(contentType) ?? "application/octet-stream"; - const body = await readResponseText(res); + const bodyResult = await readResponseText(res, { maxBytes: params.maxResponseBytes }); + const body = bodyResult.text; + const responseTruncatedWarning = bodyResult.truncated + ? `Response body truncated after ${params.maxResponseBytes} bytes.` + : undefined; let title: string | undefined; let extractor = "raw"; @@ -593,6 +645,7 @@ async function runWebFetch(params: { const wrapped = wrapWebFetchContent(text, params.maxChars); const wrappedTitle = title ? wrapWebFetchField(title) : undefined; + const wrappedWarning = wrapWebFetchField(responseTruncatedWarning); const payload = { url: params.url, // Keep raw for tool chaining finalUrl, // Keep raw @@ -613,6 +666,7 @@ async function runWebFetch(params: { fetchedAt: new Date().toISOString(), tookMs: Date.now() - start, text: wrapped.text, + warning: wrappedWarning, }; writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); return payload; @@ -623,33 +677,15 @@ async function runWebFetch(params: { } } -async function tryFirecrawlFallback(params: { - url: string; - extractMode: ExtractMode; - firecrawlEnabled: boolean; - firecrawlApiKey?: string; - firecrawlBaseUrl: string; - firecrawlOnlyMainContent: boolean; - firecrawlMaxAgeMs: number; - firecrawlProxy: "auto" | "basic" | "stealth"; - firecrawlStoreInCache: boolean; - firecrawlTimeoutSeconds: number; -}): Promise<{ text: string; title?: string } | null> { - if (!params.firecrawlEnabled || !params.firecrawlApiKey) { +async function tryFirecrawlFallback( + params: FirecrawlRuntimeParams & { url: string; extractMode: ExtractMode }, +): Promise<{ text: string; title?: string } | null> { + const firecrawlParams = toFirecrawlContentParams(params); + if (!firecrawlParams) { return null; } try { - const firecrawl = await fetchFirecrawlContent({ - url: params.url, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); + const firecrawl = await fetchFirecrawlContent(firecrawlParams); return { text: firecrawl.text, title: firecrawl.title }; } catch { return null; @@ -695,6 +731,7 @@ export function createWebFetchTool(options?: { const userAgent = (fetch && "userAgent" in fetch && typeof fetch.userAgent === "string" && fetch.userAgent) || DEFAULT_FETCH_USER_AGENT; + const maxResponseBytes = resolveFetchMaxResponseBytes(fetch); return { label: "Web Fetch", name: "web_fetch", @@ -715,6 +752,7 @@ export function createWebFetchTool(options?: { DEFAULT_FETCH_MAX_CHARS, maxCharsCap, ), + maxResponseBytes, maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS), timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), diff --git a/src/agents/tools/web-search.e2e.test.ts b/src/agents/tools/web-search.e2e.test.ts index e8896f908b4..975f92be877 100644 --- a/src/agents/tools/web-search.e2e.test.ts +++ b/src/agents/tools/web-search.e2e.test.ts @@ -1,30 +1,7 @@ import { describe, expect, it } from "vitest"; +import { withEnv } from "../../test-utils/env.js"; import { __testing } from "./web-search.js"; -function withEnv(env: Record, fn: () => T): T { - const prev: Record = {}; - for (const [key, value] of Object.entries(env)) { - prev[key] = process.env[key]; - if (value === undefined) { - // Make tests hermetic even on machines with real keys set. - delete process.env[key]; - } else { - process.env[key] = value; - } - } - try { - return fn(); - } finally { - for (const [key, value] of Object.entries(prev)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - } -} - const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index f2e059f439c..be174b951d3 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -486,7 +486,8 @@ async function runPerplexitySearch(params: { }); if (!res.ok) { - const detail = await readResponseText(res); + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`); } @@ -535,7 +536,8 @@ async function runGrokSearch(params: { }); if (!res.ok) { - const detail = await readResponseText(res); + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`); } @@ -665,7 +667,8 @@ async function runWebSearch(params: { }); if (!res.ok) { - const detail = await readResponseText(res); + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); } diff --git a/src/agents/tools/web-shared.ts b/src/agents/tools/web-shared.ts index 2a7353796e2..da0fbb38beb 100644 --- a/src/agents/tools/web-shared.ts +++ b/src/agents/tools/web-shared.ts @@ -86,10 +86,85 @@ export function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): return controller.signal; } -export async function readResponseText(res: Response): Promise { +export type ReadResponseTextResult = { + text: string; + truncated: boolean; + bytesRead: number; +}; + +export async function readResponseText( + res: Response, + options?: { maxBytes?: number }, +): Promise { + const maxBytesRaw = options?.maxBytes; + const maxBytes = + typeof maxBytesRaw === "number" && Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 + ? Math.floor(maxBytesRaw) + : undefined; + + const body = (res as unknown as { body?: unknown }).body; + if ( + maxBytes && + body && + typeof body === "object" && + "getReader" in body && + typeof (body as { getReader: () => unknown }).getReader === "function" + ) { + const reader = (body as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let bytesRead = 0; + let truncated = false; + const parts: string[] = []; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (!value || value.byteLength === 0) { + continue; + } + + let chunk = value; + if (bytesRead + chunk.byteLength > maxBytes) { + const remaining = Math.max(0, maxBytes - bytesRead); + if (remaining <= 0) { + truncated = true; + break; + } + chunk = chunk.subarray(0, remaining); + truncated = true; + } + + bytesRead += chunk.byteLength; + parts.push(decoder.decode(chunk, { stream: true })); + + if (truncated || bytesRead >= maxBytes) { + truncated = true; + break; + } + } + } catch { + // Best-effort: return whatever we decoded so far. + } finally { + if (truncated) { + try { + await reader.cancel(); + } catch { + // ignore + } + } + } + + parts.push(decoder.decode()); + return { text: parts.join(""), truncated, bytesRead }; + } + try { - return await res.text(); + const text = await res.text(); + return { text, truncated: false, bytesRead: text.length }; } catch { - return ""; + return { text: "", truncated: false, bytesRead: 0 }; } } diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts index 1061a0344ed..8ba281c485d 100644 --- a/src/agents/workspace-run.ts +++ b/src/agents/workspace-run.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { logWarn } from "../logger.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import { classifySessionKeyShape, @@ -8,6 +9,7 @@ import { } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; export type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type"; type AgentIdSource = "explicit" | "session_key" | "default"; @@ -84,8 +86,12 @@ export function resolveRunWorkspaceDir(params: { if (typeof requested === "string") { const trimmed = requested.trim(); if (trimmed) { + const sanitized = sanitizeForPromptLiteral(trimmed); + if (sanitized !== trimmed) { + logWarn("Control/format characters stripped from workspaceDir (OC-19 hardening)."); + } return { - workspaceDir: resolveUserPath(trimmed), + workspaceDir: resolveUserPath(sanitized), usedFallback: false, agentId, agentIdSource, @@ -96,8 +102,12 @@ export function resolveRunWorkspaceDir(params: { const fallbackReason: WorkspaceFallbackReason = requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type"; const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId); + const sanitizedFallback = sanitizeForPromptLiteral(fallbackWorkspace); + if (sanitizedFallback !== fallbackWorkspace) { + logWarn("Control/format characters stripped from fallback workspaceDir (OC-19 hardening)."); + } return { - workspaceDir: resolveUserPath(fallbackWorkspace), + workspaceDir: resolveUserPath(sanitizedFallback), usedFallback: true, fallbackReason, agentId, diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts index 5ac5281acb6..6322d7c9a8d 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts @@ -127,7 +127,10 @@ describe("group intro prompts", () => { vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain( - `You are replying inside a Discord group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -158,8 +161,12 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); + expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain( - `You are replying inside a WhatsApp group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `WhatsApp IDs: SenderId is the participant JID (group participant id).`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -190,8 +197,9 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "telegram"'); + expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain( - `You are replying inside a Telegram group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts deleted file mode 100644 index 145b93bd61d..00000000000 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { TemplateContext } from "../templating.js"; -import { buildThreadingToolContext } from "./agent-runner-utils.js"; - -describe("buildThreadingToolContext", () => { - const cfg = {} as OpenClawConfig; - - it("uses conversation id for WhatsApp", () => { - const sessionCtx = { - Provider: "whatsapp", - From: "123@g.us", - To: "+15550001", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("123@g.us"); - }); - - it("falls back to To for WhatsApp when From is missing", () => { - const sessionCtx = { - Provider: "whatsapp", - To: "+15550001", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("+15550001"); - }); - - it("uses the recipient id for other channels", () => { - const sessionCtx = { - Provider: "telegram", - From: "user:42", - To: "chat:99", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("chat:99"); - }); - - it("uses the sender handle for iMessage direct chats", () => { - const sessionCtx = { - Provider: "imessage", - ChatType: "direct", - From: "imessage:+15550001", - To: "chat_id:12", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("imessage:+15550001"); - }); - - it("uses chat_id for iMessage groups", () => { - const sessionCtx = { - Provider: "imessage", - ChatType: "group", - From: "imessage:group:7", - To: "chat_id:7", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("chat_id:7"); - }); - - it("prefers MessageThreadId for Slack tool threading", () => { - const sessionCtx = { - Provider: "slack", - To: "channel:C1", - MessageThreadId: "123.456", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("C1"); - expect(result.currentThreadTs).toBe("123.456"); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts deleted file mode 100644 index 9c14f82c77f..00000000000 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as sessions from "../../config/sessions.js"; -import { - createMinimalRun, - getRunEmbeddedPiAgentMock, - installRunReplyAgentTypingHeartbeatTestHooks, -} from "./agent-runner.heartbeat-typing.test-harness.js"; - -type AgentRunParams = { - onPartialReply?: (payload: { text?: string }) => Promise | void; - onAssistantMessageStart?: () => Promise | void; - onReasoningStream?: (payload: { text?: string }) => Promise | void; - onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onAgentEvent?: (evt: { stream: string; data: Record }) => void; -}; - -const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - -let fixtureRoot = ""; -let caseId = 0; - -type StateEnvSnapshot = { - OPENCLAW_STATE_DIR: string | undefined; -}; - -function snapshotStateEnv(): StateEnvSnapshot { - return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR }; -} - -function restoreStateEnv(snapshot: StateEnvSnapshot) { - if (snapshot.OPENCLAW_STATE_DIR === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR; - } -} - -async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { - const stateDir = path.join(fixtureRoot, `case-${++caseId}`); - await fs.mkdir(stateDir, { recursive: true }); - const envSnapshot = snapshotStateEnv(); - process.env.OPENCLAW_STATE_DIR = stateDir; - try { - return await fn(stateDir); - } finally { - restoreStateEnv(envSnapshot); - } -} - -async function writeCorruptGeminiSessionFixture(params: { - stateDir: string; - sessionId: string; - persistStore: boolean; -}) { - const storePath = path.join(params.stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - if (params.persistStore) { - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - } - - const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "bad", "utf-8"); - - return { storePath, sessionEntry, sessionStore, transcriptPath }; -} - -describe("runReplyAgent typing (heartbeat)", () => { - installRunReplyAgentTypingHeartbeatTestHooks(); - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } - }); - - beforeEach(() => { - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - }); - - it("signals typing for normal runs", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - }); - await run(); - - expect(onPartialReply).toHaveBeenCalled(); - expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("signals typing even without consumer partial handler", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("never signals typing for heartbeat runs", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: true, onPartialReply }, - }); - await run(); - - expect(onPartialReply).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("suppresses partial streaming for NO_REPLY", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "NO_REPLY" }); - return { payloads: [{ text: "NO_REPLY" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - typingMode: "message", - }); - await run(); - - expect(onPartialReply).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("does not start typing on assistant message start without prior text in message mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onAssistantMessageStart?.(); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - }); - await run(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("starts typing from reasoning stream in thinking mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "thinking", - }); - await run(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("suppresses typing in never mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "never", - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals typing on normalized block replies", async () => { - const onBlockReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - blockStreamingEnabled: true, - opts: { onBlockReply }, - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk"); - expect(onBlockReply).toHaveBeenCalled(); - const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? []; - expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false }); - expect(blockOpts).toMatchObject({ - abortSignal: expect.any(AbortSignal), - timeoutMs: expect.any(Number), - }); - }); - - it("signals typing on tool results", async () => { - const onToolResult = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); - expect(onToolResult).toHaveBeenCalledWith({ - text: "tooling", - mediaUrls: [], - }); - }); - - it("skips typing for silent tool results", async () => { - const onToolResult = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(onToolResult).not.toHaveBeenCalled(); - }); - - it("announces auto-compaction in verbose mode and tracks count", async () => { - await withTempStateDir(async (stateDir) => { - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run } = createMinimalRun({ - resolvedVerboseLevel: "on", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - expect(Array.isArray(res)).toBe(true); - const payloads = res as { text?: string }[]; - expect(payloads[0]?.text).toContain("Auto-compaction complete"); - expect(payloads[0]?.text).toContain("count 1"); - expect(sessionStore.main.compactionCount).toBe(1); - }); - }); - - it("retries after compaction failure by resetting the session", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Context limit exceeded during compaction"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("retries after context overflow payload by resetting the session", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [{ text: "Context overflow: prompt too large", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "context_overflow", - message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - }, - }, - })); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Context limit exceeded"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("resets the session after role ordering payloads", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "role_ordering", - message: 'messages: roles must alternate between "user" and "assistant"', - }, - }, - })); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - await expect(fs.access(transcriptPath)).rejects.toBeDefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("resets corrupted Gemini sessions and deletes transcripts", async () => { - await withTempStateDir(async (stateDir) => { - const { storePath, sessionEntry, sessionStore, transcriptPath } = - await writeCorruptGeminiSessionFixture({ - stateDir, - sessionId: "session-corrupt", - persistStore: true, - }); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - "function call turn comes immediately after a user turn or after a function response turn", - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Session history was corrupted"), - }); - expect(sessionStore.main).toBeUndefined(); - await expect(fs.access(transcriptPath)).rejects.toThrow(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main).toBeUndefined(); - }); - }); - - it("keeps sessions intact on other errors", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session-ok"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId, updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error("INVALID_ARGUMENT: some other failure"); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Agent failed before reply"), - }); - expect(sessionStore.main).toBeDefined(); - await expect(fs.access(transcriptPath)).resolves.toBeUndefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main).toBeDefined(); - }); - }); - - it("still replies even if session reset fails to persist", async () => { - await withTempStateDir(async (stateDir) => { - const saveSpy = vi - .spyOn(sessions, "saveSessionStore") - .mockRejectedValueOnce(new Error("boom")); - try { - const { storePath, sessionEntry, sessionStore, transcriptPath } = - await writeCorruptGeminiSessionFixture({ - stateDir, - sessionId: "session-corrupt", - persistStore: false, - }); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - "function call turn comes immediately after a user turn or after a function response turn", - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Session history was corrupted"), - }); - expect(sessionStore.main).toBeUndefined(); - await expect(fs.access(transcriptPath)).rejects.toThrow(); - } finally { - saveSpy.mockRestore(); - } - }); - }); - - it("returns friendly message for role ordering errors thrown as exceptions", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error("400 Incorrect role information"); - }); - - const { run } = createMinimalRun({}); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - expect(res).toMatchObject({ - text: expect.not.stringContaining("400"), - }); - }); - - it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error('messages: roles must alternate between "user" and "assistant"'); - }); - - const { run } = createMinimalRun({}); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - }); - - it("rewrites Bun socket errors into friendly text", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [ - { - text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", - isError: true, - }, - ], - meta: {}, - })); - - const { run } = createMinimalRun(); - const res = await run(); - const payloads = Array.isArray(res) ? res : res ? [res] : []; - expect(payloads.length).toBe(1); - expect(payloads[0]?.text).toContain("LLM connection failed"); - expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); - expect(payloads[0]?.text).toContain("```"); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts deleted file mode 100644 index 80e1e37c8f7..00000000000 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeAll, beforeEach, vi } from "vitest"; -import type { SessionEntry } from "../../config/sessions.js"; -import type { TypingMode } from "../../config/types.js"; -import type { TemplateContext } from "../templating.js"; -import type { GetReplyOptions } from "../types.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). -// oxlint-disable-next-line typescript/no-explicit-any -type AnyMock = any; - -const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), -})); - -let runReplyAgentPromise: - | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> - | undefined; - -async function getRunReplyAgent() { - if (!runReplyAgentPromise) { - runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent); - } - return await runReplyAgentPromise; -} - -export function getRunEmbeddedPiAgentMock(): AnyMock { - return state.runEmbeddedPiAgentMock; -} - -export function installRunReplyAgentTypingHeartbeatTestHooks() { - beforeAll(async () => { - // Avoid attributing the initial agent-runner import cost to the first test case. - await getRunReplyAgent(); - }); - beforeEach(() => { - state.runEmbeddedPiAgentMock.mockReset(); - }); -} - -async function loadHarnessMocks() { - const { loadAgentRunnerHarnessMockBundle } = await import("./agent-runner.test-harness.mocks.js"); - return await loadAgentRunnerHarnessMockBundle(state); -} - -vi.mock("../../agents/model-fallback.js", async () => { - return (await loadHarnessMocks()).modelFallback; -}); - -vi.mock("../../agents/pi-embedded.js", async () => { - return (await loadHarnessMocks()).embeddedPi; -}); - -vi.mock("./queue.js", async () => { - return (await loadHarnessMocks()).queue; -}); - -export function createMinimalRun(params?: { - opts?: GetReplyOptions; - resolvedVerboseLevel?: "off" | "on"; - sessionStore?: Record; - sessionEntry?: SessionEntry; - sessionKey?: string; - storePath?: string; - typingMode?: TypingMode; - blockStreamingEnabled?: boolean; -}) { - const typing = createMockTypingController(); - const opts = params?.opts; - const sessionCtx = { - Provider: "whatsapp", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const sessionKey = params?.sessionKey ?? "main"; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - sessionId: "session", - sessionKey, - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: params?.resolvedVerboseLevel ?? "off", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as unknown as FollowupRun; - - return { - typing, - opts, - run: async () => { - const runReplyAgent = await getRunReplyAgent(); - return runReplyAgent({ - commandBody: "hello", - followupRun, - queueKey: "main", - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - opts, - typing, - sessionEntry: params?.sessionEntry, - sessionStore: params?.sessionStore, - sessionKey, - storePath: params?.storePath, - sessionCtx, - defaultModel: "anthropic/claude-opus-4-5", - resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", - isNewSession: false, - blockStreamingEnabled: params?.blockStreamingEnabled ?? false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: params?.typingMode ?? "instant", - }); - }, - }; -} diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts deleted file mode 100644 index e13de88c54d..00000000000 --- a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - createBaseRun, - getRunCliAgentMock, - getRunEmbeddedPiAgentMock, - seedSessionStore, - type EmbeddedRunParams, -} from "./agent-runner.memory-flush.test-harness.js"; -import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; - -let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent; - -let fixtureRoot = ""; -let caseId = 0; - -async function withTempStore(fn: (storePath: string) => Promise): Promise { - const dir = path.join(fixtureRoot, `case-${++caseId}`); - await fs.mkdir(dir, { recursive: true }); - return await fn(path.join(dir, "sessions.json")); -} - -async function runReplyAgentWithBase(params: { - baseRun: ReturnType; - storePath: string; - sessionKey: string; - sessionEntry: Record; - commandBody: string; - typingMode?: "instant"; -}): Promise { - const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun; - await runReplyAgent({ - commandBody: params.commandBody, - followupRun, - queueKey: params.sessionKey, - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - typing, - sessionCtx, - sessionEntry: params.sessionEntry, - sessionStore: { [params.sessionKey]: params.sessionEntry }, - sessionKey: params.sessionKey, - storePath: params.storePath, - defaultModel: "anthropic/claude-opus-4-5", - agentCfgContextTokens: 100_000, - resolvedVerboseLevel: "off", - isNewSession: false, - blockStreamingEnabled: false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: params.typingMode ?? "instant", - }); -} - -async function expectMemoryFlushSkippedWithWorkspaceAccess( - workspaceAccess: "ro" | "none", -): Promise { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { - agents: { - defaults: { - sandbox: { mode: "all", workspaceAccess }, - }, - }, - }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual(["hello"]); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); - }); -} - -beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-flush-")); - ({ runReplyAgent } = await import("./agent-runner.js")); -}); - -afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } -}); - -describe("runReplyAgent memory flush", () => { - it("skips memory flush for CLI providers", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const runCliAgentMock = getRunCliAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - runCliAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async () => ({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - })); - runCliAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - runOverrides: { provider: "codex-cli" }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(runCliAgentMock).toHaveBeenCalledTimes(1); - const call = runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(call?.prompt).toBe("hello"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); - - it("uses configured prompts for memory flush runs", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push(params); - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { - agents: { - defaults: { - compaction: { - memoryFlush: { - prompt: "Write notes.", - systemPrompt: "Flush memory now.", - }, - }, - }, - }, - }, - runOverrides: { extraSystemPrompt: "extra system" }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - const flushCall = calls[0]; - expect(flushCall?.prompt).toContain("Write notes."); - expect(flushCall?.prompt).toContain("NO_REPLY"); - expect(flushCall?.extraSystemPrompt).toContain("extra system"); - expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); - expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); - expect(calls[1]?.prompt).toBe("hello"); - }); - }); - - it("runs a memory flush turn and updates session metadata", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); - expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); - }); - }); - - it("skips memory flush when disabled in config", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async () => ({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - })); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(call?.prompt).toBe("hello"); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); - }); - }); - - it("skips memory flush after a prior flush in the same compaction cycle", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 2, - memoryFlushCompactionCount: 2, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual(["hello"]); - }); - }); - - it("skips memory flush when the sandbox workspace is read-only", async () => { - await expectMemoryFlushSkippedWithWorkspaceAccess("ro"); - }); - - it("skips memory flush when the sandbox workspace is none", async () => { - await expectMemoryFlushSkippedWithWorkspaceAccess("none"); - }); - - it("increments compaction count when flush compaction completes", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(2); - expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2); - }); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts b/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts deleted file mode 100644 index 74204b9f7f9..00000000000 --- a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts +++ /dev/null @@ -1,121 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { vi } from "vitest"; -import type { TemplateContext } from "../templating.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). -// oxlint-disable-next-line typescript/no-explicit-any -type AnyMock = any; - -type EmbeddedRunParams = { - prompt?: string; - extraSystemPrompt?: string; - onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; -}; - -const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), - runCliAgentMock: vi.fn(), -})); - -export function getRunEmbeddedPiAgentMock(): AnyMock { - return state.runEmbeddedPiAgentMock; -} - -export function getRunCliAgentMock(): AnyMock { - return state.runCliAgentMock; -} - -export type { EmbeddedRunParams }; - -async function loadHarnessMocks() { - const { loadAgentRunnerHarnessMockBundle } = await import("./agent-runner.test-harness.mocks.js"); - return await loadAgentRunnerHarnessMockBundle(state); -} - -vi.mock("../../agents/model-fallback.js", async () => { - return (await loadHarnessMocks()).modelFallback; -}); - -vi.mock("../../agents/cli-runner.js", () => ({ - runCliAgent: (params: unknown) => state.runCliAgentMock(params), -})); - -vi.mock("../../agents/pi-embedded.js", async () => { - return (await loadHarnessMocks()).embeddedPi; -}); - -vi.mock("./queue.js", async () => { - return (await loadHarnessMocks()).queue; -}); - -export async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -export function createBaseRun(params: { - storePath: string; - sessionEntry: Record; - config?: Record; - runOverrides?: Partial; -}) { - const typing = createMockTypingController(); - const sessionCtx = { - Provider: "whatsapp", - OriginatingTo: "+15550001111", - AccountId: "primary", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - agentId: "main", - agentDir: "/tmp/agent", - sessionId: "session", - sessionKey: "main", - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: params.config ?? {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: "off", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as unknown as FollowupRun; - const run = { - ...followupRun.run, - ...params.runOverrides, - config: params.config ?? followupRun.run.config, - }; - - return { - typing, - sessionCtx, - resolvedQueue, - followupRun: { ...followupRun, run }, - }; -} diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts new file mode 100644 index 00000000000..ec7fb1161ff --- /dev/null +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -0,0 +1,1175 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { TypingMode } from "../../config/types.js"; +import type { TemplateContext } from "../templating.js"; +import type { GetReplyOptions } from "../types.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import * as sessions from "../../config/sessions.js"; +import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; +import { createMockTypingController } from "./test-helpers.js"; + +type AgentRunParams = { + onPartialReply?: (payload: { text?: string }) => Promise | void; + onAssistantMessageStart?: () => Promise | void; + onReasoningStream?: (payload: { text?: string }) => Promise | void; + onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onAgentEvent?: (evt: { stream: string; data: Record }) => void; +}; + +type EmbeddedRunParams = { + prompt?: string; + extraSystemPrompt?: string; + onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; +}; + +const state = vi.hoisted(() => ({ + runEmbeddedPiAgentMock: vi.fn(), + runCliAgentMock: vi.fn(), +})); + +let runReplyAgentPromise: + | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> + | undefined; + +async function getRunReplyAgent() { + if (!runReplyAgentPromise) { + runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent); + } + return await runReplyAgentPromise; +} + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: async ({ + provider, + model, + run, + }: { + provider: string; + model: string; + run: (provider: string, model: string) => Promise; + }) => ({ + result: await run(provider, model), + provider, + model, + }), +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), +})); + +vi.mock("../../agents/cli-runner.js", () => ({ + runCliAgent: (params: unknown) => state.runCliAgentMock(params), +})); + +vi.mock("./queue.js", () => ({ + enqueueFollowupRun: vi.fn(), + scheduleFollowupDrain: vi.fn(), +})); + +beforeAll(async () => { + // Avoid attributing the initial agent-runner import cost to the first test case. + await getRunReplyAgent(); +}); + +beforeEach(() => { + state.runEmbeddedPiAgentMock.mockReset(); + state.runCliAgentMock.mockReset(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); +}); + +function createMinimalRun(params?: { + opts?: GetReplyOptions; + resolvedVerboseLevel?: "off" | "on"; + sessionStore?: Record; + sessionEntry?: SessionEntry; + sessionKey?: string; + storePath?: string; + typingMode?: TypingMode; + blockStreamingEnabled?: boolean; +}) { + const typing = createMockTypingController(); + const opts = params?.opts; + const sessionCtx = { + Provider: "whatsapp", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const sessionKey = params?.sessionKey ?? "main"; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey, + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: params?.resolvedVerboseLevel ?? "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + return { + typing, + opts, + run: async () => { + const runReplyAgent = await getRunReplyAgent(); + return runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + opts, + typing, + sessionEntry: params?.sessionEntry, + sessionStore: params?.sessionStore, + sessionKey, + storePath: params?.storePath, + sessionCtx, + defaultModel: "anthropic/claude-opus-4-5", + resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", + isNewSession: false, + blockStreamingEnabled: params?.blockStreamingEnabled ?? false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: params?.typingMode ?? "instant", + }); + }, + }; +} + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +function createBaseRun(params: { + storePath: string; + sessionEntry: Record; + config?: Record; + runOverrides?: Partial; +}) { + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "whatsapp", + OriginatingTo: "+15550001111", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + agentId: "main", + agentDir: "/tmp/agent", + sessionId: "session", + sessionKey: "main", + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: params.config ?? {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + const run = { + ...followupRun.run, + ...params.runOverrides, + config: params.config ?? followupRun.run.config, + }; + + return { + typing, + sessionCtx, + resolvedQueue, + followupRun: { ...followupRun, run }, + }; +} + +async function runReplyAgentWithBase(params: { + baseRun: ReturnType; + storePath: string; + sessionKey: string; + sessionEntry: Record; + commandBody: string; + typingMode?: "instant"; +}): Promise { + const runReplyAgent = await getRunReplyAgent(); + const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun; + await runReplyAgent({ + commandBody: params.commandBody, + followupRun, + queueKey: params.sessionKey, + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry: params.sessionEntry, + sessionStore: { [params.sessionKey]: params.sessionEntry } as Record, + sessionKey: params.sessionKey, + storePath: params.storePath, + defaultModel: "anthropic/claude-opus-4-5", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: params.typingMode ?? "instant", + }); +} + +describe("runReplyAgent typing (heartbeat)", () => { + let fixtureRoot = ""; + let caseId = 0; + + type StateEnvSnapshot = { + OPENCLAW_STATE_DIR: string | undefined; + }; + + function snapshotStateEnv(): StateEnvSnapshot { + return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR }; + } + + function restoreStateEnv(snapshot: StateEnvSnapshot) { + if (snapshot.OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR; + } + } + + async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { + const stateDir = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(stateDir, { recursive: true }); + const envSnapshot = snapshotStateEnv(); + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + return await fn(stateDir); + } finally { + restoreStateEnv(envSnapshot); + } + } + + async function writeCorruptGeminiSessionFixture(params: { + stateDir: string; + sessionId: string; + persistStore: boolean; + }) { + const storePath = path.join(params.stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + if (params.persistStore) { + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + } + + const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "bad", "utf-8"); + + return { storePath, sessionEntry, sessionStore, transcriptPath }; + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("signals typing for normal runs", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + }); + await run(); + + expect(onPartialReply).toHaveBeenCalled(); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("signals typing even without consumer partial handler", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("never signals typing for heartbeat runs", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true, onPartialReply }, + }); + await run(); + + expect(onPartialReply).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("suppresses partial streaming for NO_REPLY", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "NO_REPLY" }); + return { payloads: [{ text: "NO_REPLY" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + typingMode: "message", + }); + await run(); + + expect(onPartialReply).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("does not start typing on assistant message start without prior text in message mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onAssistantMessageStart?.(); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + }); + await run(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("starts typing from reasoning stream in thinking mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "thinking", + }); + await run(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("suppresses typing in never mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "never", + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("signals typing on normalized block replies", async () => { + const onBlockReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + blockStreamingEnabled: true, + opts: { onBlockReply }, + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk"); + expect(onBlockReply).toHaveBeenCalled(); + const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? []; + expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false }); + expect(blockOpts).toMatchObject({ + abortSignal: expect.any(AbortSignal), + timeoutMs: expect.any(Number), + }); + }); + + it("signals typing on tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); + expect(onToolResult).toHaveBeenCalledWith({ + text: "tooling", + mediaUrls: [], + }); + }); + + it("skips typing for silent tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(onToolResult).not.toHaveBeenCalled(); + }); + + it("announces auto-compaction in verbose mode and tracks count", async () => { + await withTempStateDir(async (stateDir) => { + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + resolvedVerboseLevel: "on", + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + expect(Array.isArray(res)).toBe(true); + const payloads = res as { text?: string }[]; + expect(payloads[0]?.text).toContain("Auto-compaction complete"); + expect(payloads[0]?.text).toContain("count 1"); + expect(sessionStore.main.compactionCount).toBe(1); + }); + }); + + it("retries after compaction failure by resetting the session", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded during compaction"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("retries after context overflow payload by resetting the session", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Context overflow: prompt too large", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("resets the session after role ordering payloads", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "role_ordering", + message: 'messages: roles must alternate between "user" and "assistant"', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + await expect(fs.access(transcriptPath)).rejects.toBeDefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("resets corrupted Gemini sessions and deletes transcripts", async () => { + await withTempStateDir(async (stateDir) => { + const { storePath, sessionEntry, sessionStore, transcriptPath } = + await writeCorruptGeminiSessionFixture({ + stateDir, + sessionId: "session-corrupt", + persistStore: true, + }); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + "function call turn comes immediately after a user turn or after a function response turn", + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Session history was corrupted"), + }); + expect(sessionStore.main).toBeUndefined(); + await expect(fs.access(transcriptPath)).rejects.toThrow(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main).toBeUndefined(); + }); + }); + + it("keeps sessions intact on other errors", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session-ok"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("INVALID_ARGUMENT: some other failure"); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Agent failed before reply"), + }); + expect(sessionStore.main).toBeDefined(); + await expect(fs.access(transcriptPath)).resolves.toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main).toBeDefined(); + }); + }); + + it("still replies even if session reset fails to persist", async () => { + await withTempStateDir(async (stateDir) => { + const saveSpy = vi + .spyOn(sessions, "saveSessionStore") + .mockRejectedValueOnce(new Error("boom")); + try { + const { storePath, sessionEntry, sessionStore, transcriptPath } = + await writeCorruptGeminiSessionFixture({ + stateDir, + sessionId: "session-corrupt", + persistStore: false, + }); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + "function call turn comes immediately after a user turn or after a function response turn", + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Session history was corrupted"), + }); + expect(sessionStore.main).toBeUndefined(); + await expect(fs.access(transcriptPath)).rejects.toThrow(); + } finally { + saveSpy.mockRestore(); + } + }); + }); + + it("returns friendly message for role ordering errors thrown as exceptions", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("400 Incorrect role information"); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(res).toMatchObject({ + text: expect.not.stringContaining("400"), + }); + }); + + it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error('messages: roles must alternate between "user" and "assistant"'); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + }); + + it("rewrites Bun socket errors into friendly text", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [ + { + text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + isError: true, + }, + ], + meta: {}, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payloads = Array.isArray(res) ? res : res ? [res] : []; + expect(payloads.length).toBe(1); + expect(payloads[0]?.text).toContain("LLM connection failed"); + expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); + expect(payloads[0]?.text).toContain("```"); + }); +}); + +describe("runReplyAgent memory flush", () => { + let fixtureRoot = ""; + let caseId = 0; + + async function withTempStore(fn: (storePath: string) => Promise): Promise { + const dir = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(dir, { recursive: true }); + return await fn(path.join(dir, "sessions.json")); + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-memory-flush-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + async function expectMemoryFlushSkippedWithWorkspaceAccess( + workspaceAccess: "ro" | "none", + ): Promise { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { + agents: { + defaults: { + sandbox: { mode: "all", workspaceAccess }, + }, + }, + }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual(["hello"]); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); + }); + } + + it("skips memory flush for CLI providers", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async () => ({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + })); + state.runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + runOverrides: { provider: "codex-cli" }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(state.runCliAgentMock).toHaveBeenCalledTimes(1); + const call = state.runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; + expect(call?.prompt).toBe("hello"); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + + it("uses configured prompts for memory flush runs", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push(params); + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { + agents: { + defaults: { + compaction: { + memoryFlush: { + prompt: "Write notes.", + systemPrompt: "Flush memory now.", + }, + }, + }, + }, + }, + runOverrides: { extraSystemPrompt: "extra system" }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + const flushCall = calls[0]; + expect(flushCall?.prompt).toContain("Write notes."); + expect(flushCall?.prompt).toContain("NO_REPLY"); + expect(flushCall?.extraSystemPrompt).toContain("extra system"); + expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); + expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); + expect(calls[1]?.prompt).toBe("hello"); + }); + }); + + it("runs a memory flush turn and updates session metadata", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); + }); + }); + + it("skips memory flush when disabled in config", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async () => ({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + })); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const call = state.runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + | { prompt?: string } + | undefined; + expect(call?.prompt).toBe("hello"); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); + }); + }); + + it("skips memory flush after a prior flush in the same compaction cycle", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 2, + memoryFlushCompactionCount: 2, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual(["hello"]); + }); + }); + + it("skips memory flush when the sandbox workspace is read-only", async () => { + await expectMemoryFlushSkippedWithWorkspaceAccess("ro"); + }); + + it("skips memory flush when the sandbox workspace is none", async () => { + await expectMemoryFlushSkippedWithWorkspaceAccess("none"); + }); + + it("increments compaction count when flush compaction completes", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(2); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2); + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.test-harness.mocks.ts b/src/auto-reply/reply/agent-runner.test-harness.mocks.ts deleted file mode 100644 index 6d5d952414b..00000000000 --- a/src/auto-reply/reply/agent-runner.test-harness.mocks.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { vi } from "vitest"; - -export type AgentRunnerEmbeddedState = { - runEmbeddedPiAgentMock: (params: unknown) => unknown; -}; - -export function modelFallbackMockFactory(): Record { - return { - runWithModelFallback: async ({ - provider, - model, - run, - }: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - }) => ({ - result: await run(provider, model), - provider, - model, - }), - }; -} - -export function embeddedPiMockFactory(state: AgentRunnerEmbeddedState): Record { - return { - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), - }; -} - -export async function queueMockFactory(): Promise> { - const actual = await vi.importActual("./queue.js"); - return { - ...actual, - enqueueFollowupRun: vi.fn(), - scheduleFollowupDrain: vi.fn(), - }; -} - -export async function loadAgentRunnerHarnessMockBundle(state: AgentRunnerEmbeddedState): Promise<{ - modelFallback: Record; - embeddedPi: Record; - queue: Record; -}> { - return { - modelFallback: modelFallbackMockFactory(), - embeddedPi: embeddedPiMockFactory(state), - queue: await queueMockFactory(), - }; -} diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 6b3b021ee42..c8f8eba129a 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -450,6 +450,7 @@ export async function runReplyAgent(params: { promptTokens, total: totalTokens, }, + lastCallUsage: runResult.meta.agentMeta?.lastCallUsage, context: { limit: contextTokensUsed, used: totalTokens, diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts deleted file mode 100644 index cfb1f3cb7f0..00000000000 --- a/src/auto-reply/reply/commands-approve.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { callGateway } from "../../gateway/call.js"; -import { handleCommands } from "./commands.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; - -vi.mock("../../gateway/call.js", () => ({ - callGateway: vi.fn(), -})); - -describe("/approve command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("rejects invalid usage", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Usage: /approve"); - }); - - it("submits approval", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { SenderId: "123" }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("rejects gateway clients without approvals scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.write"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.approvals"); - expect(mockCallGateway).not.toHaveBeenCalled(); - }); - - it("allows gateway clients with approvals scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.approvals"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("allows gateway clients with admin scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.admin"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts deleted file mode 100644 index 7c418ac239a..00000000000 --- a/src/auto-reply/reply/commands-compact.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; -import { handleCompactCommand } from "./commands-compact.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; - -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn(), - compactEmbeddedPiSession: vi.fn(), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../infra/system-events.js", () => ({ - enqueueSystemEvent: vi.fn(), -})); - -vi.mock("./session-updates.js", () => ({ - incrementCompactionCount: vi.fn(), -})); - -describe("/compact command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns null when command is not /compact", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/status", cfg); - - const result = await handleCompactCommand( - { - ...params, - }, - true, - ); - - expect(result).toBeNull(); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("rejects unauthorized /compact commands", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/compact", cfg); - - const result = await handleCompactCommand( - { - ...params, - command: { - ...params.command, - isAuthorizedSender: false, - senderId: "unauthorized", - }, - }, - true, - ); - - expect(result).toEqual({ shouldContinue: false }); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("routes manual compaction with explicit trigger and context metadata", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: "/tmp/openclaw-session-store.json" }, - } as OpenClawConfig; - const params = buildCommandTestParams("/compact: focus on decisions", cfg, { - From: "+15550001", - To: "+15550002", - }); - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ - ok: true, - compacted: false, - }); - - const result = await handleCompactCommand( - { - ...params, - sessionEntry: { - sessionId: "session-1", - groupId: "group-1", - groupChannel: "#general", - space: "workspace-1", - spawnedBy: "agent:main:parent", - totalTokens: 12345, - }, - }, - true, - ); - - expect(result?.shouldContinue).toBe(false); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "session-1", - sessionKey: "agent:main:main", - trigger: "manual", - customInstructions: "focus on decisions", - messageChannel: "whatsapp", - groupId: "group-1", - groupChannel: "#general", - groupSpace: "workspace-1", - spawnedBy: "agent:main:parent", - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts deleted file mode 100644 index 9751c39cca5..00000000000 --- a/src/auto-reply/reply/commands-info.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildCommandsPaginationKeyboard } from "./commands-info.js"; - -describe("buildCommandsPaginationKeyboard", () => { - it("adds agent id to callback data when provided", () => { - const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); - expect(keyboard[0]).toEqual([ - { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, - { text: "2/3", callback_data: "commands_page_noop:agent-main" }, - { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, - ]); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts deleted file mode 100644 index 47309f93217..00000000000 --- a/src/auto-reply/reply/commands-parsing.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { extractMessageText } from "./commands-subagents.js"; -import { handleCommands } from "./commands.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; -import { parseConfigCommand } from "./config-commands.js"; -import { parseDebugCommand } from "./debug-commands.js"; - -describe("parseConfigCommand", () => { - it("parses show/unset", () => { - expect(parseConfigCommand("/config")).toEqual({ action: "show" }); - expect(parseConfigCommand("/config show")).toEqual({ - action: "show", - path: undefined, - }); - expect(parseConfigCommand("/config show foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config get foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config unset foo.bar")).toEqual({ - action: "unset", - path: "foo.bar", - }); - }); - - it("parses set with JSON", () => { - const cmd = parseConfigCommand('/config set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); -}); - -describe("parseDebugCommand", () => { - it("parses show/reset", () => { - expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); - }); - - it("parses set with JSON", () => { - const cmd = parseDebugCommand('/debug set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); - - it("parses unset", () => { - const cmd = parseDebugCommand("/debug unset foo.bar"); - expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); - }); -}); - -describe("extractMessageText", () => { - it("preserves user text that looks like tool call markers", () => { - const message = { - role: "user", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); - }); - - it("sanitizes assistant tool call markers", () => { - const message = { - role: "assistant", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toBe("Here ok"); - }); -}); - -describe("handleCommands /config configWrites gating", () => { - it("blocks /config set when channel config writes are disabled", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, - } as OpenClawConfig; - const params = buildCommandTestParams('/config set messages.ackReaction=":)"', cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config writes are disabled"); - }); -}); diff --git a/src/auto-reply/reply/commands-policy.test.ts b/src/auto-reply/reply/commands-policy.test.ts deleted file mode 100644 index c93b818e25f..00000000000 --- a/src/auto-reply/reply/commands-policy.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { MsgContext } from "../templating.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; -import { parseInlineDirectives } from "./directive-handling.js"; - -const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); -const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../config/config.js", async () => { - const actual = - await vi.importActual("../../config/config.js"); - return { - ...actual, - readConfigFileSnapshot: readConfigFileSnapshotMock, - validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, - writeConfigFile: writeConfigFileMock, - }; -}); - -const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); -const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); -const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../pairing/pairing-store.js", async () => { - const actual = await vi.importActual( - "../../pairing/pairing-store.js", - ); - return { - ...actual, - readChannelAllowFromStore: readChannelAllowFromStoreMock, - addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, - removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, - }; -}); - -vi.mock("../../channels/plugins/pairing.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/pairing.js", - ); - return { - ...actual, - listPairingChannels: () => ["telegram"], - }; -}); - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, - { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, - { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, - ]), -})); - -function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { - const ctx = { - Body: commandBody, - CommandBody: commandBody, - CommandSource: "text", - CommandAuthorized: true, - Provider: "telegram", - Surface: "telegram", - ...ctxOverrides, - } as MsgContext; - - const command = buildCommandContext({ - ctx, - cfg, - isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), - commandAuthorized: true, - }); - - return { - ctx, - cfg, - command, - directives: parseInlineDirectives(commandBody), - elevated: { enabled: true, allowed: true, failures: [] }, - sessionKey: "agent:main:main", - workspaceDir: "/tmp", - defaultGroupActivation: () => "mention", - resolvedVerboseLevel: "off" as const, - resolvedReasoningLevel: "off" as const, - resolveDefaultThinkingLevel: async () => undefined, - provider: "telegram", - model: "test-model", - contextTokens: 0, - isGroup: false, - }; -} - -describe("handleCommands /allowlist", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("lists config + store allowFrom entries", async () => { - readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); - - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["123", "@Alice"] } }, - } as OpenClawConfig; - const params = buildParams("/allowlist list dm", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Channel: telegram"); - expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice"); - expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); - }); - - it("adds entries to config and pairing store", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { allowFrom: ["123"] } }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); - - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { allowFrom: ["123"] } }, - } as OpenClawConfig; - const params = buildParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { telegram: { allowFrom: ["123", "789"] } }, - }), - ); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - }); - expect(result.reply?.text).toContain("DM allowlist added"); - }); - - it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - - const cfg = { - commands: { text: true, config: true }, - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; - - const params = buildParams("/allowlist remove dm U111", cfg, { - Provider: "slack", - Surface: "slack", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); - expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.slack.allowFrom"); - }); - - it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - - const cfg = { - commands: { text: true, config: true }, - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; - - const params = buildParams("/allowlist remove dm 111", cfg, { - Provider: "discord", - Surface: "discord", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.discord?.allowFrom).toEqual(["222"]); - expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.discord.allowFrom"); - }); -}); - -describe("/models command", () => { - const cfg = { - commands: { text: true }, - agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, - } as unknown as OpenClawConfig; - - it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { - const params = buildParams("/models", cfg, { Provider: surface, Surface: surface }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Providers:"); - expect(result.reply?.text).toContain("anthropic"); - expect(result.reply?.text).toContain("Use: /models "); - }); - - it("lists providers on telegram (buttons)", async () => { - const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toBe("Select a provider:"); - const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) - ?.telegram?.buttons; - expect(buttons).toBeDefined(); - expect(buttons?.length).toBeGreaterThan(0); - }); - - it("lists provider models with pagination hints", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic)"); - expect(result.reply?.text).toContain("page 1/"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).toContain("Switch: /model "); - expect(result.reply?.text).toContain("All: /models anthropic all"); - }); - - it("ignores page argument when all flag is present", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic)"); - expect(result.reply?.text).toContain("page 1/1"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).not.toContain("Page out of range"); - }); - - it("errors on out-of-range pages", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Page out of range"); - expect(result.reply?.text).toContain("valid: 1-"); - }); - - it("handles unknown providers", async () => { - const params = buildParams("/models not-a-provider", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Unknown provider"); - expect(result.reply?.text).toContain("Available providers"); - }); - - it("lists configured models outside the curated catalog", async () => { - const customCfg = { - commands: { text: true }, - agents: { - defaults: { - model: { - primary: "localai/ultra-chat", - fallbacks: ["anthropic/claude-opus-4-5"], - }, - imageModel: "visionpro/studio-v1", - }, - }, - } as unknown as OpenClawConfig; - - // Use discord surface for text-based output tests - const providerList = await handleCommands( - buildParams("/models", customCfg, { Surface: "discord" }), - ); - expect(providerList.reply?.text).toContain("localai"); - expect(providerList.reply?.text).toContain("visionpro"); - - const result = await handleCommands( - buildParams("/models localai", customCfg, { Surface: "discord" }), - ); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (localai)"); - expect(result.reply?.text).toContain("localai/ultra-chat"); - expect(result.reply?.text).not.toContain("Unknown provider"); - }); -}); diff --git a/src/auto-reply/reply/commands-setunset.ts b/src/auto-reply/reply/commands-setunset.ts new file mode 100644 index 00000000000..137973a5e69 --- /dev/null +++ b/src/auto-reply/reply/commands-setunset.ts @@ -0,0 +1,38 @@ +import { parseConfigValue } from "./config-value.js"; + +export type SetUnsetParseResult = + | { kind: "set"; path: string; value: unknown } + | { kind: "unset"; path: string } + | { kind: "error"; message: string }; + +export function parseSetUnsetCommand(params: { + slash: string; + action: "set" | "unset"; + args: string; +}): SetUnsetParseResult { + const action = params.action; + const args = params.args.trim(); + if (action === "unset") { + if (!args) { + return { kind: "error", message: `Usage: ${params.slash} unset path` }; + } + return { kind: "unset", path: args }; + } + if (!args) { + return { kind: "error", message: `Usage: ${params.slash} set path=value` }; + } + const eqIndex = args.indexOf("="); + if (eqIndex <= 0) { + return { kind: "error", message: `Usage: ${params.slash} set path=value` }; + } + const path = args.slice(0, eqIndex).trim(); + const rawValue = args.slice(eqIndex + 1); + if (!path) { + return { kind: "error", message: `Usage: ${params.slash} set path=value` }; + } + const parsed = parseConfigValue(rawValue); + if (parsed.error) { + return { kind: "error", message: parsed.error }; + } + return { kind: "set", path, value: parsed.value }; +} diff --git a/src/auto-reply/reply/commands-slash-parse.ts b/src/auto-reply/reply/commands-slash-parse.ts new file mode 100644 index 00000000000..8cf5541e31b --- /dev/null +++ b/src/auto-reply/reply/commands-slash-parse.ts @@ -0,0 +1,46 @@ +export type SlashCommandParseResult = + | { kind: "no-match" } + | { kind: "empty" } + | { kind: "invalid" } + | { kind: "parsed"; action: string; args: string }; + +export type ParsedSlashCommand = + | { ok: true; action: string; args: string } + | { ok: false; message: string }; + +export function parseSlashCommandActionArgs(raw: string, slash: string): SlashCommandParseResult { + const trimmed = raw.trim(); + const slashLower = slash.toLowerCase(); + if (!trimmed.toLowerCase().startsWith(slashLower)) { + return { kind: "no-match" }; + } + const rest = trimmed.slice(slash.length).trim(); + if (!rest) { + return { kind: "empty" }; + } + const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); + if (!match) { + return { kind: "invalid" }; + } + const action = match[1]?.toLowerCase() ?? ""; + const args = (match[2] ?? "").trim(); + return { kind: "parsed", action, args }; +} + +export function parseSlashCommandOrNull( + raw: string, + slash: string, + opts: { invalidMessage: string; defaultAction?: string }, +): ParsedSlashCommand | null { + const parsed = parseSlashCommandActionArgs(raw, slash); + if (parsed.kind === "no-match") { + return null; + } + if (parsed.kind === "invalid") { + return { ok: false, message: opts.invalidMessage }; + } + if (parsed.kind === "empty") { + return { ok: true, action: opts.defaultAction ?? "show", args: "" }; + } + return { ok: true, action: parsed.action, args: parsed.args }; +} diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 431755561dc..1351296a2a1 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; import { @@ -13,14 +13,96 @@ import { updateSessionStore } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { resetBashChatCommandForTests } from "./bash-command.js"; +import { handleCompactCommand } from "./commands-compact.js"; +import { buildCommandsPaginationKeyboard } from "./commands-info.js"; +import { extractMessageText } from "./commands-subagents.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; +import { parseConfigCommand } from "./config-commands.js"; +import { parseDebugCommand } from "./debug-commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../config/config.js", async () => { + const actual = + await vi.importActual("../../config/config.js"); + return { + ...actual, + readConfigFileSnapshot: readConfigFileSnapshotMock, + validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, + writeConfigFile: writeConfigFileMock, + }; +}); + +const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); +const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); +const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../pairing/pairing-store.js", async () => { + const actual = await vi.importActual( + "../../pairing/pairing-store.js", + ); + return { + ...actual, + readChannelAllowFromStore: readChannelAllowFromStoreMock, + addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, + removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, + }; +}); + +vi.mock("../../channels/plugins/pairing.js", async () => { + const actual = await vi.importActual( + "../../channels/plugins/pairing.js", + ); + return { + ...actual, + listPairingChannels: () => ["telegram"], + }; +}); + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, + ]), +})); + +vi.mock("../../agents/pi-embedded.js", () => { + const resolveEmbeddedSessionLane = (key: string) => { + const cleaned = key.trim() || "main"; + return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`; + }; + return { + abortEmbeddedPiRun: vi.fn(), + compactEmbeddedPiSession: vi.fn(), + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane, + runEmbeddedPiAgent: vi.fn(), + waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("../../infra/system-events.js", () => ({ + enqueueSystemEvent: vi.fn(), +})); + +vi.mock("./session-updates.js", () => ({ + incrementCompactionCount: vi.fn(), +})); const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -import { handleCommands } from "./commands.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ @@ -104,6 +186,293 @@ describe("handleCommands gating", () => { }); }); +describe("/approve command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects invalid usage", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Usage: /approve"); + }); + + it("submits approval", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("rejects gateway clients without approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.write"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.approvals"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("allows gateway clients with approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.approvals"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("allows gateway clients with admin scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.admin"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); +}); + +describe("/compact command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when command is not /compact", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/status", cfg); + + const result = await handleCompactCommand( + { + ...params, + }, + true, + ); + + expect(result).toBeNull(); + expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + }); + + it("rejects unauthorized /compact commands", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/compact", cfg); + + const result = await handleCompactCommand( + { + ...params, + command: { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }, + }, + true, + ); + + expect(result).toEqual({ shouldContinue: false }); + expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + }); + + it("routes manual compaction with explicit trigger and context metadata", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: "/tmp/openclaw-session-store.json" }, + } as OpenClawConfig; + const params = buildParams("/compact: focus on decisions", cfg, { + From: "+15550001", + To: "+15550002", + }); + vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + ok: true, + compacted: false, + }); + + const result = await handleCompactCommand( + { + ...params, + sessionEntry: { + sessionId: "session-1", + groupId: "group-1", + groupChannel: "#general", + space: "workspace-1", + spawnedBy: "agent:main:parent", + totalTokens: 12345, + }, + }, + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:main:main", + trigger: "manual", + customInstructions: "focus on decisions", + messageChannel: "whatsapp", + groupId: "group-1", + groupChannel: "#general", + groupSpace: "workspace-1", + spawnedBy: "agent:main:parent", + }), + ); + }); +}); + +describe("buildCommandsPaginationKeyboard", () => { + it("adds agent id to callback data when provided", () => { + const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); + expect(keyboard[0]).toEqual([ + { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, + { text: "2/3", callback_data: "commands_page_noop:agent-main" }, + { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, + ]); + }); +}); + +describe("parseConfigCommand", () => { + it("parses show/unset", () => { + expect(parseConfigCommand("/config")).toEqual({ action: "show" }); + expect(parseConfigCommand("/config show")).toEqual({ + action: "show", + path: undefined, + }); + expect(parseConfigCommand("/config show foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config get foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config unset foo.bar")).toEqual({ + action: "unset", + path: "foo.bar", + }); + }); + + it("parses set with JSON", () => { + const cmd = parseConfigCommand('/config set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); +}); + +describe("parseDebugCommand", () => { + it("parses show/reset", () => { + expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); + }); + + it("parses set with JSON", () => { + const cmd = parseDebugCommand('/debug set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); + + it("parses unset", () => { + const cmd = parseDebugCommand("/debug unset foo.bar"); + expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); + }); +}); + +describe("extractMessageText", () => { + it("preserves user text that looks like tool call markers", () => { + const message = { + role: "user", + content: "Here [Tool Call: foo (ID: 1)] ok", + }; + const result = extractMessageText(message); + expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); + }); + + it("sanitizes assistant tool call markers", () => { + const message = { + role: "assistant", + content: "Here [Tool Call: foo (ID: 1)] ok", + }; + const result = extractMessageText(message); + expect(result?.text).toBe("Here ok"); + }); +}); + +describe("handleCommands /config configWrites gating", () => { + it("blocks /config set when channel config writes are disabled", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, + } as OpenClawConfig; + const params = buildParams('/config set messages.ackReaction=":)"', cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config writes are disabled"); + }); +}); + describe("handleCommands bash alias", () => { it("routes !poll through the /bash handler", async () => { resetBashChatCommandForTests(); @@ -130,6 +499,289 @@ describe("handleCommands bash alias", () => { }); }); +function buildPolicyParams( + commandBody: string, + cfg: OpenClawConfig, + ctxOverrides?: Partial, +) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "telegram", + Surface: "telegram", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "telegram", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("handleCommands /allowlist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists config + store allowFrom entries", async () => { + readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); + + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["123", "@Alice"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist list dm", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Channel: telegram"); + expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice"); + expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); + }); + + it("adds entries to config and pairing store", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { allowFrom: ["123"] } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { telegram: { allowFrom: ["123", "789"] } }, + }), + ); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + }); + expect(result.reply?.text).toContain("DM allowlist added"); + }); + + it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + slack: { + allowFrom: ["U111", "U222"], + dm: { allowFrom: ["U111", "U222"] }, + configWrites: true, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + + const cfg = { + commands: { text: true, config: true }, + channels: { + slack: { + allowFrom: ["U111", "U222"], + dm: { allowFrom: ["U111", "U222"] }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams("/allowlist remove dm U111", cfg, { + Provider: "slack", + Surface: "slack", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; + expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); + expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain("channels.slack.allowFrom"); + }); + + it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + discord: { + allowFrom: ["111", "222"], + dm: { allowFrom: ["111", "222"] }, + configWrites: true, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + + const cfg = { + commands: { text: true, config: true }, + channels: { + discord: { + allowFrom: ["111", "222"], + dm: { allowFrom: ["111", "222"] }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams("/allowlist remove dm 111", cfg, { + Provider: "discord", + Surface: "discord", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; + expect(written.channels?.discord?.allowFrom).toEqual(["222"]); + expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain("channels.discord.allowFrom"); + }); +}); + +describe("/models command", () => { + const cfg = { + commands: { text: true }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as unknown as OpenClawConfig; + + it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { + const params = buildPolicyParams("/models", cfg, { Provider: surface, Surface: surface }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Providers:"); + expect(result.reply?.text).toContain("anthropic"); + expect(result.reply?.text).toContain("Use: /models "); + }); + + it("lists providers on telegram (buttons)", async () => { + const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toBe("Select a provider:"); + const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) + ?.telegram?.buttons; + expect(buttons).toBeDefined(); + expect(buttons?.length).toBeGreaterThan(0); + }); + + it("lists provider models with pagination hints", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("page 1/"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).toContain("Switch: /model "); + expect(result.reply?.text).toContain("All: /models anthropic all"); + }); + + it("ignores page argument when all flag is present", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("page 1/1"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).not.toContain("Page out of range"); + }); + + it("errors on out-of-range pages", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Page out of range"); + expect(result.reply?.text).toContain("valid: 1-"); + }); + + it("handles unknown providers", async () => { + const params = buildPolicyParams("/models not-a-provider", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Unknown provider"); + expect(result.reply?.text).toContain("Available providers"); + }); + + it("lists configured models outside the curated catalog", async () => { + const customCfg = { + commands: { text: true }, + agents: { + defaults: { + model: { + primary: "localai/ultra-chat", + fallbacks: ["anthropic/claude-opus-4-5"], + }, + imageModel: "visionpro/studio-v1", + }, + }, + } as unknown as OpenClawConfig; + + // Use discord surface for text-based output tests + const providerList = await handleCommands( + buildPolicyParams("/models", customCfg, { Surface: "discord" }), + ); + expect(providerList.reply?.text).toContain("localai"); + expect(providerList.reply?.text).toContain("visionpro"); + + const result = await handleCommands( + buildPolicyParams("/models localai", customCfg, { Surface: "discord" }), + ); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (localai)"); + expect(result.reply?.text).toContain("localai/ultra-chat"); + expect(result.reply?.text).not.toContain("Unknown provider"); + }); +}); + describe("handleCommands plugin commands", () => { it("dispatches registered plugin commands", async () => { clearPluginCommands(); diff --git a/src/auto-reply/reply/config-commands.ts b/src/auto-reply/reply/config-commands.ts index b78baa45905..fc924985c58 100644 --- a/src/auto-reply/reply/config-commands.ts +++ b/src/auto-reply/reply/config-commands.ts @@ -1,4 +1,5 @@ -import { parseConfigValue } from "./config-value.js"; +import { parseSetUnsetCommand } from "./commands-setunset.js"; +import { parseSlashCommandOrNull } from "./commands-slash-parse.js"; export type ConfigCommand = | { action: "show"; path?: string } @@ -7,60 +8,31 @@ export type ConfigCommand = | { action: "error"; message: string }; export function parseConfigCommand(raw: string): ConfigCommand | null { - const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith("/config")) { + const parsed = parseSlashCommandOrNull(raw, "/config", { + invalidMessage: "Invalid /config syntax.", + }); + if (!parsed) { return null; } - const rest = trimmed.slice("/config".length).trim(); - if (!rest) { - return { action: "show" }; + if (!parsed.ok) { + return { action: "error", message: parsed.message }; } - - const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); - if (!match) { - return { action: "error", message: "Invalid /config syntax." }; - } - const action = match[1].toLowerCase(); - const args = (match[2] ?? "").trim(); + const { action, args } = parsed; switch (action) { case "show": return { action: "show", path: args || undefined }; case "get": return { action: "show", path: args || undefined }; - case "unset": { - if (!args) { - return { action: "error", message: "Usage: /config unset path" }; - } - return { action: "unset", path: args }; - } + case "unset": case "set": { - if (!args) { - return { - action: "error", - message: "Usage: /config set path=value", - }; + const parsed = parseSetUnsetCommand({ slash: "/config", action, args }); + if (parsed.kind === "error") { + return { action: "error", message: parsed.message }; } - const eqIndex = args.indexOf("="); - if (eqIndex <= 0) { - return { - action: "error", - message: "Usage: /config set path=value", - }; - } - const path = args.slice(0, eqIndex).trim(); - const rawValue = args.slice(eqIndex + 1); - if (!path) { - return { - action: "error", - message: "Usage: /config set path=value", - }; - } - const parsed = parseConfigValue(rawValue); - if (parsed.error) { - return { action: "error", message: parsed.error }; - } - return { action: "set", path, value: parsed.value }; + return parsed.kind === "set" + ? { action: "set", path: parsed.path, value: parsed.value } + : { action: "unset", path: parsed.path }; } default: return { diff --git a/src/auto-reply/reply/debug-commands.ts b/src/auto-reply/reply/debug-commands.ts index 5f9f8c9fd0e..089caf2a5e5 100644 --- a/src/auto-reply/reply/debug-commands.ts +++ b/src/auto-reply/reply/debug-commands.ts @@ -1,4 +1,5 @@ -import { parseConfigValue } from "./config-value.js"; +import { parseSetUnsetCommand } from "./commands-setunset.js"; +import { parseSlashCommandOrNull } from "./commands-slash-parse.js"; export type DebugCommand = | { action: "show" } @@ -8,60 +9,31 @@ export type DebugCommand = | { action: "error"; message: string }; export function parseDebugCommand(raw: string): DebugCommand | null { - const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith("/debug")) { + const parsed = parseSlashCommandOrNull(raw, "/debug", { + invalidMessage: "Invalid /debug syntax.", + }); + if (!parsed) { return null; } - const rest = trimmed.slice("/debug".length).trim(); - if (!rest) { - return { action: "show" }; + if (!parsed.ok) { + return { action: "error", message: parsed.message }; } - - const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); - if (!match) { - return { action: "error", message: "Invalid /debug syntax." }; - } - const action = match[1].toLowerCase(); - const args = (match[2] ?? "").trim(); + const { action, args } = parsed; switch (action) { case "show": return { action: "show" }; case "reset": return { action: "reset" }; - case "unset": { - if (!args) { - return { action: "error", message: "Usage: /debug unset path" }; - } - return { action: "unset", path: args }; - } + case "unset": case "set": { - if (!args) { - return { - action: "error", - message: "Usage: /debug set path=value", - }; + const parsed = parseSetUnsetCommand({ slash: "/debug", action, args }); + if (parsed.kind === "error") { + return { action: "error", message: parsed.message }; } - const eqIndex = args.indexOf("="); - if (eqIndex <= 0) { - return { - action: "error", - message: "Usage: /debug set path=value", - }; - } - const path = args.slice(0, eqIndex).trim(); - const rawValue = args.slice(eqIndex + 1); - if (!path) { - return { - action: "error", - message: "Usage: /debug set path=value", - }; - } - const parsed = parseConfigValue(rawValue); - if (parsed.error) { - return { action: "error", message: parsed.error }; - } - return { action: "set", path, value: parsed.value }; + return parsed.kind === "set" + ? { action: "set", path: parsed.path, value: parsed.value } + : { action: "unset", path: parsed.path }; } default: return { diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts index e83aa889dfc..fdea7c75e01 100644 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -1,7 +1,7 @@ import type { ReplyPayload } from "../types.js"; import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; -import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js"; import { handleDirectiveOnly } from "./directive-handling.impl.js"; +import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; import { isDirectiveOnly } from "./directive-handling.parse.js"; export async function applyInlineDirectivesFastLane( @@ -48,19 +48,12 @@ export async function applyInlineDirectivesFastLane( } const agentCfg = params.agentCfg; - const resolvedDefaultThinkLevel = - (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? - (await modelState.resolveDefaultThinkingLevel()); - const currentThinkLevel = resolvedDefaultThinkLevel; - const currentVerboseLevel = - (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? - (agentCfg?.verboseDefault as VerboseLevel | undefined); - const currentReasoningLevel = - (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; - const currentElevatedLevel = - (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? - (agentCfg?.elevatedDefault as ElevatedLevel | undefined); + const { currentThinkLevel, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel } = + await resolveCurrentDirectiveLevels({ + sessionEntry, + agentCfg, + resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), + }); const directiveAck = await handleDirectiveOnly({ cfg, diff --git a/src/auto-reply/reply/directive-handling.levels.ts b/src/auto-reply/reply/directive-handling.levels.ts new file mode 100644 index 00000000000..61f9aef1c79 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.levels.ts @@ -0,0 +1,41 @@ +import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; + +export async function resolveCurrentDirectiveLevels(params: { + sessionEntry?: { + thinkingLevel?: unknown; + verboseLevel?: unknown; + reasoningLevel?: unknown; + elevatedLevel?: unknown; + }; + agentCfg?: { + thinkingDefault?: unknown; + verboseDefault?: unknown; + elevatedDefault?: unknown; + }; + resolveDefaultThinkingLevel: () => Promise; +}): Promise<{ + currentThinkLevel: ThinkLevel | undefined; + currentVerboseLevel: VerboseLevel | undefined; + currentReasoningLevel: ReasoningLevel; + currentElevatedLevel: ElevatedLevel | undefined; +}> { + const resolvedDefaultThinkLevel = + (params.sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? + (params.agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? + (await params.resolveDefaultThinkingLevel()); + const currentThinkLevel = resolvedDefaultThinkLevel; + const currentVerboseLevel = + (params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? + (params.agentCfg?.verboseDefault as VerboseLevel | undefined); + const currentReasoningLevel = + (params.sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; + const currentElevatedLevel = + (params.sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? + (params.agentCfg?.elevatedDefault as ElevatedLevel | undefined); + return { + currentThinkLevel, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + }; +} diff --git a/src/auto-reply/reply/formatting.test.ts b/src/auto-reply/reply/formatting.test.ts deleted file mode 100644 index e6fb0689881..00000000000 --- a/src/auto-reply/reply/formatting.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseAudioTag } from "./audio-tags.js"; -import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; -import { createReplyReferencePlanner } from "./reply-reference.js"; -import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; - -describe("parseAudioTag", () => { - it("detects audio_as_voice and strips the tag", () => { - const result = parseAudioTag("Hello [[audio_as_voice]] world"); - expect(result.audioAsVoice).toBe(true); - expect(result.hadTag).toBe(true); - expect(result.text).toBe("Hello world"); - }); - - it("returns empty output for missing text", () => { - const result = parseAudioTag(undefined); - expect(result.audioAsVoice).toBe(false); - expect(result.hadTag).toBe(false); - expect(result.text).toBe(""); - }); - - it("removes tag-only messages", () => { - const result = parseAudioTag("[[audio_as_voice]]"); - expect(result.audioAsVoice).toBe(true); - expect(result.text).toBe(""); - }); -}); - -describe("block reply coalescer", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("coalesces chunks within the idle window", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "Hello" }); - coalescer.enqueue({ text: "world" }); - - await vi.advanceTimersByTimeAsync(100); - expect(flushes).toEqual(["Hello world"]); - coalescer.stop(); - }); - - it("waits until minChars before idle flush", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "short" }); - await vi.advanceTimersByTimeAsync(50); - expect(flushes).toEqual([]); - - coalescer.enqueue({ text: "message" }); - await vi.advanceTimersByTimeAsync(50); - expect(flushes).toEqual(["short message"]); - coalescer.stop(); - }); - - it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - coalescer.enqueue({ text: "Third paragraph" }); - - await Promise.resolve(); - expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); - coalescer.stop(); - }); - - it("still accumulates when flushOnEnqueue is not set (default)", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - - await vi.advanceTimersByTimeAsync(100); - expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); - coalescer.stop(); - }); - - it("flushes short payloads immediately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "Hi" }); - await Promise.resolve(); - expect(flushes).toEqual(["Hi"]); - coalescer.stop(); - }); - - it("resets char budget per paragraph with flushOnEnqueue", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - // Each 20-char payload fits within maxChars=30 individually - coalescer.enqueue({ text: "12345678901234567890" }); - coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); - - await Promise.resolve(); - // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. - // With flushOnEnqueue, each is sent independently within budget. - expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); - coalescer.stop(); - }); - - it("flushes buffered text before media payloads", () => { - const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push({ - text: payload.text, - mediaUrls: payload.mediaUrls, - }); - }, - }); - - coalescer.enqueue({ text: "Hello" }); - coalescer.enqueue({ text: "world" }); - coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); - void coalescer.flush({ force: true }); - - expect(flushes[0].text).toBe("Hello world"); - expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); - coalescer.stop(); - }); -}); - -describe("createReplyReferencePlanner", () => { - it("disables references when mode is off", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - startId: "parent", - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - }); - - it("uses startId once when mode is first", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "first", - startId: "parent", - }); - expect(planner.use()).toBe("parent"); - expect(planner.hasReplied()).toBe(true); - planner.markSent(); - expect(planner.use()).toBeUndefined(); - }); - - it("returns startId for every call when mode is all", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - startId: "parent", - }); - expect(planner.use()).toBe("parent"); - expect(planner.use()).toBe("parent"); - }); - - it("respects replyToMode off even with existingId", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - }); - - it("uses existingId once when mode is first", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "first", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.hasReplied()).toBe(true); - expect(planner.use()).toBeUndefined(); - }); - - it("uses existingId on every call when mode is all", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.use()).toBe("thread-1"); - }); - - it("honors allowReference=false", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - startId: "parent", - allowReference: false, - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - planner.markSent(); - expect(planner.hasReplied()).toBe(true); - }); -}); - -describe("createStreamingDirectiveAccumulator", () => { - it("stashes reply_to_current until a renderable chunk arrives", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); - - const result = accumulator.consume("Hello"); - expect(result?.text).toBe("Hello"); - expect(result?.replyToCurrent).toBe(true); - expect(result?.replyToTag).toBe(true); - }); - - it("handles reply tags split across chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to_")).toBeNull(); - - const result = accumulator.consume("current]] Yo"); - expect(result?.text).toBe("Yo"); - expect(result?.replyToCurrent).toBe(true); - expect(result?.replyToTag).toBe(true); - }); - - it("propagates explicit reply ids across chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); - - const result = accumulator.consume("Hi"); - expect(result?.text).toBe("Hi"); - expect(result?.replyToId).toBe("abc-123"); - expect(result?.replyToTag).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 0068aed5415..b6ecc4c8300 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { MsgContext } from "../templating.js"; -import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; +import type { ElevatedLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import type { createModelSelectionState } from "./model-selection.js"; import type { TypingController } from "./typing.js"; @@ -13,6 +13,7 @@ import { isDirectiveOnly, persistInlineDirectives, } from "./directive-handling.js"; +import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; type AgentDefaults = NonNullable["defaults"]; @@ -122,19 +123,17 @@ export async function applyInlineDirectiveOverrides(params: { typing.cleanup(); return { kind: "reply", reply: undefined }; } - const resolvedDefaultThinkLevel = - (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? - (await modelState.resolveDefaultThinkingLevel()); + const { + currentThinkLevel: resolvedDefaultThinkLevel, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + } = await resolveCurrentDirectiveLevels({ + sessionEntry, + agentCfg, + resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), + }); const currentThinkLevel = resolvedDefaultThinkLevel; - const currentVerboseLevel = - (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? - (agentCfg?.verboseDefault as VerboseLevel | undefined); - const currentReasoningLevel = - (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; - const currentElevatedLevel = - (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? - (agentCfg?.elevatedDefault as ElevatedLevel | undefined); const directiveReply = await handleDirectiveOnly({ cfg, directives, diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts deleted file mode 100644 index 7991731daf6..00000000000 --- a/src/auto-reply/reply/history.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - appendHistoryEntry, - buildHistoryContext, - buildHistoryContextFromEntries, - buildHistoryContextFromMap, - buildPendingHistoryContextFromMap, - clearHistoryEntriesIfEnabled, - HISTORY_CONTEXT_MARKER, - recordPendingHistoryEntryIfEnabled, -} from "./history.js"; -import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; - -describe("history helpers", () => { - it("returns current message when history is empty", () => { - const result = buildHistoryContext({ - historyText: " ", - currentMessage: "hello", - }); - expect(result).toBe("hello"); - }); - - it("wraps history entries and excludes current by default", () => { - const result = buildHistoryContextFromEntries({ - entries: [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ], - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).not.toContain("B: two"); - expect(result).toContain(CURRENT_MESSAGE_MARKER); - expect(result).toContain("current"); - }); - - it("trims history to configured limit", () => { - const historyMap = new Map(); - - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "A", body: "one" }, - }); - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "B", body: "two" }, - }); - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "C", body: "three" }, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]); - }); - - it("builds context from map and appends entry", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - const result = buildHistoryContextFromMap({ - historyMap, - historyKey: "group", - limit: 3, - entry: { sender: "C", body: "three" }, - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).toContain("B: two"); - expect(result).not.toContain("C: three"); - }); - - it("builds context from pending map without appending", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - const result = buildPendingHistoryContextFromMap({ - historyMap, - historyKey: "group", - limit: 3, - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).toContain("B: two"); - expect(result).toContain(CURRENT_MESSAGE_MARKER); - expect(result).toContain("current"); - }); - - it("records pending entries only when enabled", () => { - const historyMap = new Map(); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 0, - entry: { sender: "A", body: "one" }, - }); - expect(historyMap.get("group")).toEqual(undefined); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 2, - entry: null, - }); - expect(historyMap.get("group")).toEqual(undefined); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "B", body: "two" }, - }); - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); - }); - - it("clears history entries only when enabled", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); - - clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); - expect(historyMap.get("group")).toEqual([]); - }); -}); diff --git a/src/auto-reply/reply/inbound-context.providers-contract.test.ts b/src/auto-reply/reply/inbound-context.providers-contract.test.ts deleted file mode 100644 index a75b2996c30..00000000000 --- a/src/auto-reply/reply/inbound-context.providers-contract.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it } from "vitest"; -import type { MsgContext } from "../templating.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; -import { finalizeInboundContext } from "./inbound-context.js"; - -describe("inbound context contract (providers + extensions)", () => { - const cases: Array<{ name: string; ctx: MsgContext }> = [ - { - name: "whatsapp group", - ctx: { - Provider: "whatsapp", - Surface: "whatsapp", - ChatType: "group", - From: "123@g.us", - To: "+15550001111", - Body: "[WhatsApp 123@g.us] hi", - RawBody: "hi", - CommandBody: "hi", - SenderName: "Alice", - }, - }, - { - name: "telegram group", - ctx: { - Provider: "telegram", - Surface: "telegram", - ChatType: "group", - From: "group:123", - To: "telegram:123", - Body: "[Telegram group:123] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Telegram Group", - SenderName: "Alice", - }, - }, - { - name: "slack channel", - ctx: { - Provider: "slack", - Surface: "slack", - ChatType: "channel", - From: "slack:channel:C123", - To: "channel:C123", - Body: "[Slack #general] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "discord channel", - ctx: { - Provider: "discord", - Surface: "discord", - ChatType: "channel", - From: "group:123", - To: "channel:123", - Body: "[Discord #general] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "signal dm", - ctx: { - Provider: "signal", - Surface: "signal", - ChatType: "direct", - From: "signal:+15550001111", - To: "signal:+15550002222", - Body: "[Signal] hi", - RawBody: "hi", - CommandBody: "hi", - }, - }, - { - name: "imessage group", - ctx: { - Provider: "imessage", - Surface: "imessage", - ChatType: "group", - From: "group:chat_id:123", - To: "chat_id:123", - Body: "[iMessage Group] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "iMessage Group", - SenderName: "Alice", - }, - }, - { - name: "matrix channel", - ctx: { - Provider: "matrix", - Surface: "matrix", - ChatType: "channel", - From: "matrix:channel:!room:example.org", - To: "room:!room:example.org", - Body: "[Matrix] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "msteams channel", - ctx: { - Provider: "msteams", - Surface: "msteams", - ChatType: "channel", - From: "msteams:channel:19:abc@thread.tacv2", - To: "msteams:channel:19:abc@thread.tacv2", - Body: "[Teams] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Teams Channel", - SenderName: "Alice", - }, - }, - { - name: "zalo dm", - ctx: { - Provider: "zalo", - Surface: "zalo", - ChatType: "direct", - From: "zalo:123", - To: "zalo:123", - Body: "[Zalo] hi", - RawBody: "hi", - CommandBody: "hi", - }, - }, - { - name: "zalouser group", - ctx: { - Provider: "zalouser", - Surface: "zalouser", - ChatType: "group", - From: "group:123", - To: "zalouser:123", - Body: "[Zalo Personal] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Zalouser Group", - SenderName: "Alice", - }, - }, - ]; - - for (const entry of cases) { - it(entry.name, () => { - const ctx = finalizeInboundContext({ ...entry.ctx }); - expectInboundContextContract(ctx); - }); - } -}); diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts deleted file mode 100644 index f358aebc794..00000000000 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TemplateContext } from "../templating.js"; -import { buildInboundUserContextPrefix } from "./inbound-meta.js"; - -describe("buildInboundUserContextPrefix", () => { - it("omits conversation label block for direct chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "direct", - ConversationLabel: "openclaw-tui", - } as TemplateContext); - - expect(text).toBe(""); - }); - - it("keeps conversation label for group chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "group", - ConversationLabel: "ops-room", - } as TemplateContext); - - expect(text).toContain("Conversation info (untrusted metadata):"); - expect(text).toContain('"conversation_label": "ops-room"'); - }); -}); diff --git a/src/auto-reply/reply/inbound-text.test.ts b/src/auto-reply/reply/inbound-text.test.ts deleted file mode 100644 index 2b54a71299a..00000000000 --- a/src/auto-reply/reply/inbound-text.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeInboundTextNewlines } from "./inbound-text.js"; - -describe("normalizeInboundTextNewlines", () => { - it("converts CRLF to LF", () => { - expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); - }); - - it("converts CR to LF", () => { - expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); - }); - - it("preserves literal backslash-n sequences in Windows paths", () => { - // Windows paths like C:\Work\nxxx should NOT have \n converted to newlines - const windowsPath = "C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); - }); - - it("preserves backslash-n in messages containing Windows paths", () => { - const message = "Please read the file at C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(message)).toBe( - "Please read the file at C:\\Work\\nxxx\\README.md", - ); - }); - - it("preserves multiple backslash-n sequences", () => { - const message = "C:\\new\\notes\\nested"; - expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); - }); - - it("still normalizes actual CRLF while preserving backslash-n", () => { - const message = "Line 1\r\nC:\\Work\\nxxx"; - expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); - }); -}); diff --git a/src/auto-reply/reply/line-directives.test.ts b/src/auto-reply/reply/line-directives.test.ts deleted file mode 100644 index bf60232b854..00000000000 --- a/src/auto-reply/reply/line-directives.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; - -const getLineData = (result: ReturnType) => - (result.channelData?.line as Record | undefined) ?? {}; - -describe("hasLineDirectives", () => { - it("detects quick_replies directive", () => { - expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); - }); - - it("detects location directive", () => { - expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); - }); - - it("detects confirm directive", () => { - expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); - }); - - it("detects buttons directive", () => { - expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); - }); - - it("returns false for regular text", () => { - expect(hasLineDirectives("Just regular text")).toBe(false); - }); - - it("returns false for similar but invalid patterns", () => { - expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); - }); - - it("detects media_player directive", () => { - expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); - }); - - it("detects event directive", () => { - expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); - }); - - it("detects agenda directive", () => { - expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); - }); - - it("detects device directive", () => { - expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); - }); - - it("detects appletv_remote directive", () => { - expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); - }); -}); - -describe("parseLineDirectives", () => { - describe("quick_replies", () => { - it("parses quick_replies and removes from text", () => { - const result = parseLineDirectives({ - text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", - }); - - expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); - expect(result.text).toBe("Choose one:"); - }); - - it("handles quick_replies in middle of text", () => { - const result = parseLineDirectives({ - text: "Before [[quick_replies: A, B]] After", - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - expect(result.text).toBe("Before After"); - }); - - it("merges with existing quickReplies", () => { - const result = parseLineDirectives({ - text: "Text [[quick_replies: C, D]]", - channelData: { line: { quickReplies: ["A", "B"] } }, - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); - }); - }); - - describe("location", () => { - it("parses location with all fields", () => { - const result = parseLineDirectives({ - text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", - }); - - expect(getLineData(result).location).toEqual({ - title: "Tokyo Station", - address: "Tokyo, Japan", - latitude: 35.6812, - longitude: 139.7671, - }); - expect(result.text).toBe("Here's the location:"); - }); - - it("ignores invalid coordinates", () => { - const result = parseLineDirectives({ - text: "[[location: Place | Address | invalid | 139.7]]", - }); - - expect(getLineData(result).location).toBeUndefined(); - }); - - it("does not override existing location", () => { - const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; - const result = parseLineDirectives({ - text: "[[location: New | New Addr | 35.6 | 139.7]]", - channelData: { line: { location: existing } }, - }); - - expect(getLineData(result).location).toEqual(existing); - }); - }); - - describe("confirm", () => { - it("parses simple confirm", () => { - const result = parseLineDirectives({ - text: "[[confirm: Delete this item? | Yes | No]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Delete this item?", - confirmLabel: "Yes", - confirmData: "yes", - cancelLabel: "No", - cancelData: "no", - altText: "Delete this item?", - }); - // Text is undefined when directive consumes entire text - expect(result.text).toBeUndefined(); - }); - - it("parses confirm with custom data", () => { - const result = parseLineDirectives({ - text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Proceed?", - confirmLabel: "OK", - confirmData: "action=confirm", - cancelLabel: "Cancel", - cancelData: "action=cancel", - altText: "Proceed?", - }); - }); - }); - - describe("buttons", () => { - it("parses buttons with message actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "buttons", - title: "Menu", - text: "Select an option", - actions: [ - { type: "message", label: "Help", data: "/help" }, - { type: "message", label: "Status", data: "/status" }, - ], - altText: "Menu: Select an option", - }); - }); - - it("parses buttons with uri actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Links | Visit us | Site:https://example.com]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "uri", - label: "Site", - uri: "https://example.com", - }); - } - }); - - it("parses buttons with postback actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "postback", - label: "Select", - data: "action=select&id=1", - }); - } - }); - - it("limits to 4 actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.length).toBe(4); - } - }); - }); - - describe("media_player", () => { - it("parses media_player with all fields", () => { - const result = parseLineDirectives({ - text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - altText?: string; - contents?: { footer?: { contents?: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen"); - const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; - expect(contents.footer?.contents?.length).toBeGreaterThan(0); - expect(result.text).toBe("Now playing:"); - }); - - it("parses media_player with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[media_player: Unknown Track]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎵 Unknown Track"); - }); - - it("handles paused status", () => { - const result = parseLineDirectives({ - text: "[[media_player: Song | Artist | Player | | paused]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - contents?: { body: { contents: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - const contents = flexMessage?.contents as { body: { contents: unknown[] } }; - expect(contents).toBeDefined(); - }); - }); - - describe("event", () => { - it("parses event with all fields", () => { - const result = parseLineDirectives({ - text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); - }); - - it("parses event with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[event: Birthday Party | March 15]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15"); - }); - }); - - describe("agenda", () => { - it("parses agenda with multiple events", () => { - const result = parseLineDirectives({ - text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)"); - }); - - it("parses agenda with events without times", () => { - const result = parseLineDirectives({ - text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📋 Tasks (3 events)"); - }); - }); - - describe("device", () => { - it("parses device with controls", () => { - const result = parseLineDirectives({ - text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📱 TV: Playing"); - }); - - it("parses device with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[device: Speaker]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📱 Speaker"); - }); - }); - - describe("appletv_remote", () => { - it("parses appletv_remote with status", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV | Playing]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toContain("Apple TV"); - }); - - it("parses appletv_remote with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - }); - }); - - describe("combined directives", () => { - it("handles text with no directives", () => { - const result = parseLineDirectives({ - text: "Just plain text here", - }); - - expect(result.text).toBe("Just plain text here"); - expect(getLineData(result).quickReplies).toBeUndefined(); - expect(getLineData(result).location).toBeUndefined(); - expect(getLineData(result).templateMessage).toBeUndefined(); - }); - - it("preserves other payload fields", () => { - const result = parseLineDirectives({ - text: "Hello [[quick_replies: A, B]]", - mediaUrl: "https://example.com/image.jpg", - replyToId: "msg123", - }); - - expect(result.mediaUrl).toBe("https://example.com/image.jpg"); - expect(result.replyToId).toBe("msg123"); - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - }); - }); -}); diff --git a/src/auto-reply/reply/memory-flush.test.ts b/src/auto-reply/reply/memory-flush.test.ts deleted file mode 100644 index e3dcc124e18..00000000000 --- a/src/auto-reply/reply/memory-flush.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - resolveMemoryFlushContextWindowTokens, - resolveMemoryFlushSettings, - shouldRunMemoryFlush, -} from "./memory-flush.js"; - -describe("memory flush settings", () => { - it("defaults to enabled with fallback prompt and system prompt", () => { - const settings = resolveMemoryFlushSettings(); - expect(settings).not.toBeNull(); - expect(settings?.enabled).toBe(true); - expect(settings?.prompt.length).toBeGreaterThan(0); - expect(settings?.systemPrompt.length).toBeGreaterThan(0); - }); - - it("respects disable flag", () => { - expect( - resolveMemoryFlushSettings({ - agents: { - defaults: { compaction: { memoryFlush: { enabled: false } } }, - }, - }), - ).toBeNull(); - }); - - it("appends NO_REPLY hint when missing", () => { - const settings = resolveMemoryFlushSettings({ - agents: { - defaults: { - compaction: { - memoryFlush: { - prompt: "Write memories now.", - systemPrompt: "Flush memory.", - }, - }, - }, - }, - }); - expect(settings?.prompt).toContain("NO_REPLY"); - expect(settings?.systemPrompt).toContain("NO_REPLY"); - }); -}); - -describe("shouldRunMemoryFlush", () => { - it("requires totalTokens and threshold", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 0 }, - contextWindowTokens: 16_000, - reserveTokensFloor: 20_000, - softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - }), - ).toBe(false); - }); - - it("skips when entry is missing", () => { - expect( - shouldRunMemoryFlush({ - entry: undefined, - contextWindowTokens: 16_000, - reserveTokensFloor: 1_000, - softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - }), - ).toBe(false); - }); - - it("skips when under threshold", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 10_000 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 20_000, - softThresholdTokens: 10_000, - }), - ).toBe(false); - }); - - it("triggers at the threshold boundary", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 85 }, - contextWindowTokens: 100, - reserveTokensFloor: 10, - softThresholdTokens: 5, - }), - ).toBe(true); - }); - - it("skips when already flushed for current compaction count", () => { - expect( - shouldRunMemoryFlush({ - entry: { - totalTokens: 90_000, - compactionCount: 2, - memoryFlushCompactionCount: 2, - }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(false); - }); - - it("runs when above threshold and not flushed", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, compactionCount: 1 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(true); - }); - - it("ignores stale cached totals", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(false); - }); -}); - -describe("resolveMemoryFlushContextWindowTokens", () => { - it("falls back to agent config or default tokens", () => { - expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000); - }); -}); diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts deleted file mode 100644 index 8b700d23b1f..00000000000 --- a/src/auto-reply/reply/mentions.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { matchesMentionWithExplicit } from "./mentions.js"; - -describe("matchesMentionWithExplicit", () => { - const mentionRegexes = [/\bopenclaw\b/i]; - - it("checks mentionPatterns even when explicit mention is available", () => { - const result = matchesMentionWithExplicit({ - text: "@openclaw hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, - }, - }); - expect(result).toBe(true); - }); - - it("returns false when explicit is false and no regex match", () => { - const result = matchesMentionWithExplicit({ - text: "<@999999> hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, - }, - }); - expect(result).toBe(false); - }); - - it("returns true when explicitly mentioned even if regexes do not match", () => { - const result = matchesMentionWithExplicit({ - text: "<@123456>", - mentionRegexes: [], - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: true, - canResolveExplicit: true, - }, - }); - expect(result).toBe(true); - }); - - it("falls back to regex matching when explicit mention cannot be resolved", () => { - const result = matchesMentionWithExplicit({ - text: "openclaw please", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: false, - }, - }); - expect(result).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/model-selection.override-respected.test.ts b/src/auto-reply/reply/model-selection.override-respected.test.ts deleted file mode 100644 index b3457fc5596..00000000000 --- a/src/auto-reply/reply/model-selection.override-respected.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createModelSelectionState } from "./model-selection.js"; - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, - { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, - ]), -})); - -const defaultProvider = "inferencer"; -const defaultModel = "deepseek-v3-4bit-mlx"; - -const makeEntry = (overrides: Record = {}) => ({ - sessionId: "session-id", - updatedAt: Date.now(), - ...overrides, -}); - -describe("createModelSelectionState respects session model override", () => { - it("applies session modelOverride when set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - providerOverride: "kimi-coding", - modelOverride: "k2p5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe("kimi-coding"); - expect(state.model).toBe("k2p5"); - }); - - it("falls back to default when no modelOverride is set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry(); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe(defaultProvider); - expect(state.model).toBe(defaultModel); - }); - - it("respects modelOverride even when session model field differs", async () => { - // This tests the scenario from issue #14783: user switches model via /model, - // the override is stored, but session.model still reflects the last-used - // fallback model. The override should take precedence. - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - // Last-used model (from fallback) - should NOT be used for selection - model: "k2p5", - modelProvider: "kimi-coding", - contextTokens: 262_000, - // User's explicit override - SHOULD be used - providerOverride: "anthropic", - modelOverride: "claude-opus-4-5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - // Should use the override, not the last-used model - expect(state.provider).toBe("anthropic"); - expect(state.model).toBe("claude-opus-4-5"); - }); - - it("uses default provider when providerOverride is not set but modelOverride is", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - modelOverride: "deepseek-v3-4bit-mlx", - // no providerOverride - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe(defaultProvider); - expect(state.model).toBe("deepseek-v3-4bit-mlx"); - }); -}); diff --git a/src/auto-reply/reply/model-selection.inherit-parent.test.ts b/src/auto-reply/reply/model-selection.test.ts similarity index 56% rename from src/auto-reply/reply/model-selection.inherit-parent.test.ts rename to src/auto-reply/reply/model-selection.test.ts index e80088b42a0..3da30c3c6da 100644 --- a/src/auto-reply/reply/model-selection.inherit-parent.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -4,44 +4,46 @@ import { createModelSelectionState } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, + { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, + { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, ]), })); -const defaultProvider = "openai"; -const defaultModel = "gpt-4o-mini"; - const makeEntry = (overrides: Record = {}) => ({ sessionId: "session-id", updatedAt: Date.now(), ...overrides, }); -async function resolveState(params: { - cfg: OpenClawConfig; - sessionEntry: ReturnType; - sessionStore: Record>; - sessionKey: string; - parentSessionKey?: string; -}) { - return createModelSelectionState({ - cfg: params.cfg, - agentCfg: params.cfg.agents?.defaults, - sessionEntry: params.sessionEntry, - sessionStore: params.sessionStore, - sessionKey: params.sessionKey, - parentSessionKey: params.parentSessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); -} - describe("createModelSelectionState parent inheritance", () => { + const defaultProvider = "openai"; + const defaultModel = "gpt-4o-mini"; + + async function resolveState(params: { + cfg: OpenClawConfig; + sessionEntry: ReturnType; + sessionStore: Record>; + sessionKey: string; + parentSessionKey?: string; + }) { + return createModelSelectionState({ + cfg: params.cfg, + agentCfg: params.cfg.agents?.defaults, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + parentSessionKey: params.parentSessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + } + it("inherits parent override from explicit parentSessionKey", async () => { const cfg = {} as OpenClawConfig; const parentKey = "agent:main:discord:channel:c1"; @@ -212,3 +214,112 @@ describe("createModelSelectionState parent inheritance", () => { expect(state.model).toBe("claude-opus-4-5"); }); }); + +describe("createModelSelectionState respects session model override", () => { + const defaultProvider = "inferencer"; + const defaultModel = "deepseek-v3-4bit-mlx"; + + it("applies session modelOverride when set", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + providerOverride: "kimi-coding", + modelOverride: "k2p5", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe("kimi-coding"); + expect(state.model).toBe("k2p5"); + }); + + it("falls back to default when no modelOverride is set", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe(defaultModel); + }); + + it("respects modelOverride even when session model field differs", async () => { + // From issue #14783: stored override should beat last-used fallback model. + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + model: "k2p5", + modelProvider: "kimi-coding", + contextTokens: 262_000, + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe("anthropic"); + expect(state.model).toBe("claude-opus-4-5"); + }); + + it("uses default provider when providerOverride is not set but modelOverride is", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + modelOverride: "deepseek-v3-4bit-mlx", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe("deepseek-v3-4bit-mlx"); + }); +}); diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts deleted file mode 100644 index 26866892669..00000000000 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SILENT_REPLY_TOKEN } from "../tokens.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; - -// Keep channelData-only payloads so channel-specific replies survive normalization. -describe("normalizeReplyPayload", () => { - it("keeps channelData-only replies", () => { - const payload = { - channelData: { - line: { - flexMessage: { type: "bubble" }, - }, - }, - }; - - const normalized = normalizeReplyPayload(payload); - - expect(normalized).not.toBeNull(); - expect(normalized?.text).toBeUndefined(); - expect(normalized?.channelData).toEqual(payload.channelData); - }); - - it("records silent skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: SILENT_REPLY_TOKEN }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["silent"]); - }); - - it("records empty skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: " " }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["empty"]); - }); -}); diff --git a/src/auto-reply/reply/queue.collect-routing.test.ts b/src/auto-reply/reply/queue.collect-routing.test.ts deleted file mode 100644 index e1afe6eab67..00000000000 --- a/src/auto-reply/reply/queue.collect-routing.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { defaultRuntime } from "../../runtime.js"; -import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -let previousRuntimeError: typeof defaultRuntime.error; - -beforeAll(() => { - previousRuntimeError = defaultRuntime.error; - defaultRuntime.error = undefined; -}); - -afterAll(() => { - defaultRuntime.error = previousRuntimeError; -}); - -const COLLECT_SETTINGS: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", -}; - -function createRun(params: { - prompt: string; - messageId?: string; - originatingChannel?: FollowupRun["originatingChannel"]; - originatingTo?: string; - originatingAccountId?: string; - originatingThreadId?: string | number; -}): FollowupRun { - return { - prompt: params.prompt, - messageId: params.messageId, - enqueuedAt: Date.now(), - originatingChannel: params.originatingChannel, - originatingTo: params.originatingTo, - originatingAccountId: params.originatingAccountId, - originatingThreadId: params.originatingThreadId, - run: { - agentId: "agent", - agentDir: "/tmp", - sessionId: "sess", - sessionFile: "/tmp/session.json", - workspaceDir: "/tmp", - config: {} as OpenClawConfig, - provider: "openai", - model: "gpt-test", - timeoutMs: 10_000, - blockReplyBreak: "text_end", - }, - }; -} - -function createHarness(params: { - expectedCalls: number; - runFollowup?: ( - run: FollowupRun, - ctx: { - calls: FollowupRun[]; - done: ReturnType>; - expectedCalls: number; - }, - ) => Promise; -}) { - const calls: FollowupRun[] = []; - const done = createDeferred(); - const expectedCalls = params.expectedCalls; - const runFollowup = async (run: FollowupRun) => { - if (params.runFollowup) { - await params.runFollowup(run, { calls, done, expectedCalls }); - return; - } - calls.push(run); - if (calls.length >= expectedCalls) { - done.resolve(); - } - }; - return { calls, done, runFollowup, expectedCalls }; -} - -describe("followup queue deduplication", () => { - it("deduplicates messages with same Discord message_id", async () => { - const key = `test-dedup-message-id-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 1 }); - - // First enqueue should succeed - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] Hello", - messageId: "m1", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - COLLECT_SETTINGS, - ); - expect(first).toBe(true); - - // Second enqueue with same message id should be deduplicated - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] Hello (dupe)", - messageId: "m1", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - COLLECT_SETTINGS, - ); - expect(second).toBe(false); - - // Third enqueue with different message id should succeed - const third = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] World", - messageId: "m2", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - COLLECT_SETTINGS, - ); - expect(third).toBe(true); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - // Should collect both unique messages - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - }); - - it("deduplicates exact prompt when routing matches and no message id", async () => { - const key = `test-dedup-whatsapp-${Date.now()}`; - const settings = COLLECT_SETTINGS; - - // First enqueue should succeed - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(first).toBe(true); - - // Second enqueue with same prompt should be allowed (default dedupe: message id only) - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(second).toBe(true); - - // Third enqueue with different prompt should succeed - const third = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world 2", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(third).toBe(true); - }); - - it("does not deduplicate across different providers without message id", async () => { - const key = `test-dedup-cross-provider-${Date.now()}`; - const settings = COLLECT_SETTINGS; - - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Same text", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(first).toBe(true); - - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Same text", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - settings, - ); - expect(second).toBe(true); - }); - - it("can opt-in to prompt-based dedupe when message id is absent", async () => { - const key = `test-dedup-prompt-mode-${Date.now()}`; - const settings = COLLECT_SETTINGS; - - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - "prompt", - ); - expect(first).toBe(true); - - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - "prompt", - ); - expect(second).toBe(false); - }); -}); - -describe("followup queue collect routing", () => { - it("does not collect when destinations differ", async () => { - const key = `test-collect-diff-to-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 2 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:B", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toBe("one"); - expect(calls[1]?.prompt).toBe("two"); - }); - - it("collects when channel+destination match", async () => { - const key = `test-collect-same-to-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 1 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - expect(calls[0]?.originatingChannel).toBe("slack"); - expect(calls[0]?.originatingTo).toBe("channel:A"); - }); - - it("collects Slack messages in same thread and preserves string thread id", async () => { - const key = `test-collect-slack-thread-same-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 1 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); - }); - - it("does not collect Slack messages when thread ids differ", async () => { - const key = `test-collect-slack-thread-diff-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 2 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000002", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toBe("one"); - expect(calls[1]?.prompt).toBe("two"); - expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); - expect(calls[1]?.originatingThreadId).toBe("1706000000.000002"); - }); - - it("retries collect-mode batches without losing queued items", async () => { - const key = `test-collect-retry-${Date.now()}`; - let attempt = 0; - const { calls, done, runFollowup } = createHarness({ - expectedCalls: 1, - runFollowup: async (run, ctx) => { - attempt += 1; - if (attempt === 1) { - throw new Error("transient failure"); - } - ctx.calls.push(run); - if (ctx.calls.length >= ctx.expectedCalls) { - ctx.done.resolve(); - } - }, - }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun(key, createRun({ prompt: "one" }), settings); - enqueueFollowupRun(key, createRun({ prompt: "two" }), settings); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("Queued #1\none"); - expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); - }); - - it("retries overflow summary delivery without losing dropped previews", async () => { - const key = `test-overflow-summary-retry-${Date.now()}`; - let attempt = 0; - const { calls, done, runFollowup } = createHarness({ - expectedCalls: 1, - runFollowup: async (run, ctx) => { - attempt += 1; - if (attempt === 1) { - throw new Error("transient failure"); - } - ctx.calls.push(run); - if (ctx.calls.length >= ctx.expectedCalls) { - ctx.done.resolve(); - } - }, - }); - const settings: QueueSettings = { - mode: "followup", - debounceMs: 0, - cap: 1, - dropPolicy: "summarize", - }; - - enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); - enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); - expect(calls[0]?.prompt).toContain("- first"); - }); -}); diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts new file mode 100644 index 00000000000..c314997929f --- /dev/null +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -0,0 +1,1317 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { MsgContext, TemplateContext } from "../templating.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { defaultRuntime } from "../../runtime.js"; +import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { finalizeInboundContext } from "./inbound-context.js"; +import { buildInboundUserContextPrefix } from "./inbound-meta.js"; +import { normalizeInboundTextNewlines } from "./inbound-text.js"; +import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; +import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; +import { createReplyDispatcher } from "./reply-dispatcher.js"; +import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; + +describe("buildInboundUserContextPrefix", () => { + it("omits conversation label block for direct chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + ConversationLabel: "openclaw-tui", + } as TemplateContext); + + expect(text).toBe(""); + }); + + it("keeps conversation label for group chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + ConversationLabel: "ops-room", + } as TemplateContext); + + expect(text).toContain("Conversation info (untrusted metadata):"); + expect(text).toContain('"conversation_label": "ops-room"'); + }); +}); + +describe("normalizeInboundTextNewlines", () => { + it("converts CRLF to LF", () => { + expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); + }); + + it("converts CR to LF", () => { + expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); + }); + + it("preserves literal backslash-n sequences in Windows paths", () => { + const windowsPath = "C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); + }); + + it("preserves backslash-n in messages containing Windows paths", () => { + const message = "Please read the file at C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(message)).toBe( + "Please read the file at C:\\Work\\nxxx\\README.md", + ); + }); + + it("preserves multiple backslash-n sequences", () => { + const message = "C:\\new\\notes\\nested"; + expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); + }); + + it("still normalizes actual CRLF while preserving backslash-n", () => { + const message = "Line 1\r\nC:\\Work\\nxxx"; + expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); + }); +}); + +describe("inbound context contract (providers + extensions)", () => { + const cases: Array<{ name: string; ctx: MsgContext }> = [ + { + name: "whatsapp group", + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + ChatType: "group", + From: "123@g.us", + To: "+15550001111", + Body: "[WhatsApp 123@g.us] hi", + RawBody: "hi", + CommandBody: "hi", + SenderName: "Alice", + }, + }, + { + name: "telegram group", + ctx: { + Provider: "telegram", + Surface: "telegram", + ChatType: "group", + From: "group:123", + To: "telegram:123", + Body: "[Telegram group:123] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Telegram Group", + SenderName: "Alice", + }, + }, + { + name: "slack channel", + ctx: { + Provider: "slack", + Surface: "slack", + ChatType: "channel", + From: "slack:channel:C123", + To: "channel:C123", + Body: "[Slack #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "discord channel", + ctx: { + Provider: "discord", + Surface: "discord", + ChatType: "channel", + From: "group:123", + To: "channel:123", + Body: "[Discord #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "signal dm", + ctx: { + Provider: "signal", + Surface: "signal", + ChatType: "direct", + From: "signal:+15550001111", + To: "signal:+15550002222", + Body: "[Signal] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "imessage group", + ctx: { + Provider: "imessage", + Surface: "imessage", + ChatType: "group", + From: "group:chat_id:123", + To: "chat_id:123", + Body: "[iMessage Group] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "iMessage Group", + SenderName: "Alice", + }, + }, + { + name: "matrix channel", + ctx: { + Provider: "matrix", + Surface: "matrix", + ChatType: "channel", + From: "matrix:channel:!room:example.org", + To: "room:!room:example.org", + Body: "[Matrix] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "msteams channel", + ctx: { + Provider: "msteams", + Surface: "msteams", + ChatType: "channel", + From: "msteams:channel:19:abc@thread.tacv2", + To: "msteams:channel:19:abc@thread.tacv2", + Body: "[Teams] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Teams Channel", + SenderName: "Alice", + }, + }, + { + name: "zalo dm", + ctx: { + Provider: "zalo", + Surface: "zalo", + ChatType: "direct", + From: "zalo:123", + To: "zalo:123", + Body: "[Zalo] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "zalouser group", + ctx: { + Provider: "zalouser", + Surface: "zalouser", + ChatType: "group", + From: "group:123", + To: "zalouser:123", + Body: "[Zalo Personal] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Zalouser Group", + SenderName: "Alice", + }, + }, + ]; + + for (const entry of cases) { + it(entry.name, () => { + const ctx = finalizeInboundContext({ ...entry.ctx }); + expectInboundContextContract(ctx); + }); + } +}); + +const getLineData = (result: ReturnType) => + (result.channelData?.line as Record | undefined) ?? {}; + +describe("hasLineDirectives", () => { + it("detects quick_replies directive", () => { + expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); + }); + + it("detects location directive", () => { + expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); + }); + + it("detects confirm directive", () => { + expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); + }); + + it("detects buttons directive", () => { + expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); + }); + + it("returns false for regular text", () => { + expect(hasLineDirectives("Just regular text")).toBe(false); + }); + + it("returns false for similar but invalid patterns", () => { + expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); + }); + + it("detects media_player directive", () => { + expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); + }); + + it("detects event directive", () => { + expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); + }); + + it("detects agenda directive", () => { + expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); + }); + + it("detects device directive", () => { + expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); + }); + + it("detects appletv_remote directive", () => { + expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); + }); +}); + +describe("parseLineDirectives", () => { + describe("quick_replies", () => { + it("parses quick_replies and removes from text", () => { + const result = parseLineDirectives({ + text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", + }); + + expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); + expect(result.text).toBe("Choose one:"); + }); + + it("handles quick_replies in middle of text", () => { + const result = parseLineDirectives({ + text: "Before [[quick_replies: A, B]] After", + }); + + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + expect(result.text).toBe("Before After"); + }); + + it("merges with existing quickReplies", () => { + const result = parseLineDirectives({ + text: "Text [[quick_replies: C, D]]", + channelData: { line: { quickReplies: ["A", "B"] } }, + }); + + expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); + }); + }); + + describe("location", () => { + it("parses location with all fields", () => { + const result = parseLineDirectives({ + text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", + }); + + expect(getLineData(result).location).toEqual({ + title: "Tokyo Station", + address: "Tokyo, Japan", + latitude: 35.6812, + longitude: 139.7671, + }); + expect(result.text).toBe("Here's the location:"); + }); + + it("ignores invalid coordinates", () => { + const result = parseLineDirectives({ + text: "[[location: Place | Address | invalid | 139.7]]", + }); + + expect(getLineData(result).location).toBeUndefined(); + }); + + it("does not override existing location", () => { + const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; + const result = parseLineDirectives({ + text: "[[location: New | New Addr | 35.6 | 139.7]]", + channelData: { line: { location: existing } }, + }); + + expect(getLineData(result).location).toEqual(existing); + }); + }); + + describe("confirm", () => { + it("parses simple confirm", () => { + const result = parseLineDirectives({ + text: "[[confirm: Delete this item? | Yes | No]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "confirm", + text: "Delete this item?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + altText: "Delete this item?", + }); + // Text is undefined when directive consumes entire text + expect(result.text).toBeUndefined(); + }); + + it("parses confirm with custom data", () => { + const result = parseLineDirectives({ + text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "confirm", + text: "Proceed?", + confirmLabel: "OK", + confirmData: "action=confirm", + cancelLabel: "Cancel", + cancelData: "action=cancel", + altText: "Proceed?", + }); + }); + }); + + describe("buttons", () => { + it("parses buttons with message actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "buttons", + title: "Menu", + text: "Select an option", + actions: [ + { type: "message", label: "Help", data: "/help" }, + { type: "message", label: "Status", data: "/status" }, + ], + altText: "Menu: Select an option", + }); + }); + + it("parses buttons with uri actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Links | Visit us | Site:https://example.com]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.[0]).toEqual({ + type: "uri", + label: "Site", + uri: "https://example.com", + }); + } + }); + + it("parses buttons with postback actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.[0]).toEqual({ + type: "postback", + label: "Select", + data: "action=select&id=1", + }); + } + }); + + it("limits to 4 actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.length).toBe(4); + } + }); + }); + + describe("media_player", () => { + it("parses media_player with all fields", () => { + const result = parseLineDirectives({ + text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", + }); + + const flexMessage = getLineData(result).flexMessage as { + altText?: string; + contents?: { footer?: { contents?: unknown[] } }; + }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen"); + const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; + expect(contents.footer?.contents?.length).toBeGreaterThan(0); + expect(result.text).toBe("Now playing:"); + }); + + it("parses media_player with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[media_player: Unknown Track]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("🎵 Unknown Track"); + }); + + it("handles paused status", () => { + const result = parseLineDirectives({ + text: "[[media_player: Song | Artist | Player | | paused]]", + }); + + const flexMessage = getLineData(result).flexMessage as { + contents?: { body: { contents: unknown[] } }; + }; + expect(flexMessage).toBeDefined(); + const contents = flexMessage?.contents as { body: { contents: unknown[] } }; + expect(contents).toBeDefined(); + }); + }); + + describe("event", () => { + it("parses event with all fields", () => { + const result = parseLineDirectives({ + text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); + }); + + it("parses event with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[event: Birthday Party | March 15]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15"); + }); + }); + + describe("agenda", () => { + it("parses agenda with multiple events", () => { + const result = parseLineDirectives({ + text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)"); + }); + + it("parses agenda with events without times", () => { + const result = parseLineDirectives({ + text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📋 Tasks (3 events)"); + }); + }); + + describe("device", () => { + it("parses device with controls", () => { + const result = parseLineDirectives({ + text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📱 TV: Playing"); + }); + + it("parses device with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[device: Speaker]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📱 Speaker"); + }); + }); + + describe("appletv_remote", () => { + it("parses appletv_remote with status", () => { + const result = parseLineDirectives({ + text: "[[appletv_remote: Apple TV | Playing]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toContain("Apple TV"); + }); + + it("parses appletv_remote with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[appletv_remote: Apple TV]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + }); + }); + + describe("combined directives", () => { + it("handles text with no directives", () => { + const result = parseLineDirectives({ + text: "Just plain text here", + }); + + expect(result.text).toBe("Just plain text here"); + expect(getLineData(result).quickReplies).toBeUndefined(); + expect(getLineData(result).location).toBeUndefined(); + expect(getLineData(result).templateMessage).toBeUndefined(); + }); + + it("preserves other payload fields", () => { + const result = parseLineDirectives({ + text: "Hello [[quick_replies: A, B]]", + mediaUrl: "https://example.com/image.jpg", + replyToId: "msg123", + }); + + expect(result.mediaUrl).toBe("https://example.com/image.jpg"); + expect(result.replyToId).toBe("msg123"); + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + }); + }); +}); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let previousRuntimeError: typeof defaultRuntime.error; + +beforeAll(() => { + previousRuntimeError = defaultRuntime.error; + defaultRuntime.error = undefined; +}); + +afterAll(() => { + defaultRuntime.error = previousRuntimeError; +}); + +function createRun(params: { + prompt: string; + messageId?: string; + originatingChannel?: FollowupRun["originatingChannel"]; + originatingTo?: string; + originatingAccountId?: string; + originatingThreadId?: string | number; +}): FollowupRun { + return { + prompt: params.prompt, + messageId: params.messageId, + enqueuedAt: Date.now(), + originatingChannel: params.originatingChannel, + originatingTo: params.originatingTo, + originatingAccountId: params.originatingAccountId, + originatingThreadId: params.originatingThreadId, + run: { + agentId: "agent", + agentDir: "/tmp", + sessionId: "sess", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp", + config: {} as OpenClawConfig, + provider: "openai", + model: "gpt-test", + timeoutMs: 10_000, + blockReplyBreak: "text_end", + }, + }; +} + +describe("followup queue deduplication", () => { + it("deduplicates messages with same Discord message_id", async () => { + const key = `test-dedup-message-id-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello", + messageId: "m1", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same message id should be deduplicated + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello (dupe)", + messageId: "m1", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(false); + + // Third enqueue with different message id should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] World", + messageId: "m2", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(third).toBe(true); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + // Should collect both unique messages + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + }); + + it("deduplicates exact prompt when routing matches and no message id", async () => { + const key = `test-dedup-whatsapp-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same prompt should be allowed (default dedupe: message id only) + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(second).toBe(true); + + // Third enqueue with different prompt should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world 2", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(third).toBe(true); + }); + + it("does not deduplicate across different providers without message id", async () => { + const key = `test-dedup-cross-provider-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Same text", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Same text", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(true); + }); + + it("can opt-in to prompt-based dedupe when message id is absent", async () => { + const key = `test-dedup-prompt-mode-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + "prompt", + ); + expect(first).toBe(true); + + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + "prompt", + ); + expect(second).toBe(false); + }); +}); + +describe("followup queue collect routing", () => { + it("does not collect when destinations differ", async () => { + const key = `test-collect-diff-to-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 2; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:B", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toBe("one"); + expect(calls[1]?.prompt).toBe("two"); + }); + + it("collects when channel+destination match", async () => { + const key = `test-collect-same-to-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + expect(calls[0]?.originatingChannel).toBe("slack"); + expect(calls[0]?.originatingTo).toBe("channel:A"); + }); + + it("collects Slack messages in same thread and preserves string thread id", async () => { + const key = `test-collect-slack-thread-same-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); + }); + + it("does not collect Slack messages when thread ids differ", async () => { + const key = `test-collect-slack-thread-diff-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 2; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000002", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toBe("one"); + expect(calls[1]?.prompt).toBe("two"); + expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); + expect(calls[1]?.originatingThreadId).toBe("1706000000.000002"); + }); + + it("retries collect-mode batches without losing queued items", async () => { + const key = `test-collect-retry-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + let attempt = 0; + const runFollowup = async (run: FollowupRun) => { + attempt += 1; + if (attempt === 1) { + throw new Error("transient failure"); + } + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "one" }), settings); + enqueueFollowupRun(key, createRun({ prompt: "two" }), settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("Queued #1\none"); + expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); + }); + + it("retries overflow summary delivery without losing dropped previews", async () => { + const key = `test-overflow-summary-retry-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + let attempt = 0; + const runFollowup = async (run: FollowupRun) => { + attempt += 1; + if (attempt === 1) { + throw new Error("transient failure"); + } + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); + enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.prompt).toContain("- first"); + }); +}); + +const emptyCfg = {} as OpenClawConfig; + +describe("createReplyDispatcher", () => { + it("drops empty payloads and silent tokens without media", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ deliver }); + + expect(dispatcher.sendFinalReply({})).toBe(false); + expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); + + await dispatcher.waitForIdle(); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("strips heartbeat tokens and applies responsePrefix", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const onHeartbeatStrip = vi.fn(); + const dispatcher = createReplyDispatcher({ + deliver, + responsePrefix: "PFX", + onHeartbeatStrip, + }); + + expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false); + expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true); + await dispatcher.waitForIdle(); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0][0].text).toBe("PFX hello"); + expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); + }); + + it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + responsePrefix: "PFX", + }); + + expect( + dispatcher.sendFinalReply({ + text: "PFX already", + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: HEARTBEAT_TOKEN, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: `${SILENT_REPLY_TOKEN} -- explanation`, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + + await dispatcher.waitForIdle(); + + expect(deliver).toHaveBeenCalledTimes(3); + expect(deliver.mock.calls[0][0].text).toBe("PFX already"); + expect(deliver.mock.calls[1][0].text).toBe(""); + expect(deliver.mock.calls[2][0].text).toBe(""); + }); + + it("preserves ordering across tool, block, and final replies", async () => { + const delivered: string[] = []; + const deliver = vi.fn(async (_payload, info) => { + delivered.push(info.kind); + if (info.kind === "tool") { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }); + const dispatcher = createReplyDispatcher({ deliver }); + + dispatcher.sendToolResult({ text: "tool" }); + dispatcher.sendBlockReply({ text: "block" }); + dispatcher.sendFinalReply({ text: "final" }); + + await dispatcher.waitForIdle(); + expect(delivered).toEqual(["tool", "block", "final"]); + }); + + it("fires onIdle when the queue drains", async () => { + const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5))); + const onIdle = vi.fn(); + const dispatcher = createReplyDispatcher({ deliver, onIdle }); + + dispatcher.sendToolResult({ text: "one" }); + dispatcher.sendFinalReply({ text: "two" }); + + await dispatcher.waitForIdle(); + dispatcher.markComplete(); + await Promise.resolve(); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it("delays block replies after the first when humanDelay is natural", async () => { + vi.useFakeTimers(); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + humanDelay: { mode: "natural" }, + }); + + dispatcher.sendBlockReply({ text: "first" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + dispatcher.sendBlockReply({ text: "second" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(799); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(2); + + randomSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("uses custom bounds for humanDelay and clamps when max <= min", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 }, + }); + + dispatcher.sendBlockReply({ text: "first" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + dispatcher.sendBlockReply({ text: "second" }); + await vi.advanceTimersByTimeAsync(1199); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); +}); + +describe("resolveReplyToMode", () => { + it("defaults to off for Telegram", () => { + expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); + }); + + it("defaults to off for Discord and Slack", () => { + expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); + expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); + }); + + it("defaults to all when channel is unknown", () => { + expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); + }); + + it("uses configured value when present", () => { + const cfg = { + channels: { + telegram: { replyToMode: "all" }, + discord: { replyToMode: "first" }, + slack: { replyToMode: "all" }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); + expect(resolveReplyToMode(cfg, "discord")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack")).toBe("all"); + }); + + it("uses chat-type replyToMode overrides for Slack when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + replyToModeByChatType: { direct: "all", group: "first" }, + }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); + }); + + it("falls back to top-level replyToMode when no chat-type override is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); + }); + + it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + dm: { replyToMode: "all" }, + }, + }, + } as OpenClawConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + }); +}); + +describe("createReplyToModeFilter", () => { + it("drops replyToId when mode is off", () => { + const filter = createReplyToModeFilter("off"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); + }); + + it("keeps replyToId when mode is off and reply tags are allowed", () => { + const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }); + expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); + }); + + it("keeps replyToId when mode is all", () => { + const filter = createReplyToModeFilter("all"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + }); + + it("keeps only the first replyToId when mode is first", () => { + const filter = createReplyToModeFilter("first"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts b/src/auto-reply/reply/reply-payloads.auto-threading.test.ts deleted file mode 100644 index 80578f4b721..00000000000 --- a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyReplyThreading } from "./reply-payloads.js"; - -describe("applyReplyThreading auto-threading", () => { - it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { - const result = applyReplyThreading({ - payloads: [{ text: "Hello" }], - replyToMode: "first", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - }); - - it("threads only first payload when mode is 'first'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }, { text: "B" }], - replyToMode: "first", - currentMessageId: "42", - }); - - expect(result).toHaveLength(2); - expect(result[0].replyToId).toBe("42"); - expect(result[1].replyToId).toBeUndefined(); - }); - - it("threads all payloads when mode is 'all'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }, { text: "B" }], - replyToMode: "all", - currentMessageId: "42", - }); - - expect(result).toHaveLength(2); - expect(result[0].replyToId).toBe("42"); - expect(result[1].replyToId).toBe("42"); - }); - - it("strips replyToId when mode is 'off'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }], - replyToMode: "off", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); - }); - - it("does not bypass off mode for Slack when reply is implicit", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }], - replyToMode: "off", - replyToChannel: "slack", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); - }); - - it("keeps explicit tags for Slack when off mode allows tags", () => { - const result = applyReplyThreading({ - payloads: [{ text: "[[reply_to_current]]A" }], - replyToMode: "off", - replyToChannel: "slack", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); - }); - - it("keeps explicit tags for Telegram when off mode is enabled", () => { - const result = applyReplyThreading({ - payloads: [{ text: "[[reply_to_current]]A" }], - replyToMode: "off", - replyToChannel: "telegram", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts new file mode 100644 index 00000000000..2b1d1367ac3 --- /dev/null +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { TemplateContext } from "../templating.js"; +import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; +import { buildThreadingToolContext } from "./agent-runner-utils.js"; +import { applyReplyThreading } from "./reply-payloads.js"; +import { + formatRunLabel, + formatRunStatus, + resolveSubagentLabel, + sortSubagentRuns, +} from "./subagents-utils.js"; + +describe("buildThreadingToolContext", () => { + const cfg = {} as OpenClawConfig; + + it("uses conversation id for WhatsApp", () => { + const sessionCtx = { + Provider: "whatsapp", + From: "123@g.us", + To: "+15550001", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("123@g.us"); + }); + + it("falls back to To for WhatsApp when From is missing", () => { + const sessionCtx = { + Provider: "whatsapp", + To: "+15550001", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("+15550001"); + }); + + it("uses the recipient id for other channels", () => { + const sessionCtx = { + Provider: "telegram", + From: "user:42", + To: "chat:99", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat:99"); + }); + + it("uses the sender handle for iMessage direct chats", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "direct", + From: "imessage:+15550001", + To: "chat_id:12", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("imessage:+15550001"); + }); + + it("uses chat_id for iMessage groups", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "group", + From: "imessage:group:7", + To: "chat_id:7", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat_id:7"); + }); + + it("prefers MessageThreadId for Slack tool threading", () => { + const sessionCtx = { + Provider: "slack", + To: "channel:C1", + MessageThreadId: "123.456", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("C1"); + expect(result.currentThreadTs).toBe("123.456"); + }); +}); + +describe("applyReplyThreading auto-threading", () => { + it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { + const result = applyReplyThreading({ + payloads: [{ text: "Hello" }], + replyToMode: "first", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + }); + + it("threads only first payload when mode is 'first'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }, { text: "B" }], + replyToMode: "first", + currentMessageId: "42", + }); + + expect(result).toHaveLength(2); + expect(result[0].replyToId).toBe("42"); + expect(result[1].replyToId).toBeUndefined(); + }); + + it("threads all payloads when mode is 'all'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }, { text: "B" }], + replyToMode: "all", + currentMessageId: "42", + }); + + expect(result).toHaveLength(2); + expect(result[0].replyToId).toBe("42"); + expect(result[1].replyToId).toBe("42"); + }); + + it("strips replyToId when mode is 'off'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("does not bypass off mode for Slack when reply is implicit", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("keeps explicit tags for Slack when off mode allows tags", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); + + it("keeps explicit tags for Telegram when off mode is enabled", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "telegram", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); +}); + +const baseRun: SubagentRunRecord = { + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, +}; + +describe("subagents utils", () => { + it("resolves labels from label, task, or fallback", () => { + expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label"); + expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task"); + expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe( + "fallback", + ); + }); + + it("formats run labels with truncation", () => { + const long = "x".repeat(100); + const run = { ...baseRun, label: long }; + const formatted = formatRunLabel(run, { maxLength: 10 }); + expect(formatted.startsWith("x".repeat(10))).toBe(true); + expect(formatted.endsWith("…")).toBe(true); + }); + + it("sorts subagent runs by newest start/created time", () => { + const runs: SubagentRunRecord[] = [ + { ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 }, + { ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 }, + { ...baseRun, runId: "run-3", createdAt: 900 }, + ]; + const sorted = sortSubagentRuns(runs); + expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); + }); + + it("formats run status from outcome and timestamps", () => { + expect(formatRunStatus({ ...baseRun })).toBe("running"); + expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); + expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( + "timeout", + ); + }); + + it("formats duration compact for seconds and minutes", () => { + expect(formatDurationCompact(45_000)).toBe("45s"); + expect(formatDurationCompact(65_000)).toBe("1m5s"); + }); +}); diff --git a/src/auto-reply/reply/reply-routing.test.ts b/src/auto-reply/reply/reply-routing.test.ts deleted file mode 100644 index 78a4010c53c..00000000000 --- a/src/auto-reply/reply/reply-routing.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; -import { createReplyDispatcher } from "./reply-dispatcher.js"; -import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; - -const emptyCfg = {} as OpenClawConfig; - -describe("createReplyDispatcher", () => { - it("drops empty payloads and silent tokens without media", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ deliver }); - - expect(dispatcher.sendFinalReply({})).toBe(false); - expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); - expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); - - await dispatcher.waitForIdle(); - expect(deliver).not.toHaveBeenCalled(); - }); - - it("strips heartbeat tokens and applies responsePrefix", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const onHeartbeatStrip = vi.fn(); - const dispatcher = createReplyDispatcher({ - deliver, - responsePrefix: "PFX", - onHeartbeatStrip, - }); - - expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false); - expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true); - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(1); - expect(deliver.mock.calls[0][0].text).toBe("PFX hello"); - expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); - }); - - it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - responsePrefix: "PFX", - }); - - expect( - dispatcher.sendFinalReply({ - text: "PFX already", - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - expect( - dispatcher.sendFinalReply({ - text: HEARTBEAT_TOKEN, - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - expect( - dispatcher.sendFinalReply({ - text: `${SILENT_REPLY_TOKEN} -- explanation`, - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(3); - expect(deliver.mock.calls[0][0].text).toBe("PFX already"); - expect(deliver.mock.calls[1][0].text).toBe(""); - expect(deliver.mock.calls[2][0].text).toBe(""); - }); - - it("preserves ordering across tool, block, and final replies", async () => { - const delivered: string[] = []; - const deliver = vi.fn(async (_payload, info) => { - delivered.push(info.kind); - if (info.kind === "tool") { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - }); - const dispatcher = createReplyDispatcher({ deliver }); - - dispatcher.sendToolResult({ text: "tool" }); - dispatcher.sendBlockReply({ text: "block" }); - dispatcher.sendFinalReply({ text: "final" }); - - await dispatcher.waitForIdle(); - expect(delivered).toEqual(["tool", "block", "final"]); - }); - - it("fires onIdle when the queue drains", async () => { - const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5))); - const onIdle = vi.fn(); - const dispatcher = createReplyDispatcher({ deliver, onIdle }); - - dispatcher.sendToolResult({ text: "one" }); - dispatcher.sendFinalReply({ text: "two" }); - - await dispatcher.waitForIdle(); - dispatcher.markComplete(); - await Promise.resolve(); - expect(onIdle).toHaveBeenCalledTimes(1); - }); - - it("delays block replies after the first when humanDelay is natural", async () => { - vi.useFakeTimers(); - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - humanDelay: { mode: "natural" }, - }); - - dispatcher.sendBlockReply({ text: "first" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - dispatcher.sendBlockReply({ text: "second" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(799); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); - - randomSpy.mockRestore(); - vi.useRealTimers(); - }); - - it("uses custom bounds for humanDelay and clamps when max <= min", async () => { - vi.useFakeTimers(); - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 }, - }); - - dispatcher.sendBlockReply({ text: "first" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - dispatcher.sendBlockReply({ text: "second" }); - await vi.advanceTimersByTimeAsync(1199); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); - - vi.useRealTimers(); - }); -}); - -describe("resolveReplyToMode", () => { - it("defaults to off for Telegram", () => { - expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); - }); - - it("defaults to off for Discord and Slack", () => { - expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); - expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); - }); - - it("defaults to all when channel is unknown", () => { - expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); - }); - - it("uses configured value when present", () => { - const cfg = { - channels: { - telegram: { replyToMode: "all" }, - discord: { replyToMode: "first" }, - slack: { replyToMode: "all" }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); - expect(resolveReplyToMode(cfg, "discord")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack")).toBe("all"); - }); - - it("uses chat-type replyToMode overrides for Slack when configured", () => { - const cfg = { - channels: { - slack: { - replyToMode: "off", - replyToModeByChatType: { direct: "all", group: "first" }, - }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); - }); - - it("falls back to top-level replyToMode when no chat-type override is set", () => { - const cfg = { - channels: { - slack: { - replyToMode: "first", - }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); - }); - - it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { - const cfg = { - channels: { - slack: { - replyToMode: "off", - dm: { replyToMode: "all" }, - }, - }, - } as OpenClawConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - }); -}); - -describe("createReplyToModeFilter", () => { - it("drops replyToId when mode is off", () => { - const filter = createReplyToModeFilter("off"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); - }); - - it("keeps replyToId when mode is off and reply tags are allowed", () => { - const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }); - expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); - }); - - it("keeps replyToId when mode is all", () => { - const filter = createReplyToModeFilter("all"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); - }); - - it("keeps only the first replyToId when mode is first", () => { - const filter = createReplyToModeFilter("first"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); - expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); - }); -}); diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts new file mode 100644 index 00000000000..182506b4e48 --- /dev/null +++ b/src/auto-reply/reply/reply-state.test.ts @@ -0,0 +1,381 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import { + appendHistoryEntry, + buildHistoryContext, + buildHistoryContextFromEntries, + buildHistoryContextFromMap, + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + HISTORY_CONTEXT_MARKER, + recordPendingHistoryEntryIfEnabled, +} from "./history.js"; +import { + DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushSettings, + shouldRunMemoryFlush, +} from "./memory-flush.js"; +import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; +import { incrementCompactionCount } from "./session-updates.js"; + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +describe("history helpers", () => { + it("returns current message when history is empty", () => { + const result = buildHistoryContext({ + historyText: " ", + currentMessage: "hello", + }); + expect(result).toBe("hello"); + }); + + it("wraps history entries and excludes current by default", () => { + const result = buildHistoryContextFromEntries({ + entries: [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ], + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).not.toContain("B: two"); + expect(result).toContain(CURRENT_MESSAGE_MARKER); + expect(result).toContain("current"); + }); + + it("trims history to configured limit", () => { + const historyMap = new Map(); + + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "A", body: "one" }, + }); + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "C", body: "three" }, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]); + }); + + it("builds context from map and appends entry", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + const result = buildHistoryContextFromMap({ + historyMap, + historyKey: "group", + limit: 3, + entry: { sender: "C", body: "three" }, + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).toContain("B: two"); + expect(result).not.toContain("C: three"); + }); + + it("builds context from pending map without appending", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + const result = buildPendingHistoryContextFromMap({ + historyMap, + historyKey: "group", + limit: 3, + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).toContain("B: two"); + expect(result).toContain(CURRENT_MESSAGE_MARKER); + expect(result).toContain("current"); + }); + + it("records pending entries only when enabled", () => { + const historyMap = new Map(); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 0, + entry: { sender: "A", body: "one" }, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: null, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); + }); + + it("clears history entries only when enabled", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); + expect(historyMap.get("group")).toEqual([]); + }); +}); + +describe("memory flush settings", () => { + it("defaults to enabled with fallback prompt and system prompt", () => { + const settings = resolveMemoryFlushSettings(); + expect(settings).not.toBeNull(); + expect(settings?.enabled).toBe(true); + expect(settings?.prompt.length).toBeGreaterThan(0); + expect(settings?.systemPrompt.length).toBeGreaterThan(0); + }); + + it("respects disable flag", () => { + expect( + resolveMemoryFlushSettings({ + agents: { + defaults: { compaction: { memoryFlush: { enabled: false } } }, + }, + }), + ).toBeNull(); + }); + + it("appends NO_REPLY hint when missing", () => { + const settings = resolveMemoryFlushSettings({ + agents: { + defaults: { + compaction: { + memoryFlush: { + prompt: "Write memories now.", + systemPrompt: "Flush memory.", + }, + }, + }, + }, + }); + expect(settings?.prompt).toContain("NO_REPLY"); + expect(settings?.systemPrompt).toContain("NO_REPLY"); + }); +}); + +describe("shouldRunMemoryFlush", () => { + it("requires totalTokens and threshold", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 0 }, + contextWindowTokens: 16_000, + reserveTokensFloor: 20_000, + softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + }), + ).toBe(false); + }); + + it("skips when entry is missing", () => { + expect( + shouldRunMemoryFlush({ + entry: undefined, + contextWindowTokens: 16_000, + reserveTokensFloor: 1_000, + softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + }), + ).toBe(false); + }); + + it("skips when under threshold", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 10_000 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 20_000, + softThresholdTokens: 10_000, + }), + ).toBe(false); + }); + + it("triggers at the threshold boundary", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 85 }, + contextWindowTokens: 100, + reserveTokensFloor: 10, + softThresholdTokens: 5, + }), + ).toBe(true); + }); + + it("skips when already flushed for current compaction count", () => { + expect( + shouldRunMemoryFlush({ + entry: { + totalTokens: 90_000, + compactionCount: 2, + memoryFlushCompactionCount: 2, + }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(false); + }); + + it("runs when above threshold and not flushed", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 96_000, compactionCount: 1 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(true); + }); + + it("ignores stale cached totals", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(false); + }); +}); + +describe("resolveMemoryFlushContextWindowTokens", () => { + it("falls back to agent config or default tokens", () => { + expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000); + }); +}); + +describe("incrementCompactionCount", () => { + it("increments compaction count", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + const count = await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + }); + expect(count).toBe(3); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(3); + }); + + it("updates totalTokens when tokensAfter is provided", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + updatedAt: Date.now(), + compactionCount: 0, + totalTokens: 180_000, + inputTokens: 170_000, + outputTokens: 10_000, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + tokensAfter: 12_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(1); + expect(stored[sessionKey].totalTokens).toBe(12_000); + // input/output cleared since we only have the total estimate + expect(stored[sessionKey].inputTokens).toBeUndefined(); + expect(stored[sessionKey].outputTokens).toBeUndefined(); + }); + + it("does not update totalTokens when tokensAfter is not provided", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + updatedAt: Date.now(), + compactionCount: 0, + totalTokens: 180_000, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(1); + // totalTokens unchanged + expect(stored[sessionKey].totalTokens).toBe(180_000); + }); +}); diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts new file mode 100644 index 00000000000..743568b38c1 --- /dev/null +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -0,0 +1,844 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import { parseAudioTag } from "./audio-tags.js"; +import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; +import { matchesMentionWithExplicit } from "./mentions.js"; +import { normalizeReplyPayload } from "./normalize-reply.js"; +import { createReplyReferencePlanner } from "./reply-reference.js"; +import { + extractShortModelName, + hasTemplateVariables, + resolveResponsePrefixTemplate, +} from "./response-prefix-template.js"; +import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; +import { createMockTypingController } from "./test-helpers.js"; +import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; +import { createTypingController } from "./typing.js"; + +describe("matchesMentionWithExplicit", () => { + const mentionRegexes = [/\bopenclaw\b/i]; + + it("checks mentionPatterns even when explicit mention is available", () => { + const result = matchesMentionWithExplicit({ + text: "@openclaw hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("returns false when explicit is false and no regex match", () => { + const result = matchesMentionWithExplicit({ + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(false); + }); + + it("returns true when explicitly mentioned even if regexes do not match", () => { + const result = matchesMentionWithExplicit({ + text: "<@123456>", + mentionRegexes: [], + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: true, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("falls back to regex matching when explicit mention cannot be resolved", () => { + const result = matchesMentionWithExplicit({ + text: "openclaw please", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: false, + }, + }); + expect(result).toBe(true); + }); +}); + +// Keep channelData-only payloads so channel-specific replies survive normalization. +describe("normalizeReplyPayload", () => { + it("keeps channelData-only replies", () => { + const payload = { + channelData: { + line: { + flexMessage: { type: "bubble" }, + }, + }, + }; + + const normalized = normalizeReplyPayload(payload); + + expect(normalized).not.toBeNull(); + expect(normalized?.text).toBeUndefined(); + expect(normalized?.channelData).toEqual(payload.channelData); + }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); +}); + +describe("typing controller", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("stops after run completion and dispatcher idle", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(1_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + }); + + it("keeps typing until both idle and run completion are set", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + }); + + it("does not start typing after run completion", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + typing.markRunComplete(); + await typing.startTypingOnText("late text"); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).not.toHaveBeenCalled(); + }); + + it("does not restart typing after it has stopped", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markRunComplete(); + typing.markDispatchIdle(); + + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + // Late callbacks should be ignored and must not restart the interval. + await typing.startTypingOnText("late tool result"); + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveTypingMode", () => { + it("defaults to instant for direct chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("defaults to message for group chats without mentions", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("defaults to instant for mentioned group chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("honors configured mode across contexts", () => { + expect( + resolveTypingMode({ + configured: "thinking", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("thinking"); + expect( + resolveTypingMode({ + configured: "message", + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("forces never for heartbeat runs", () => { + expect( + resolveTypingMode({ + configured: "instant", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: true, + }), + ).toBe("never"); + }); +}); + +describe("createTypingSignaler", () => { + it("signals immediately for instant mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "instant", + isHeartbeat: false, + }); + + await signaler.signalRunStart(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("signals on text for message mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hello"); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("signals on message start for message mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalMessageStart(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hello"); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + }); + + it("signals on reasoning for thinking mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "thinking", + isHeartbeat: false, + }); + + await signaler.signalReasoningDelta(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("refreshes ttl on text for thinking mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "thinking", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hi"); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("starts typing on tool start before text", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalToolStart(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("refreshes ttl on tool start when active after text", async () => { + const typing = createMockTypingController({ + isActive: vi.fn(() => true), + }); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hello"); + typing.startTypingLoop.mockClear(); + typing.startTypingOnText.mockClear(); + typing.refreshTypingTtl.mockClear(); + await signaler.signalToolStart(); + + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("suppresses typing when disabled", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "instant", + isHeartbeat: true, + }); + + await signaler.signalRunStart(); + await signaler.signalTextDelta("hi"); + await signaler.signalReasoningDelta(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); +}); + +describe("parseAudioTag", () => { + it("detects audio_as_voice and strips the tag", () => { + const result = parseAudioTag("Hello [[audio_as_voice]] world"); + expect(result.audioAsVoice).toBe(true); + expect(result.hadTag).toBe(true); + expect(result.text).toBe("Hello world"); + }); + + it("returns empty output for missing text", () => { + const result = parseAudioTag(undefined); + expect(result.audioAsVoice).toBe(false); + expect(result.hadTag).toBe(false); + expect(result.text).toBe(""); + }); + + it("removes tag-only messages", () => { + const result = parseAudioTag("[[audio_as_voice]]"); + expect(result.audioAsVoice).toBe(true); + expect(result.text).toBe(""); + }); +}); + +describe("block reply coalescer", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces chunks within the idle window", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "Hello" }); + coalescer.enqueue({ text: "world" }); + + await vi.advanceTimersByTimeAsync(100); + expect(flushes).toEqual(["Hello world"]); + coalescer.stop(); + }); + + it("waits until minChars before idle flush", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "short" }); + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual([]); + + coalescer.enqueue({ text: "message" }); + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual(["short message"]); + coalescer.stop(); + }); + + it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + coalescer.enqueue({ text: "Third paragraph" }); + + await Promise.resolve(); + expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); + coalescer.stop(); + }); + + it("still accumulates when flushOnEnqueue is not set (default)", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + + await vi.advanceTimersByTimeAsync(100); + expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); + coalescer.stop(); + }); + + it("flushes short payloads immediately when flushOnEnqueue is set", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "Hi" }); + await Promise.resolve(); + expect(flushes).toEqual(["Hi"]); + coalescer.stop(); + }); + + it("resets char budget per paragraph with flushOnEnqueue", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + // Each 20-char payload fits within maxChars=30 individually + coalescer.enqueue({ text: "12345678901234567890" }); + coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); + + await Promise.resolve(); + // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. + // With flushOnEnqueue, each is sent independently within budget. + expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); + coalescer.stop(); + }); + + it("flushes buffered text before media payloads", () => { + const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }); + }, + }); + + coalescer.enqueue({ text: "Hello" }); + coalescer.enqueue({ text: "world" }); + coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); + void coalescer.flush({ force: true }); + + expect(flushes[0].text).toBe("Hello world"); + expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); + coalescer.stop(); + }); +}); + +describe("createReplyReferencePlanner", () => { + it("disables references when mode is off", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "off", + startId: "parent", + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + }); + + it("uses startId once when mode is first", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "first", + startId: "parent", + }); + expect(planner.use()).toBe("parent"); + expect(planner.hasReplied()).toBe(true); + planner.markSent(); + expect(planner.use()).toBeUndefined(); + }); + + it("returns startId for every call when mode is all", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + startId: "parent", + }); + expect(planner.use()).toBe("parent"); + expect(planner.use()).toBe("parent"); + }); + + it("respects replyToMode off even with existingId", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "off", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + }); + + it("uses existingId once when mode is first", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "first", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBe("thread-1"); + expect(planner.hasReplied()).toBe(true); + expect(planner.use()).toBeUndefined(); + }); + + it("uses existingId on every call when mode is all", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBe("thread-1"); + expect(planner.use()).toBe("thread-1"); + }); + + it("honors allowReference=false", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + startId: "parent", + allowReference: false, + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + planner.markSent(); + expect(planner.hasReplied()).toBe(true); + }); +}); + +describe("createStreamingDirectiveAccumulator", () => { + it("stashes reply_to_current until a renderable chunk arrives", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); + + const result = accumulator.consume("Hello"); + expect(result?.text).toBe("Hello"); + expect(result?.replyToCurrent).toBe(true); + expect(result?.replyToTag).toBe(true); + }); + + it("handles reply tags split across chunks", () => { + const accumulator = createStreamingDirectiveAccumulator(); + expect(accumulator.consume("[[reply_to_")).toBeNull(); + + const result = accumulator.consume("current]] Yo"); + expect(result?.text).toBe("Yo"); + expect(result?.replyToCurrent).toBe(true); + expect(result?.replyToTag).toBe(true); + }); + + it("propagates explicit reply ids across chunks", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); + + const result = accumulator.consume("Hi"); + expect(result?.text).toBe("Hi"); + expect(result?.replyToId).toBe("abc-123"); + expect(result?.replyToTag).toBe(true); + }); +}); + +describe("resolveResponsePrefixTemplate", () => { + it("returns undefined for undefined template", () => { + expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); + }); + + it("returns template as-is when no variables present", () => { + expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); + }); + + it("resolves {model} variable", () => { + const result = resolveResponsePrefixTemplate("[{model}]", { + model: "gpt-5.2", + }); + expect(result).toBe("[gpt-5.2]"); + }); + + it("resolves {modelFull} variable", () => { + const result = resolveResponsePrefixTemplate("[{modelFull}]", { + modelFull: "openai-codex/gpt-5.2", + }); + expect(result).toBe("[openai-codex/gpt-5.2]"); + }); + + it("resolves {provider} variable", () => { + const result = resolveResponsePrefixTemplate("[{provider}]", { + provider: "anthropic", + }); + expect(result).toBe("[anthropic]"); + }); + + it("resolves {thinkingLevel} variable", () => { + const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { + thinkingLevel: "high", + }); + expect(result).toBe("think:high"); + }); + + it("resolves {think} as alias for thinkingLevel", () => { + const result = resolveResponsePrefixTemplate("think:{think}", { + thinkingLevel: "low", + }); + expect(result).toBe("think:low"); + }); + + it("resolves {identity.name} variable", () => { + const result = resolveResponsePrefixTemplate("[{identity.name}]", { + identityName: "OpenClaw", + }); + expect(result).toBe("[OpenClaw]"); + }); + + it("resolves {identityName} as alias", () => { + const result = resolveResponsePrefixTemplate("[{identityName}]", { + identityName: "OpenClaw", + }); + expect(result).toBe("[OpenClaw]"); + }); + + it("resolves multiple variables", () => { + const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", { + model: "claude-opus-4-5", + thinkingLevel: "high", + }); + expect(result).toBe("[claude-opus-4-5 | think:high]"); + }); + + it("leaves unresolved variables as-is", () => { + const result = resolveResponsePrefixTemplate("[{model}]", {}); + expect(result).toBe("[{model}]"); + }); + + it("leaves unrecognized variables as-is", () => { + const result = resolveResponsePrefixTemplate("[{unknownVar}]", { + model: "gpt-5.2", + }); + expect(result).toBe("[{unknownVar}]"); + }); + + it("handles case insensitivity", () => { + const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { + model: "gpt-5.2", + thinkingLevel: "low", + }); + expect(result).toBe("[gpt-5.2 | low]"); + }); + + it("handles mixed resolved and unresolved variables", () => { + const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { + model: "gpt-5.2", + // provider not provided + }); + expect(result).toBe("[gpt-5.2 | {provider}]"); + }); + + it("handles complex template with all variables", () => { + const result = resolveResponsePrefixTemplate( + "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", + { + identityName: "OpenClaw", + provider: "anthropic", + model: "claude-opus-4-5", + thinkingLevel: "high", + }, + ); + expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); + }); +}); + +describe("extractShortModelName", () => { + it("strips provider prefix", () => { + expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2"); + expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5"); + expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); + }); + + it("strips date suffix", () => { + expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); + expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2"); + }); + + it("strips -latest suffix", () => { + expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); + expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet"); + }); + + it("handles model without provider", () => { + expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2"); + expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5"); + }); + + it("handles full path with provider and date suffix", () => { + expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); + }); + + it("preserves version numbers that look like dates but are not", () => { + // Date suffix must be exactly 8 digits at the end + expect(extractShortModelName("model-v1234567")).toBe("model-v1234567"); + expect(extractShortModelName("model-123456789")).toBe("model-123456789"); + }); +}); + +describe("hasTemplateVariables", () => { + it("returns false for undefined", () => { + expect(hasTemplateVariables(undefined)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(hasTemplateVariables("")).toBe(false); + }); + + it("returns false for static prefix", () => { + expect(hasTemplateVariables("[Claude]")).toBe(false); + }); + + it("returns true when template variables present", () => { + expect(hasTemplateVariables("[{model}]")).toBe(true); + expect(hasTemplateVariables("{provider}")).toBe(true); + expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true); + }); + + it("returns true for multiple variables", () => { + expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true); + }); + + it("handles consecutive calls correctly (regex lastIndex reset)", () => { + // First call + expect(hasTemplateVariables("[{model}]")).toBe(true); + // Second call should still work + expect(hasTemplateVariables("[{model}]")).toBe(true); + // Static string should return false + expect(hasTemplateVariables("[Claude]")).toBe(false); + }); +}); diff --git a/src/auto-reply/reply/response-prefix-template.test.ts b/src/auto-reply/reply/response-prefix-template.test.ts deleted file mode 100644 index 41c28e23ed9..00000000000 --- a/src/auto-reply/reply/response-prefix-template.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - extractShortModelName, - hasTemplateVariables, - resolveResponsePrefixTemplate, -} from "./response-prefix-template.js"; - -describe("resolveResponsePrefixTemplate", () => { - it("returns undefined for undefined template", () => { - expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); - }); - - it("returns template as-is when no variables present", () => { - expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); - }); - - it("resolves {model} variable", () => { - const result = resolveResponsePrefixTemplate("[{model}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[gpt-5.2]"); - }); - - it("resolves {modelFull} variable", () => { - const result = resolveResponsePrefixTemplate("[{modelFull}]", { - modelFull: "openai-codex/gpt-5.2", - }); - expect(result).toBe("[openai-codex/gpt-5.2]"); - }); - - it("resolves {provider} variable", () => { - const result = resolveResponsePrefixTemplate("[{provider}]", { - provider: "anthropic", - }); - expect(result).toBe("[anthropic]"); - }); - - it("resolves {thinkingLevel} variable", () => { - const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { - thinkingLevel: "high", - }); - expect(result).toBe("think:high"); - }); - - it("resolves {think} as alias for thinkingLevel", () => { - const result = resolveResponsePrefixTemplate("think:{think}", { - thinkingLevel: "low", - }); - expect(result).toBe("think:low"); - }); - - it("resolves {identity.name} variable", () => { - const result = resolveResponsePrefixTemplate("[{identity.name}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves {identityName} as alias", () => { - const result = resolveResponsePrefixTemplate("[{identityName}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves multiple variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", { - model: "claude-opus-4-5", - thinkingLevel: "high", - }); - expect(result).toBe("[claude-opus-4-5 | think:high]"); - }); - - it("leaves unresolved variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{model}]", {}); - expect(result).toBe("[{model}]"); - }); - - it("leaves unrecognized variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{unknownVar}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[{unknownVar}]"); - }); - - it("handles case insensitivity", () => { - const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { - model: "gpt-5.2", - thinkingLevel: "low", - }); - expect(result).toBe("[gpt-5.2 | low]"); - }); - - it("handles mixed resolved and unresolved variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { - model: "gpt-5.2", - // provider not provided - }); - expect(result).toBe("[gpt-5.2 | {provider}]"); - }); - - it("handles complex template with all variables", () => { - const result = resolveResponsePrefixTemplate( - "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", - { - identityName: "OpenClaw", - provider: "anthropic", - model: "claude-opus-4-5", - thinkingLevel: "high", - }, - ); - expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); - }); -}); - -describe("extractShortModelName", () => { - it("strips provider prefix", () => { - expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2"); - expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5"); - expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); - - it("strips date suffix", () => { - expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2"); - }); - - it("strips -latest suffix", () => { - expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); - expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet"); - }); - - it("handles model without provider", () => { - expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2"); - expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5"); - }); - - it("handles full path with provider and date suffix", () => { - expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - }); - - it("preserves version numbers that look like dates but are not", () => { - // Date suffix must be exactly 8 digits at the end - expect(extractShortModelName("model-v1234567")).toBe("model-v1234567"); - expect(extractShortModelName("model-123456789")).toBe("model-123456789"); - }); -}); - -describe("hasTemplateVariables", () => { - it("returns false for undefined", () => { - expect(hasTemplateVariables(undefined)).toBe(false); - }); - - it("returns false for empty string", () => { - expect(hasTemplateVariables("")).toBe(false); - }); - - it("returns false for static prefix", () => { - expect(hasTemplateVariables("[Claude]")).toBe(false); - }); - - it("returns true when template variables present", () => { - expect(hasTemplateVariables("[{model}]")).toBe(true); - expect(hasTemplateVariables("{provider}")).toBe(true); - expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true); - }); - - it("returns true for multiple variables", () => { - expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true); - }); - - it("handles consecutive calls correctly (regex lastIndex reset)", () => { - // First call - expect(hasTemplateVariables("[{model}]")).toBe(true); - // Second call should still work - expect(hasTemplateVariables("[{model}]")).toBe(true); - // Static string should return false - expect(hasTemplateVariables("[Claude]")).toBe(false); - }); -}); diff --git a/src/auto-reply/reply/session-resets.test.ts b/src/auto-reply/reply/session-resets.test.ts deleted file mode 100644 index 9c105c0307b..00000000000 --- a/src/auto-reply/reply/session-resets.test.ts +++ /dev/null @@ -1,689 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { buildModelAliasIndex } from "../../agents/model-selection.js"; -import { saveSessionStore } from "../../config/sessions.js"; -import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; -import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; -import { applyResetModelOverride } from "./session-reset-model.js"; -import { prependSystemEvents } from "./session-updates.js"; -import { initSessionState } from "./session.js"; - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "minimax", id: "m2.1", name: "M2.1" }, - { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, - ]), -})); - -let suiteRoot = ""; -let suiteCase = 0; - -beforeAll(async () => { - suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-resets-suite-")); -}); - -afterAll(async () => { - await fs.rm(suiteRoot, { recursive: true, force: true }); - suiteRoot = ""; - suiteCase = 0; -}); - -async function createStorePath(prefix: string): Promise { - const root = path.join(suiteRoot, `${prefix}${++suiteCase}`); - await fs.mkdir(root); - return path.join(root, "sessions.json"); -} - -describe("initSessionState reset triggers in WhatsApp groups", () => { - async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - sessionId: string; - }): Promise { - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - } - - function makeCfg(params: { storePath: string; allowFrom: string[] }): OpenClawConfig { - return { - session: { store: params.storePath, idleMinutes: 999 }, - channels: { - whatsapp: { - allowFrom: params.allowFrom, - groupPolicy: "open", - }, - }, - } as OpenClawConfig; - } - - it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { - const storePath = await createStorePath("openclaw-group-reset-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Peschiño", - SenderE164: "+41796666864", - SenderId: "41796666864:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { - const storePath = await createStorePath("openclaw-group-reset-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "OtherPerson", - SenderE164: "+1555123456", - SenderId: "1555123456:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); - - it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { - const storePath = await createStorePath("openclaw-group-rawbody-"); - const sessionKey = "agent:main:whatsapp:group:g1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["*"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+1111", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - SenderE164: "+1222", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Owner", - SenderE164: "+41796666864", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Other", - SenderE164: "+1555123456", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); -}); - -describe("initSessionState reset triggers in Slack channels", () => { - async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - sessionId: string; - }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - } - - it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-reset-"); - const sessionKey = "agent:main:slack:channel:c1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /reset", - RawBody: "<@U123> /reset", - CommandBody: "<@U123> /reset", - From: "slack:channel:C1", - To: "channel:C1", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-new-"); - const sessionKey = "agent:main:slack:channel:c2"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /new take notes", - RawBody: "<@U123> /new take notes", - CommandBody: "<@U123> /new take notes", - From: "slack:channel:C2", - To: "channel:C2", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe("take notes"); - }); -}); - -describe("applyResetModelOverride", () => { - it("selects a model hint and strips it from the body", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.providerOverride).toBe("minimax"); - expect(sessionEntry.modelOverride).toBe("m2.1"); - expect(sessionCtx.BodyStripped).toBe("summarize"); - }); - - it("clears auth profile overrides when reset applies a model", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - authProfileOverride: "anthropic:default", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 2, - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.authProfileOverride).toBeUndefined(); - expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); - expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); - }); - - it("skips when resetTriggered is false", async () => { - const cfg = {} as OpenClawConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: false, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.providerOverride).toBeUndefined(); - expect(sessionEntry.modelOverride).toBeUndefined(); - expect(sessionCtx.BodyStripped).toBe("minimax summarize"); - }); -}); - -describe("initSessionState preserves behavior overrides across /new and /reset", () => { - async function seedSessionStoreWithOverrides(params: { - storePath: string; - sessionKey: string; - sessionId: string; - overrides: Record; - }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - ...params.overrides, - }, - }); - } - - it("/new preserves verboseLevel from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-verbose-"); - const sessionKey = "agent:main:telegram:dm:user1"; - const existingSessionId = "existing-session-verbose"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { verboseLevel: "on" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user1", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.sessionEntry.verboseLevel).toBe("on"); - }); - - it("/reset preserves thinkingLevel and reasoningLevel from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-thinking-"); - const sessionKey = "agent:main:telegram:dm:user2"; - const existingSessionId = "existing-session-thinking"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { thinkingLevel: "full", reasoningLevel: "high" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/reset", - RawBody: "/reset", - CommandBody: "/reset", - From: "user2", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionEntry.thinkingLevel).toBe("full"); - expect(result.sessionEntry.reasoningLevel).toBe("high"); - }); - - it("/new preserves ttsAuto from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-tts-"); - const sessionKey = "agent:main:telegram:dm:user3"; - const existingSessionId = "existing-session-tts"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { ttsAuto: "on" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user3", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.sessionEntry.ttsAuto).toBe("on"); - }); - - it("archives previous transcript file on /new reset", async () => { - const storePath = await createStorePath("openclaw-reset-archive-"); - const sessionKey = "agent:main:telegram:dm:user-archive"; - const existingSessionId = "existing-session-archive"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: {}, - }); - const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`); - await fs.writeFile( - transcriptPath, - `${JSON.stringify({ message: { role: "user", content: "hello" } })}\n`, - "utf-8", - ); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user-archive", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - const files = await fs.readdir(path.dirname(storePath)); - expect(files.some((f) => f.startsWith(`${existingSessionId}.jsonl.reset.`))).toBe(true); - }); - - it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { - const storePath = await createStorePath("openclaw-idle-no-preserve-"); - const sessionKey = "agent:main:telegram:dm:new-user"; - - const cfg = { - session: { store: storePath, idleMinutes: 0 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "hello", - RawBody: "hello", - CommandBody: "hello", - From: "new-user", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(false); - expect(result.sessionEntry.verboseLevel).toBeUndefined(); - expect(result.sessionEntry.thinkingLevel).toBeUndefined(); - }); -}); - -describe("prependSystemEvents", () => { - it("adds a local timestamp to queued system events by default", async () => { - vi.useFakeTimers(); - try { - const timestamp = new Date("2026-01-12T20:19:17Z"); - const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true }); - vi.setSystemTime(timestamp); - - enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); - - const result = await prependSystemEvents({ - cfg: {} as OpenClawConfig, - sessionKey: "agent:main:main", - isMainSession: false, - isNewSession: false, - prefixedBodyBase: "User: hi", - }); - - expect(expectedTimestamp).toBeDefined(); - expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); - } finally { - resetSystemEventsForTest(); - vi.useRealTimers(); - } - }); -}); diff --git a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts b/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts deleted file mode 100644 index 5a90b4ed5f8..00000000000 --- a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { SessionEntry } from "../../config/sessions.js"; -import { incrementCompactionCount } from "./session-updates.js"; - -async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -describe("incrementCompactionCount", () => { - it("increments compaction count", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - const count = await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - }); - expect(count).toBe(3); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(3); - }); - - it("updates totalTokens when tokensAfter is provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - updatedAt: Date.now(), - compactionCount: 0, - totalTokens: 180_000, - inputTokens: 170_000, - outputTokens: 10_000, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - tokensAfter: 12_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(1); - expect(stored[sessionKey].totalTokens).toBe(12_000); - // input/output cleared since we only have the total estimate - expect(stored[sessionKey].inputTokens).toBeUndefined(); - expect(stored[sessionKey].outputTokens).toBeUndefined(); - }); - - it("does not update totalTokens when tokensAfter is not provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - updatedAt: Date.now(), - compactionCount: 0, - totalTokens: 180_000, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(1); - // totalTokens unchanged - expect(stored[sessionKey].totalTokens).toBe(180_000); - }); -}); diff --git a/src/auto-reply/reply/session-usage.test.ts b/src/auto-reply/reply/session-usage.test.ts deleted file mode 100644 index ab44c53ed29..00000000000 --- a/src/auto-reply/reply/session-usage.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { persistSessionUsageUpdate } from "./session-usage.js"; - -async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -describe("persistSessionUsageUpdate", () => { - it("uses lastCallUsage for totalTokens when provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now(), totalTokens: 100_000 }, - }); - - // Accumulated usage (sums all API calls) — inflated - const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; - // Last individual API call's usage — actual context after compaction - const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: accumulatedUsage, - lastCallUsage, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - // totalTokens should reflect lastCallUsage (12_000 input), not accumulated (180_000) - expect(stored[sessionKey].totalTokens).toBe(12_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - // inputTokens/outputTokens still reflect accumulated usage for cost tracking - expect(stored[sessionKey].inputTokens).toBe(180_000); - expect(stored[sessionKey].outputTokens).toBe(10_000); - }); - - it("marks totalTokens as unknown when no fresh context snapshot is available", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 50_000, output: 5_000, total: 55_000 }, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBeUndefined(); - expect(stored[sessionKey].totalTokensFresh).toBe(false); - }); - - it("uses promptTokens when available without lastCallUsage", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 50_000, output: 5_000, total: 55_000 }, - promptTokens: 42_000, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBe(42_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - }); - - it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 300_000, output: 10_000, total: 310_000 }, - lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBe(250_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 269279146d4..5eb8bedc65b 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -3,7 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { buildModelAliasIndex } from "../../agents/model-selection.js"; import { saveSessionStore } from "../../config/sessions.js"; +import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; +import { applyResetModelOverride } from "./session-reset-model.js"; +import { prependSystemEvents } from "./session-updates.js"; +import { persistSessionUsageUpdate } from "./session-usage.js"; import { initSessionState } from "./session.js"; // Perf: session-store locks are exercised elsewhere; most session tests don't need FS lock files. @@ -11,6 +17,13 @@ vi.mock("../../agents/session-write-lock.js", () => ({ acquireSessionWriteLock: async () => ({ release: async () => {} }), })); +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "minimax", id: "m2.1", name: "M2.1" }, + { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, + ]), +})); + let suiteRoot = ""; let suiteCase = 0; @@ -30,6 +43,13 @@ async function makeCaseDir(prefix: string): Promise { return dir; } +async function makeStorePath(prefix: string): Promise { + const root = await makeCaseDir(prefix); + return path.join(root, "sessions.json"); +} + +const createStorePath = makeStorePath; + describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -513,3 +533,762 @@ describe("initSessionState channel reset overrides", () => { expect(result.sessionEntry.sessionId).toBe(sessionId); }); }); + +describe("initSessionState reset triggers in WhatsApp groups", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + sessionId: string; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + } + + function makeCfg(params: { storePath: string; allowFrom: string[] }): OpenClawConfig { + return { + session: { store: params.storePath, idleMinutes: 999 }, + channels: { + whatsapp: { + allowFrom: params.allowFrom, + groupPolicy: "open", + }, + }, + } as OpenClawConfig; + } + + it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { + const storePath = await createStorePath("openclaw-group-reset-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Peschiño", + SenderE164: "+41796666864", + SenderId: "41796666864:0@s.whatsapp.net", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { + const storePath = await createStorePath("openclaw-group-reset-unauth-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "OtherPerson", + SenderE164: "+1555123456", + SenderId: "1555123456:0@s.whatsapp.net", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.sessionId).toBe(existingSessionId); + expect(result.isNewSession).toBe(false); + }); + + it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { + const storePath = await createStorePath("openclaw-group-rawbody-"); + const sessionKey = "agent:main:whatsapp:group:g1"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["*"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+1111", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + SenderE164: "+1222", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { + const storePath = await createStorePath("openclaw-group-reset-lid-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Owner", + SenderE164: "+41796666864", + SenderId: "123@lid", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { + const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Other", + SenderE164: "+1555123456", + SenderId: "123@lid", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.sessionId).toBe(existingSessionId); + expect(result.isNewSession).toBe(false); + }); +}); + +describe("initSessionState reset triggers in Slack channels", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + sessionId: string; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + } + + it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { + const storePath = await createStorePath("openclaw-slack-channel-reset-"); + const sessionKey = "agent:main:slack:channel:c1"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const channelMessageCtx = { + Body: "<@U123> /reset", + RawBody: "<@U123> /reset", + CommandBody: "<@U123> /reset", + From: "slack:channel:C1", + To: "channel:C1", + ChatType: "channel", + SessionKey: sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }; + + const result = await initSessionState({ + ctx: channelMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { + const storePath = await createStorePath("openclaw-slack-channel-new-"); + const sessionKey = "agent:main:slack:channel:c2"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const channelMessageCtx = { + Body: "<@U123> /new take notes", + RawBody: "<@U123> /new take notes", + CommandBody: "<@U123> /new take notes", + From: "slack:channel:C2", + To: "channel:C2", + ChatType: "channel", + SessionKey: sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }; + + const result = await initSessionState({ + ctx: channelMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe("take notes"); + }); +}); + +describe("applyResetModelOverride", () => { + it("selects a model hint and strips it from the body", async () => { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.providerOverride).toBe("minimax"); + expect(sessionEntry.modelOverride).toBe("m2.1"); + expect(sessionCtx.BodyStripped).toBe("summarize"); + }); + + it("clears auth profile overrides when reset applies a model", async () => { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + authProfileOverride: "anthropic:default", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.authProfileOverride).toBeUndefined(); + expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); + expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); + }); + + it("skips when resetTriggered is false", async () => { + const cfg = {} as OpenClawConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: false, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.providerOverride).toBeUndefined(); + expect(sessionEntry.modelOverride).toBeUndefined(); + expect(sessionCtx.BodyStripped).toBe("minimax summarize"); + }); +}); + +describe("initSessionState preserves behavior overrides across /new and /reset", () => { + async function seedSessionStoreWithOverrides(params: { + storePath: string; + sessionKey: string; + sessionId: string; + overrides: Record; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + ...params.overrides, + }, + }); + } + + it("/new preserves verboseLevel from previous session", async () => { + const storePath = await createStorePath("openclaw-reset-verbose-"); + const sessionKey = "agent:main:telegram:dm:user1"; + const existingSessionId = "existing-session-verbose"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { verboseLevel: "on" }, + }); + await fs.writeFile( + path.join(path.dirname(storePath), `${existingSessionId}.jsonl`), + "", + "utf-8", + ); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user1", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.verboseLevel).toBe("on"); + }); + + it("/reset preserves thinkingLevel and reasoningLevel from previous session", async () => { + const storePath = await createStorePath("openclaw-reset-thinking-"); + const sessionKey = "agent:main:telegram:dm:user2"; + const existingSessionId = "existing-session-thinking"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { thinkingLevel: "high", reasoningLevel: "low" }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/reset", + RawBody: "/reset", + CommandBody: "/reset", + From: "user2", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.thinkingLevel).toBe("high"); + expect(result.sessionEntry.reasoningLevel).toBe("low"); + }); + + it("/new in a new session does not preserve overrides", async () => { + const storePath = await createStorePath("openclaw-new-no-preserve-"); + const sessionKey = "agent:main:telegram:dm:user3"; + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user3", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionEntry.verboseLevel).toBeUndefined(); + expect(result.sessionEntry.thinkingLevel).toBeUndefined(); + }); + + it("archives the old session store entry on /new", async () => { + const storePath = await createStorePath("openclaw-archive-old-"); + const sessionKey = "agent:main:telegram:dm:user-archive"; + const existingSessionId = "existing-session-archive"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { verboseLevel: "on" }, + }); + const sessionUtils = await import("../../gateway/session-utils.fs.js"); + const archiveSpy = vi.spyOn(sessionUtils, "archiveSessionTranscripts"); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user-archive", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(archiveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: existingSessionId, + storePath, + reason: "reset", + }), + ); + archiveSpy.mockRestore(); + }); + + it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { + const storePath = await createStorePath("openclaw-idle-no-preserve-"); + const sessionKey = "agent:main:telegram:dm:new-user"; + + const cfg = { + session: { store: storePath, idleMinutes: 0 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "new-user", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(false); + expect(result.sessionEntry.verboseLevel).toBeUndefined(); + expect(result.sessionEntry.thinkingLevel).toBeUndefined(); + }); +}); + +describe("prependSystemEvents", () => { + it("adds a local timestamp to queued system events by default", async () => { + vi.useFakeTimers(); + try { + const timestamp = new Date("2026-01-12T20:19:17Z"); + const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true }); + vi.setSystemTime(timestamp); + + enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); + + const result = await prependSystemEvents({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + isMainSession: false, + isNewSession: false, + prefixedBodyBase: "User: hi", + }); + + expect(expectedTimestamp).toBeDefined(); + expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); + } finally { + resetSystemEventsForTest(); + vi.useRealTimers(); + } + }); +}); + +describe("persistSessionUsageUpdate", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; + }) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); + } + + it("uses lastCallUsage for totalTokens when provided", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now(), totalTokens: 100_000 }, + }); + + const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; + const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: accumulatedUsage, + lastCallUsage, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(12_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + expect(stored[sessionKey].inputTokens).toBe(180_000); + expect(stored[sessionKey].outputTokens).toBe(10_000); + }); + + it("marks totalTokens as unknown when no fresh context snapshot is available", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 50_000, output: 5_000, total: 55_000 }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBeUndefined(); + expect(stored[sessionKey].totalTokensFresh).toBe(false); + }); + + it("uses promptTokens when available without lastCallUsage", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 50_000, output: 5_000, total: 55_000 }, + promptTokens: 42_000, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(42_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + }); + + it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 300_000, output: 10_000, total: 310_000 }, + lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(250_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + }); +}); diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts deleted file mode 100644 index b66a70680da..00000000000 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; -import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; -import { - formatRunLabel, - formatRunStatus, - resolveSubagentLabel, - sortSubagentRuns, -} from "./subagents-utils.js"; - -const baseRun: SubagentRunRecord = { - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, -}; - -describe("subagents utils", () => { - it("resolves labels from label, task, or fallback", () => { - expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label"); - expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task"); - expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe( - "fallback", - ); - }); - - it("formats run labels with truncation", () => { - const long = "x".repeat(100); - const run = { ...baseRun, label: long }; - const formatted = formatRunLabel(run, { maxLength: 10 }); - expect(formatted.startsWith("x".repeat(10))).toBe(true); - expect(formatted.endsWith("…")).toBe(true); - }); - - it("sorts subagent runs by newest start/created time", () => { - const runs: SubagentRunRecord[] = [ - { ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 }, - { ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 }, - { ...baseRun, runId: "run-3", createdAt: 900 }, - ]; - const sorted = sortSubagentRuns(runs); - expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); - }); - - it("formats run status from outcome and timestamps", () => { - expect(formatRunStatus({ ...baseRun })).toBe("running"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( - "timeout", - ); - }); - - it("formats duration compact for seconds and minutes", () => { - expect(formatDurationCompact(45_000)).toBe("45s"); - expect(formatDurationCompact(65_000)).toBe("1m5s"); - }); -}); diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts deleted file mode 100644 index edefc57f8ee..00000000000 --- a/src/auto-reply/reply/typing.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createMockTypingController } from "./test-helpers.js"; -import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; -import { createTypingController } from "./typing.js"; - -describe("typing controller", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("stops after run completion and dispatcher idle", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(1_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - }); - - it("keeps typing until both idle and run completion are set", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - }); - - it("does not start typing after run completion", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - typing.markRunComplete(); - await typing.startTypingOnText("late text"); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).not.toHaveBeenCalled(); - }); - - it("does not restart typing after it has stopped", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markRunComplete(); - typing.markDispatchIdle(); - - vi.advanceTimersByTime(5_000); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - // Late callbacks should be ignored and must not restart the interval. - await typing.startTypingOnText("late tool result"); - vi.advanceTimersByTime(5_000); - expect(onReplyStart).toHaveBeenCalledTimes(1); - }); -}); - -describe("resolveTypingMode", () => { - it("defaults to instant for direct chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("defaults to message for group chats without mentions", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("defaults to instant for mentioned group chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("honors configured mode across contexts", () => { - expect( - resolveTypingMode({ - configured: "thinking", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("thinking"); - expect( - resolveTypingMode({ - configured: "message", - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("forces never for heartbeat runs", () => { - expect( - resolveTypingMode({ - configured: "instant", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: true, - }), - ).toBe("never"); - }); -}); - -describe("createTypingSignaler", () => { - it("signals immediately for instant mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: false, - }); - - await signaler.signalRunStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("signals on text for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals on message start for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalMessageStart(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - await signaler.signalTextDelta("hello"); - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - }); - - it("signals on reasoning for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalReasoningDelta(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - await signaler.signalTextDelta("hi"); - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("refreshes ttl on text for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hi"); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("starts typing on tool start before text", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalToolStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("refreshes ttl on tool start when active after text", async () => { - const typing = createMockTypingController({ - isActive: vi.fn(() => true), - }); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - typing.startTypingLoop.mockClear(); - typing.startTypingOnText.mockClear(); - typing.refreshTypingTtl.mockClear(); - await signaler.signalToolStart(); - - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("suppresses typing when disabled", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: true, - }); - - await signaler.signalRunStart(); - await signaler.signalTextDelta("hi"); - await signaler.signalReasoningDelta(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); -}); diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts new file mode 100644 index 00000000000..ab23bca95e7 --- /dev/null +++ b/src/browser/browser-utils.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import type { BrowserServerState } from "./server-context.js"; +import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; +import { __test } from "./client-fetch.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; +import { shouldRejectBrowserMutation } from "./csrf.js"; +import { toBoolean } from "./routes/utils.js"; +import { listKnownProfileNames } from "./server-context.js"; +import { resolveTargetIdFromTabs } from "./target-id.js"; + +describe("toBoolean", () => { + it("parses yes/no and 1/0", () => { + expect(toBoolean("yes")).toBe(true); + expect(toBoolean("1")).toBe(true); + expect(toBoolean("no")).toBe(false); + expect(toBoolean("0")).toBe(false); + }); + + it("returns undefined for on/off strings", () => { + expect(toBoolean("on")).toBeUndefined(); + expect(toBoolean("off")).toBeUndefined(); + }); + + it("passes through boolean values", () => { + expect(toBoolean(true)).toBe(true); + expect(toBoolean(false)).toBe(false); + }); +}); + +describe("browser target id resolution", () => { + it("resolves exact ids", () => { + const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]); + expect(res).toEqual({ ok: true, targetId: "FULL" }); + }); + + it("resolves unique prefixes (case-insensitive)", () => { + const res = resolveTargetIdFromTabs("57a01309", [ + { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, + ]); + expect(res).toEqual({ + ok: true, + targetId: "57A01309E14B5DEE0FB41F908515A2FC", + }); + }); + + it("fails on ambiguous prefixes", () => { + const res = resolveTargetIdFromTabs("57A0", [ + { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, + { targetId: "57A0BEEF000000000000000000000000" }, + ]); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.reason).toBe("ambiguous"); + expect(res.matches?.length).toBe(2); + } + }); + + it("fails when no tab matches", () => { + const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]); + expect(res).toEqual({ ok: false, reason: "not_found" }); + }); +}); + +describe("browser CSRF loopback mutation guard", () => { + it("rejects mutating methods from non-loopback origin", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "https://evil.example", + }), + ).toBe(true); + }); + + it("allows mutating methods from loopback origin", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "http://127.0.0.1:18789", + }), + ).toBe(false); + + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "http://localhost:18789", + }), + ).toBe(false); + }); + + it("allows mutating methods without origin/referer (non-browser clients)", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + }), + ).toBe(false); + }); + + it("rejects mutating methods with origin=null", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + origin: "null", + }), + ).toBe(true); + }); + + it("rejects mutating methods from non-loopback referer", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + referer: "https://evil.example/attack", + }), + ).toBe(true); + }); + + it("rejects cross-site mutations via Sec-Fetch-Site when present", () => { + expect( + shouldRejectBrowserMutation({ + method: "POST", + secFetchSite: "cross-site", + }), + ).toBe(true); + }); + + it("does not reject non-mutating methods", () => { + expect( + shouldRejectBrowserMutation({ + method: "GET", + origin: "https://evil.example", + }), + ).toBe(false); + + expect( + shouldRejectBrowserMutation({ + method: "OPTIONS", + origin: "https://evil.example", + }), + ).toBe(false); + }); +}); + +describe("cdp.helpers", () => { + it("preserves query params when appending CDP paths", () => { + const url = appendCdpPath("https://example.com?token=abc", "/json/version"); + expect(url).toBe("https://example.com/json/version?token=abc"); + }); + + it("appends paths under a base prefix", () => { + const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list"); + expect(url).toBe("https://example.com/chrome/json/list?token=abc"); + }); + + it("adds basic auth headers when credentials are present", () => { + const headers = getHeadersWithAuth("https://user:pass@example.com"); + expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("keeps preexisting authorization headers", () => { + const headers = getHeadersWithAuth("https://user:pass@example.com", { + Authorization: "Bearer token", + }); + expect(headers.Authorization).toBe("Bearer token"); + }); +}); + +describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { + it("falls back to per-port bridge auth when config auth is not available", async () => { + const port = 18765; + const getBridgeAuthForPort = vi.fn((candidate: number) => + candidate === port ? { token: "registry-token" } : undefined, + ); + const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, { + loadConfig: () => ({}), + resolveBrowserControlAuth: () => ({}), + getBridgeAuthForPort, + }); + const headers = new Headers(init.headers ?? {}); + expect(headers.get("authorization")).toBe("Bearer registry-token"); + expect(getBridgeAuthForPort).toHaveBeenCalledWith(port); + }); +}); + +describe("browser server-context listKnownProfileNames", () => { + it("includes configured and runtime-only profile names", () => { + const resolved = resolveBrowserConfig({ + defaultProfile: "openclaw", + profiles: { + openclaw: { cdpPort: 18800, color: "#FF4500" }, + }, + }); + const openclaw = resolveProfile(resolved, "openclaw"); + if (!openclaw) { + throw new Error("expected openclaw profile"); + } + + const state: BrowserServerState = { + server: null as unknown as BrowserServerState["server"], + port: 18791, + resolved, + profiles: new Map([ + [ + "stale-removed", + { + profile: { ...openclaw, name: "stale-removed" }, + running: null, + }, + ], + ]), + }; + + expect(listKnownProfileNames(state).toSorted()).toEqual([ + "chrome", + "openclaw", + "stale-removed", + ]); + }); +}); diff --git a/src/browser/cdp.helpers.test.ts b/src/browser/cdp.helpers.test.ts deleted file mode 100644 index b41864ee431..00000000000 --- a/src/browser/cdp.helpers.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; - -describe("cdp.helpers", () => { - it("preserves query params when appending CDP paths", () => { - const url = appendCdpPath("https://example.com?token=abc", "/json/version"); - expect(url).toBe("https://example.com/json/version?token=abc"); - }); - - it("appends paths under a base prefix", () => { - const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list"); - expect(url).toBe("https://example.com/chrome/json/list?token=abc"); - }); - - it("adds basic auth headers when credentials are present", () => { - const headers = getHeadersWithAuth("https://user:pass@example.com"); - expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); - }); - - it("keeps preexisting authorization headers", () => { - const headers = getHeadersWithAuth("https://user:pass@example.com", { - Authorization: "Bearer token", - }); - expect(headers.Authorization).toBe("Bearer token"); - }); -}); diff --git a/src/browser/client-fetch.bridge-auth-registry.test.ts b/src/browser/client-fetch.bridge-auth-registry.test.ts deleted file mode 100644 index 8e8ef5848b6..00000000000 --- a/src/browser/client-fetch.bridge-auth-registry.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { __test } from "./client-fetch.js"; - -describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { - it("falls back to per-port bridge auth when config auth is not available", async () => { - const port = 18765; - const getBridgeAuthForPort = vi.fn((candidate: number) => - candidate === port ? { token: "registry-token" } : undefined, - ); - const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, { - loadConfig: () => ({}), - resolveBrowserControlAuth: () => ({}), - getBridgeAuthForPort, - }); - const headers = new Headers(init.headers ?? {}); - expect(headers.get("authorization")).toBe("Bearer registry-token"); - expect(getBridgeAuthForPort).toHaveBeenCalledWith(port); - }); -}); diff --git a/src/browser/csrf.test.ts b/src/browser/csrf.test.ts deleted file mode 100644 index 6f4bedd692f..00000000000 --- a/src/browser/csrf.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldRejectBrowserMutation } from "./csrf.js"; - -describe("browser CSRF loopback mutation guard", () => { - it("rejects mutating methods from non-loopback origin", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "https://evil.example", - }), - ).toBe(true); - }); - - it("allows mutating methods from loopback origin", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "http://127.0.0.1:18789", - }), - ).toBe(false); - - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "http://localhost:18789", - }), - ).toBe(false); - }); - - it("allows mutating methods without origin/referer (non-browser clients)", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - }), - ).toBe(false); - }); - - it("rejects mutating methods with origin=null", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - origin: "null", - }), - ).toBe(true); - }); - - it("rejects mutating methods from non-loopback referer", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - referer: "https://evil.example/attack", - }), - ).toBe(true); - }); - - it("rejects cross-site mutations via Sec-Fetch-Site when present", () => { - expect( - shouldRejectBrowserMutation({ - method: "POST", - secFetchSite: "cross-site", - }), - ).toBe(true); - }); - - it("does not reject non-mutating methods", () => { - expect( - shouldRejectBrowserMutation({ - method: "GET", - origin: "https://evil.example", - }), - ).toBe(false); - - expect( - shouldRejectBrowserMutation({ - method: "OPTIONS", - origin: "https://evil.example", - }), - ).toBe(false); - }); -}); diff --git a/src/browser/routes/utils.test.ts b/src/browser/routes/utils.test.ts deleted file mode 100644 index 4f7762a944e..00000000000 --- a/src/browser/routes/utils.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { toBoolean } from "./utils.js"; - -describe("toBoolean", () => { - it("parses yes/no and 1/0", () => { - expect(toBoolean("yes")).toBe(true); - expect(toBoolean("1")).toBe(true); - expect(toBoolean("no")).toBe(false); - expect(toBoolean("0")).toBe(false); - }); - - it("returns undefined for on/off strings", () => { - expect(toBoolean("on")).toBeUndefined(); - expect(toBoolean("off")).toBeUndefined(); - }); - - it("passes through boolean values", () => { - expect(toBoolean(true)).toBe(true); - expect(toBoolean(false)).toBe(false); - }); -}); diff --git a/src/browser/server-context.list-known-profile-names.test.ts b/src/browser/server-context.list-known-profile-names.test.ts deleted file mode 100644 index 04c897563e9..00000000000 --- a/src/browser/server-context.list-known-profile-names.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { BrowserServerState } from "./server-context.js"; -import { resolveBrowserConfig, resolveProfile } from "./config.js"; -import { listKnownProfileNames } from "./server-context.js"; - -describe("browser server-context listKnownProfileNames", () => { - it("includes configured and runtime-only profile names", () => { - const resolved = resolveBrowserConfig({ - defaultProfile: "openclaw", - profiles: { - openclaw: { cdpPort: 18800, color: "#FF4500" }, - }, - }); - const openclaw = resolveProfile(resolved, "openclaw"); - if (!openclaw) { - throw new Error("expected openclaw profile"); - } - - const state: BrowserServerState = { - server: null as unknown as BrowserServerState["server"], - port: 18791, - resolved, - profiles: new Map([ - [ - "stale-removed", - { - profile: { ...openclaw, name: "stale-removed" }, - running: null, - }, - ], - ]), - }; - - expect(listKnownProfileNames(state).toSorted()).toEqual([ - "chrome", - "openclaw", - "stale-removed", - ]); - }); -}); diff --git a/src/browser/target-id.test.ts b/src/browser/target-id.test.ts deleted file mode 100644 index a63b6aedbf3..00000000000 --- a/src/browser/target-id.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveTargetIdFromTabs } from "./target-id.js"; - -describe("browser target id resolution", () => { - it("resolves exact ids", () => { - const res = resolveTargetIdFromTabs("FULL", [{ targetId: "AAA" }, { targetId: "FULL" }]); - expect(res).toEqual({ ok: true, targetId: "FULL" }); - }); - - it("resolves unique prefixes (case-insensitive)", () => { - const res = resolveTargetIdFromTabs("57a01309", [ - { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, - ]); - expect(res).toEqual({ - ok: true, - targetId: "57A01309E14B5DEE0FB41F908515A2FC", - }); - }); - - it("fails on ambiguous prefixes", () => { - const res = resolveTargetIdFromTabs("57A0", [ - { targetId: "57A01309E14B5DEE0FB41F908515A2FC" }, - { targetId: "57A0BEEF000000000000000000000000" }, - ]); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.reason).toBe("ambiguous"); - expect(res.matches?.length).toBe(2); - } - }); - - it("fails when no tab matches", () => { - const res = resolveTargetIdFromTabs("NOPE", [{ targetId: "AAA" }]); - expect(res).toEqual({ ok: false, reason: "not_found" }); - }); -}); diff --git a/src/channel-web.barrel.test.ts b/src/channel-web.barrel.test.ts deleted file mode 100644 index 0c52598c3e2..00000000000 --- a/src/channel-web.barrel.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import * as mod from "./channel-web.js"; - -describe("channel-web barrel", () => { - it("exports the expected web helpers", () => { - expect(mod.createWaSocket).toBeTypeOf("function"); - expect(mod.loginWeb).toBeTypeOf("function"); - expect(mod.monitorWebChannel).toBeTypeOf("function"); - expect(mod.sendMessageWhatsApp).toBeTypeOf("function"); - expect(mod.monitorWebInbox).toBeTypeOf("function"); - expect(mod.pickWebChannel).toBeTypeOf("function"); - expect(mod.WA_WEB_AUTH_DIR).toBeTruthy(); - }); -}); diff --git a/src/channels/channels-misc.test.ts b/src/channels/channels-misc.test.ts new file mode 100644 index 00000000000..3eb51c509ac --- /dev/null +++ b/src/channels/channels-misc.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import * as channelWeb from "../channel-web.js"; +import { normalizeChatType } from "./chat-type.js"; +import * as webEntry from "./web/index.js"; + +describe("channel-web barrel", () => { + it("exports the expected web helpers", () => { + expect(channelWeb.createWaSocket).toBeTypeOf("function"); + expect(channelWeb.loginWeb).toBeTypeOf("function"); + expect(channelWeb.monitorWebChannel).toBeTypeOf("function"); + expect(channelWeb.sendMessageWhatsApp).toBeTypeOf("function"); + expect(channelWeb.monitorWebInbox).toBeTypeOf("function"); + expect(channelWeb.pickWebChannel).toBeTypeOf("function"); + expect(channelWeb.WA_WEB_AUTH_DIR).toBeTruthy(); + }); +}); + +describe("normalizeChatType", () => { + it("normalizes common inputs", () => { + expect(normalizeChatType("direct")).toBe("direct"); + expect(normalizeChatType("dm")).toBe("direct"); + expect(normalizeChatType("group")).toBe("group"); + expect(normalizeChatType("channel")).toBe("channel"); + }); + + it("returns undefined for empty/unknown values", () => { + expect(normalizeChatType(undefined)).toBeUndefined(); + expect(normalizeChatType("")).toBeUndefined(); + expect(normalizeChatType("nope")).toBeUndefined(); + expect(normalizeChatType("room")).toBeUndefined(); + }); + + describe("backward compatibility", () => { + it("accepts legacy 'dm' value and normalizes to 'direct'", () => { + // Legacy config/input may use "dm" - ensure smooth upgrade path + expect(normalizeChatType("dm")).toBe("direct"); + expect(normalizeChatType("DM")).toBe("direct"); + expect(normalizeChatType(" dm ")).toBe("direct"); + }); + }); +}); + +describe("channels/web entrypoint", () => { + it("re-exports web channel helpers", () => { + expect(webEntry.createWaSocket).toBe(channelWeb.createWaSocket); + expect(webEntry.loginWeb).toBe(channelWeb.loginWeb); + expect(webEntry.logWebSelfId).toBe(channelWeb.logWebSelfId); + expect(webEntry.monitorWebInbox).toBe(channelWeb.monitorWebInbox); + expect(webEntry.monitorWebChannel).toBe(channelWeb.monitorWebChannel); + expect(webEntry.pickWebChannel).toBe(channelWeb.pickWebChannel); + expect(webEntry.sendMessageWhatsApp).toBe(channelWeb.sendMessageWhatsApp); + expect(webEntry.WA_WEB_AUTH_DIR).toBe(channelWeb.WA_WEB_AUTH_DIR); + expect(webEntry.waitForWaConnection).toBe(channelWeb.waitForWaConnection); + expect(webEntry.webAuthExists).toBe(channelWeb.webAuthExists); + }); +}); diff --git a/src/channels/chat-type.test.ts b/src/channels/chat-type.test.ts deleted file mode 100644 index 6775c8cac8e..00000000000 --- a/src/channels/chat-type.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeChatType } from "./chat-type.js"; - -describe("normalizeChatType", () => { - it("normalizes common inputs", () => { - expect(normalizeChatType("direct")).toBe("direct"); - expect(normalizeChatType("dm")).toBe("direct"); - expect(normalizeChatType("group")).toBe("group"); - expect(normalizeChatType("channel")).toBe("channel"); - }); - - it("returns undefined for empty/unknown values", () => { - expect(normalizeChatType(undefined)).toBeUndefined(); - expect(normalizeChatType("")).toBeUndefined(); - expect(normalizeChatType("nope")).toBeUndefined(); - expect(normalizeChatType("room")).toBeUndefined(); - }); - - describe("backward compatibility", () => { - it("accepts legacy 'dm' value and normalizes to 'direct'", () => { - // Legacy config/input may use "dm" - ensure smooth upgrade path - expect(normalizeChatType("dm")).toBe("direct"); - expect(normalizeChatType("DM")).toBe("direct"); - expect(normalizeChatType(" dm ")).toBe("direct"); - }); - }); -}); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts new file mode 100644 index 00000000000..1b33f41377c --- /dev/null +++ b/src/channels/plugins/actions/actions.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); +const handleTelegramAction = vi.fn(async () => ({ ok: true })); +const sendReactionSignal = vi.fn(async () => ({ ok: true })); +const removeReactionSignal = vi.fn(async () => ({ ok: true })); +const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); + +vi.mock("../../../agents/tools/discord-actions.js", () => ({ + handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), +})); + +vi.mock("../../../agents/tools/telegram-actions.js", () => ({ + handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), +})); + +vi.mock("../../../signal/send-reactions.js", () => ({ + sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args), + removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args), +})); + +vi.mock("../../../agents/tools/slack-actions.js", () => ({ + handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), +})); + +const { discordMessageActions } = await import("./discord.js"); +const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); +const { telegramMessageActions } = await import("./telegram.js"); +const { signalMessageActions } = await import("./signal.js"); +const { createSlackActions } = await import("../slack.actions.js"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("discord message actions", () => { + it("lists channel and upload actions by default", async () => { + const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).toContain("emoji-upload"); + expect(actions).toContain("sticker-upload"); + expect(actions).toContain("channel-create"); + }); + + it("respects disabled channel actions", async () => { + const cfg = { + channels: { discord: { token: "d0", actions: { channels: false } } }, + } as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).not.toContain("channel-create"); + }); +}); + +describe("handleDiscordMessageAction", () => { + it("forwards context accountId for send", async () => { + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + accountId: "ops", + to: "channel:123", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("forwards legacy embeds for send", async () => { + const embeds = [{ title: "Legacy", description: "Use components v2." }]; + + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + embeds, + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "channel:123", + content: "hi", + embeds, + }), + expect.any(Object), + ); + }); + + it("falls back to params accountId when context missing", async () => { + await handleDiscordMessageAction({ + action: "poll", + params: { + to: "channel:123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + accountId: "marve", + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + accountId: "marve", + to: "channel:123", + question: "Ready?", + answers: ["Yes", "No"], + }), + expect.any(Object), + ); + }); + + it("forwards accountId for thread replies", async () => { + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + channelId: "123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadReply", + accountId: "ops", + channelId: "123", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("accepts threadId for thread replies (tool compatibility)", async () => { + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + // The `message` tool uses `threadId`. + threadId: "999", + // Include a conflicting channelId to ensure threadId takes precedence. + channelId: "123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadReply", + accountId: "ops", + channelId: "999", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("forwards thread-create message as content", async () => { + await handleDiscordMessageAction({ + action: "thread-create", + params: { + to: "channel:123456789", + threadName: "Forum thread", + message: "Initial forum post body", + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadCreate", + channelId: "123456789", + name: "Forum thread", + content: "Initial forum post body", + }), + expect.any(Object), + ); + }); + + it("forwards thread edit fields for channel-edit", async () => { + await handleDiscordMessageAction({ + action: "channel-edit", + params: { + channelId: "123456789", + archived: true, + locked: false, + autoArchiveDuration: 1440, + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "channelEdit", + channelId: "123456789", + archived: true, + locked: false, + autoArchiveDuration: 1440, + }), + expect.any(Object), + ); + }); +}); + +describe("telegramMessageActions", () => { + it("excludes sticker actions when not enabled", () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const actions = telegramMessageActions.listActions({ cfg }); + expect(actions).not.toContain("sticker"); + expect(actions).not.toContain("sticker-search"); + }); + + it("allows media-only sends and passes asVoice", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "send", + params: { + to: "123", + media: "https://example.com/voice.ogg", + asVoice: true, + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "123", + content: "", + mediaUrl: "https://example.com/voice.ogg", + asVoice: true, + }), + cfg, + ); + }); + + it("passes silent flag for silent sends", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "send", + params: { + to: "456", + message: "Silent notification test", + silent: true, + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "456", + content: "Silent notification test", + silent: true, + }), + cfg, + ); + }); + + it("maps edit action params into editMessage", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: 42, + message: "Updated", + buttons: [], + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + { + action: "editMessage", + chatId: "123", + messageId: 42, + content: "Updated", + buttons: [], + accountId: undefined, + }, + cfg, + ); + }); + + it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await expect( + telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: "nope", + message: "Updated", + }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(); + + expect(handleTelegramAction).not.toHaveBeenCalled(); + }); + + it("accepts numeric messageId and channelId for reactions", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "react", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0] as Record; + expect(call.action).toBe("react"); + expect(String(call.chatId)).toBe("123"); + expect(String(call.messageId)).toBe("456"); + expect(call.emoji).toBe("ok"); + }); +}); + +describe("signalMessageActions", () => { + it("returns no actions when no configured accounts exist", () => { + const cfg = {} as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual([]); + }); + + it("hides react when reactions are disabled", () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]); + }); + + it("enables react when at least one account allows reactions", () => { + const cfg = { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]); + }); + + it("skips send for plugin dispatch", () => { + expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); + expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); + }); + + it("blocks reactions when action gate is disabled", async () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction({ + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "✅" }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(/actions\.reactions/); + }); + + it("uses account-level actions when enabled", async () => { + const cfg = { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "👍" }, + cfg, + accountId: "work", + }); + + expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", { + accountId: "work", + }); + }); + + it("normalizes uuid recipients", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { + recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "🔥", + }, + cfg, + accountId: undefined, + }); + + expect(sendReactionSignal).toHaveBeenCalledWith( + "123e4567-e89b-12d3-a456-426614174000", + 123, + "🔥", + { accountId: undefined }, + ); + }); + + it("requires targetAuthor for group reactions", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction({ + action: "react", + params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(/targetAuthor/); + }); + + it("passes groupId and targetAuthor for group reactions", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { + to: "signal:group:group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "✅", + }, + cfg, + accountId: undefined, + }); + + expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", { + accountId: undefined, + groupId: "group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + targetAuthorUuid: undefined, + }); + }); +}); + +describe("slack actions adapter", () => { + it("forwards threadId for read", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "read", + cfg, + params: { + channelId: "C1", + threadId: "171234.567", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "readMessages", + channelId: "C1", + threadId: "171234.567", + }); + }); + + it("forwards normalized limit for emoji-list", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "emoji-list", + cfg, + params: { + limit: "2.9", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "emojiList", + limit: 2, + }); + }); +}); diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts deleted file mode 100644 index 63426c89c26..00000000000 --- a/src/channels/plugins/actions/discord.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord; -type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord; - -const sendMessageDiscord = vi.fn, ReturnType>( - async () => ({ ok: true }) as Awaited>, -); -const sendPollDiscord = vi.fn, ReturnType>( - async () => ({ ok: true }) as Awaited>, -); - -vi.mock("../../../discord/send.js", async () => { - const actual = await vi.importActual( - "../../../discord/send.js", - ); - return { - ...actual, - sendMessageDiscord: (...args: Parameters) => sendMessageDiscord(...args), - sendPollDiscord: (...args: Parameters) => sendPollDiscord(...args), - }; -}); - -const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); -const { discordMessageActions } = await import("./discord.js"); - -describe("discord message actions", () => { - it("lists channel and upload actions by default", async () => { - const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("emoji-upload"); - expect(actions).toContain("sticker-upload"); - expect(actions).toContain("channel-create"); - }); - - it("respects disabled channel actions", async () => { - const cfg = { - channels: { discord: { token: "d0", actions: { channels: false } } }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("channel-create"); - }); -}); - -describe("handleDiscordMessageAction", () => { - it("forwards context accountId for send", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "send", - params: { - to: "channel:123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); - - it("forwards legacy embeds for send", async () => { - sendMessageDiscord.mockClear(); - - const embeds = [{ title: "Legacy", description: "Use components v2." }]; - - await handleDiscordMessageAction({ - action: "send", - params: { - to: "channel:123", - message: "hi", - embeds, - }, - cfg: {} as OpenClawConfig, - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - embeds, - }), - ); - }); - - it("falls back to params accountId when context missing", async () => { - sendPollDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "poll", - params: { - to: "channel:123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - accountId: "marve", - }, - cfg: {} as OpenClawConfig, - }); - - expect(sendPollDiscord).toHaveBeenCalledWith( - "channel:123", - expect.objectContaining({ - question: "Ready?", - options: ["Yes", "No"], - }), - expect.objectContaining({ - accountId: "marve", - }), - ); - }); - - it("forwards accountId for thread replies", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "thread-reply", - params: { - channelId: "123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); - - it("accepts threadId for thread replies (tool compatibility)", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "thread-reply", - params: { - // The `message` tool uses `threadId`. - threadId: "999", - // Include a conflicting channelId to ensure threadId takes precedence. - channelId: "123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:999", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); -}); diff --git a/src/channels/plugins/actions/discord/handle-action.test.ts b/src/channels/plugins/actions/discord/handle-action.test.ts deleted file mode 100644 index 425c7d5a50e..00000000000 --- a/src/channels/plugins/actions/discord/handle-action.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { handleDiscordMessageAction } from "./handle-action.js"; - -const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); - -vi.mock("../../../../agents/tools/discord-actions.js", () => ({ - handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), -})); - -describe("handleDiscordMessageAction", () => { - beforeEach(() => { - handleDiscordAction.mockClear(); - }); - - it("forwards thread-create message as content", async () => { - await handleDiscordMessageAction({ - action: "thread-create", - params: { - to: "channel:123456789", - threadName: "Forum thread", - message: "Initial forum post body", - }, - cfg: {}, - }); - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "threadCreate", - channelId: "123456789", - name: "Forum thread", - content: "Initial forum post body", - }), - expect.any(Object), - ); - }); - - it("forwards thread edit fields for channel-edit", async () => { - await handleDiscordMessageAction({ - action: "channel-edit", - params: { - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }, - cfg: {}, - }); - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "channelEdit", - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }), - expect.any(Object), - ); - }); -}); diff --git a/src/channels/plugins/actions/signal.test.ts b/src/channels/plugins/actions/signal.test.ts deleted file mode 100644 index 613b725f77a..00000000000 --- a/src/channels/plugins/actions/signal.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { signalMessageActions } from "./signal.js"; - -const sendReactionSignal = vi.fn(async () => ({ ok: true })); -const removeReactionSignal = vi.fn(async () => ({ ok: true })); - -vi.mock("../../../signal/send-reactions.js", () => ({ - sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args), - removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args), -})); - -describe("signalMessageActions", () => { - it("returns no actions when no configured accounts exist", () => { - const cfg = {} as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual([]); - }); - - it("hides react when reactions are disabled", () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]); - }); - - it("enables react when at least one account allows reactions", () => { - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]); - }); - - it("skips send for plugin dispatch", () => { - expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); - expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); - }); - - it("blocks reactions when action gate is disabled", async () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - - await expect( - signalMessageActions.handleAction({ - action: "react", - params: { to: "+15550001111", messageId: "123", emoji: "✅" }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(/actions\.reactions/); - }); - - it("uses account-level actions when enabled", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { to: "+15550001111", messageId: "123", emoji: "👍" }, - cfg, - accountId: "work", - }); - - expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", { - accountId: "work", - }); - }); - - it("normalizes uuid recipients", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { - recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "🔥", - }, - cfg, - accountId: undefined, - }); - - expect(sendReactionSignal).toHaveBeenCalledWith( - "123e4567-e89b-12d3-a456-426614174000", - 123, - "🔥", - { accountId: undefined }, - ); - }); - - it("requires targetAuthor for group reactions", async () => { - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await expect( - signalMessageActions.handleAction({ - action: "react", - params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(/targetAuthor/); - }); - - it("passes groupId and targetAuthor for group reactions", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { - to: "signal:group:group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "✅", - }, - cfg, - accountId: undefined, - }); - - expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", { - accountId: undefined, - groupId: "group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - targetAuthorUuid: undefined, - }); - }); -}); diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts deleted file mode 100644 index 21922905e53..00000000000 --- a/src/channels/plugins/actions/telegram.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramMessageActions } from "./telegram.js"; - -const handleTelegramAction = vi.fn(async () => ({ ok: true })); - -vi.mock("../../../agents/tools/telegram-actions.js", () => ({ - handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), -})); - -describe("telegramMessageActions", () => { - it("excludes sticker actions when not enabled", () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - const actions = telegramMessageActions.listActions({ cfg }); - expect(actions).not.toContain("sticker"); - expect(actions).not.toContain("sticker-search"); - }); - - it("allows media-only sends and passes asVoice", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "send", - params: { - to: "123", - media: "https://example.com/voice.ogg", - asVoice: true, - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - to: "123", - content: "", - mediaUrl: "https://example.com/voice.ogg", - asVoice: true, - }), - cfg, - ); - }); - - it("passes silent flag for silent sends", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "send", - params: { - to: "456", - message: "Silent notification test", - silent: true, - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - to: "456", - content: "Silent notification test", - silent: true, - }), - cfg, - ); - }); - - it("maps edit action params into editMessage", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "edit", - params: { - chatId: "123", - messageId: 42, - message: "Updated", - buttons: [], - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - { - action: "editMessage", - chatId: "123", - messageId: 42, - content: "Updated", - buttons: [], - accountId: undefined, - }, - cfg, - ); - }); - - it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await expect( - telegramMessageActions.handleAction({ - action: "edit", - params: { - chatId: "123", - messageId: "nope", - message: "Updated", - }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(); - - expect(handleTelegramAction).not.toHaveBeenCalled(); - }); - - it("accepts numeric messageId and channelId for reactions", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "react", - params: { - channelId: 123, - messageId: 456, - emoji: "ok", - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0] as Record; - expect(call.action).toBe("react"); - expect(String(call.chatId)).toBe("123"); - expect(String(call.messageId)).toBe("456"); - expect(call.emoji).toBe("ok"); - }); -}); diff --git a/src/channels/plugins/base-types-assignability.test.ts b/src/channels/plugins/base-types-assignability.test.ts deleted file mode 100644 index 839146018fe..00000000000 --- a/src/channels/plugins/base-types-assignability.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expectTypeOf } from "vitest"; -import type { DiscordProbe } from "../../discord/probe.js"; -import type { DiscordTokenResolution } from "../../discord/token.js"; -import type { IMessageProbe } from "../../imessage/probe.js"; -import type { LineProbeResult } from "../../line/types.js"; -import type { SignalProbe } from "../../signal/probe.js"; -import type { SlackProbe } from "../../slack/probe.js"; -import type { TelegramProbe } from "../../telegram/probe.js"; -import type { TelegramTokenResolution } from "../../telegram/token.js"; -import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; - -describe("BaseProbeResult assignability", () => { - it("TelegramProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SlackProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SignalProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("IMessageProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("LineProbeResult satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); - -describe("BaseTokenResolution assignability", () => { - it("TelegramTokenResolution satisfies BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordTokenResolution satisfies BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts deleted file mode 100644 index d62fac8a8fc..00000000000 --- a/src/channels/plugins/catalog.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; - -describe("channel plugin catalog", () => { - it("includes Microsoft Teams", () => { - const entry = getChannelPluginCatalogEntry("msteams"); - expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); - expect(entry?.meta.aliases).toContain("teams"); - }); - - it("lists plugin catalog entries", () => { - const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); - expect(ids).toContain("msteams"); - }); - - it("includes external catalog entries", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); - const catalogPath = path.join(dir, "catalog.json"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/demo-channel", - openclaw: { - channel: { - id: "demo-channel", - label: "Demo Channel", - selectionLabel: "Demo Channel", - docsPath: "/channels/demo-channel", - blurb: "Demo entry", - order: 999, - }, - install: { - npmSpec: "@openclaw/demo-channel", - }, - }, - }, - ], - }), - ); - - const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( - (entry) => entry.id, - ); - expect(ids).toContain("demo-channel"); - }); -}); diff --git a/src/channels/plugins/config-writes.test.ts b/src/channels/plugins/config-writes.test.ts deleted file mode 100644 index 00fe9164f8e..00000000000 --- a/src/channels/plugins/config-writes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveChannelConfigWrites } from "./config-writes.js"; - -describe("resolveChannelConfigWrites", () => { - it("defaults to allow when unset", () => { - const cfg = {}; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); - }); - - it("blocks when channel config disables writes", () => { - const cfg = { channels: { slack: { configWrites: false } } }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); - }); - - it("account override wins over channel default", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); - - it("matches account ids case-insensitively", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - Work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); -}); diff --git a/src/channels/plugins/directory-config.test.ts b/src/channels/plugins/directory-config.test.ts deleted file mode 100644 index ab043e1b36d..00000000000 --- a/src/channels/plugins/directory-config.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "./directory-config.js"; - -describe("directory (config-backed)", () => { - it("lists Slack peers/groups from config", async () => { - const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - dm: { allowFrom: ["U123", "user:U999"] }, - dms: { U234: {} }, - channels: { C111: { users: ["U777"] } }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listSlackDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual([ - "user:u123", - "user:u234", - "user:u777", - "user:u999", - ]); - - const groups = await listSlackDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); - }); - - it("lists Discord peers/groups from config (numeric ids only)", async () => { - const cfg = { - channels: { - discord: { - token: "discord-test", - dm: { allowFrom: ["<@111>", "nope"] }, - dms: { "222": {} }, - guilds: { - "123": { - users: ["<@12345>", "not-an-id"], - channels: { - "555": {}, - "channel:666": {}, - general: {}, - }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listDiscordDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); - - const groups = await listDiscordDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); - }); - - it("lists Telegram peers/groups from config", async () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - allowFrom: ["123", "alice", "tg:@bob"], - dms: { "456": {} }, - groups: { "-1001": {}, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listTelegramDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); - - const groups = await listTelegramDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["-1001"]); - }); - - it("lists WhatsApp peers/groups from config", async () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["+15550000000", "*", "123@g.us"], - groups: { "999@g.us": { requireMention: true }, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listWhatsAppDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); - - const groups = await listWhatsAppDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); - }); -}); diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index 3b57bd082c9..2593878d204 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -14,6 +14,26 @@ export type DirectoryConfigParams = { limit?: number | null; }; +function addAllowFromAndDmsIds( + ids: Set, + allowFrom: readonly unknown[] | undefined, + dms: Record | undefined, +) { + for (const entry of allowFrom ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") { + continue; + } + ids.add(raw); + } + for (const id of Object.keys(dms ?? {})) { + const trimmed = id.trim(); + if (trimmed) { + ids.add(trimmed); + } + } +} + export async function listSlackDirectoryPeersFromConfig( params: DirectoryConfigParams, ): Promise { @@ -21,19 +41,7 @@ export async function listSlackDirectoryPeersFromConfig( const q = params.query?.trim().toLowerCase() || ""; const ids = new Set(); - for (const entry of account.config.allowFrom ?? account.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw); - } - for (const id of Object.keys(account.config.dms ?? {})) { - const trimmed = id.trim(); - if (trimmed) { - ids.add(trimmed); - } - } + addAllowFromAndDmsIds(ids, account.config.allowFrom ?? account.dm?.allowFrom, account.config.dms); for (const channel of Object.values(account.config.channels ?? {})) { for (const user of channel.users ?? []) { const raw = String(user).trim(); @@ -84,19 +92,11 @@ export async function listDiscordDirectoryPeersFromConfig( const q = params.query?.trim().toLowerCase() || ""; const ids = new Set(); - for (const entry of account.config.allowFrom ?? account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw); - } - for (const id of Object.keys(account.config.dms ?? {})) { - const trimmed = id.trim(); - if (trimmed) { - ids.add(trimmed); - } - } + addAllowFromAndDmsIds( + ids, + account.config.allowFrom ?? account.config.dm?.allowFrom, + account.config.dms, + ); for (const guild of Object.values(account.config.guilds ?? {})) { for (const entry of guild.users ?? []) { const raw = String(entry).trim(); diff --git a/src/channels/plugins/index.test.ts b/src/channels/plugins/index.test.ts deleted file mode 100644 index 63162f09018..00000000000 --- a/src/channels/plugins/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { listChannelPlugins } from "./index.js"; - -describe("channel plugin registry", () => { - const emptyRegistry = createTestRegistry([]); - - const createPlugin = (id: string): ChannelPlugin => ({ - id, - meta: { - id, - label: id, - selectionLabel: id, - docsPath: `/channels/${id}`, - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - }); - - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("sorts channel plugins by configured order", () => { - const registry = createTestRegistry( - ["slack", "telegram", "signal"].map((id) => ({ - pluginId: id, - plugin: createPlugin(id), - source: "test", - })), - ); - setActivePluginRegistry(registry); - const pluginIds = listChannelPlugins().map((plugin) => plugin.id); - expect(pluginIds).toEqual(["telegram", "slack", "signal"]); - }); -}); diff --git a/src/channels/plugins/load.test.ts b/src/channels/plugins/load.test.ts deleted file mode 100644 index f3daf0543c7..00000000000 --- a/src/channels/plugins/load.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PluginRegistry } from "../../plugins/registry.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { loadChannelPlugin } from "./load.js"; -import { loadChannelOutboundAdapter } from "./outbound/load.js"; - -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - -const emptyRegistry = createRegistry([]); - -const msteamsOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - sendText: async () => ({ channel: "msteams", messageId: "m1" }), - sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), -}; - -const msteamsPlugin: ChannelPlugin = { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - outbound: msteamsOutbound, -}; - -const registryWithMSTeams = createRegistry([ - { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, -]); - -describe("channel plugin loader", () => { - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("loads channel plugins from the active registry", async () => { - setActivePluginRegistry(registryWithMSTeams); - const plugin = await loadChannelPlugin("msteams"); - expect(plugin).toBe(msteamsPlugin); - }); - - it("loads outbound adapters from registered plugins", async () => { - setActivePluginRegistry(registryWithMSTeams); - const outbound = await loadChannelOutboundAdapter("msteams"); - expect(outbound).toBe(msteamsOutbound); - }); -}); diff --git a/src/channels/plugins/normalize/imessage.test.ts b/src/channels/plugins/normalize/imessage.test.ts deleted file mode 100644 index a3cbf0501eb..00000000000 --- a/src/channels/plugins/normalize/imessage.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeIMessageMessagingTarget } from "./imessage.js"; - -describe("imessage target normalization", () => { - it("preserves service prefixes for handles", () => { - expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); - }); - - it("drops service prefixes for chat targets", () => { - expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); - expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); - expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); - }); -}); diff --git a/src/channels/plugins/normalize/signal.test.ts b/src/channels/plugins/normalize/signal.test.ts deleted file mode 100644 index 547a8f30d91..00000000000 --- a/src/channels/plugins/normalize/signal.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js"; - -describe("signal target normalization", () => { - it("normalizes uuid targets by stripping uuid:", () => { - expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("normalizes signal:uuid targets", () => { - expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("preserves case for group targets", () => { - expect( - normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), - ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); - }); - - it("accepts uuid prefixes for target detection", () => { - expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - }); - - it("accepts compact UUIDs for target detection", () => { - expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); - expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); - }); - - it("rejects invalid uuid prefixes", () => { - expect(looksLikeSignalTargetId("uuid:")).toBe(false); - expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); - }); -}); diff --git a/src/channels/plugins/onboarding/signal.test.ts b/src/channels/plugins/onboarding/signal.test.ts deleted file mode 100644 index 23f218bd4c4..00000000000 --- a/src/channels/plugins/onboarding/signal.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeSignalAccountInput } from "./signal.js"; - -describe("normalizeSignalAccountInput", () => { - it("accepts already normalized numbers", () => { - expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); - }); - - it("normalizes formatted input", () => { - expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); - }); - - it("rejects empty input", () => { - expect(normalizeSignalAccountInput(" ")).toBeNull(); - }); - - it("rejects non-numeric input", () => { - expect(normalizeSignalAccountInput("ok")).toBeNull(); - expect(normalizeSignalAccountInput("++--")).toBeNull(); - }); - - it("rejects inputs with stray + characters", () => { - expect(normalizeSignalAccountInput("++12345")).toBeNull(); - expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); - }); - - it("rejects numbers that are too short or too long", () => { - expect(normalizeSignalAccountInput("+1234")).toBeNull(); - expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); - }); -}); diff --git a/src/channels/plugins/onboarding/whatsapp.ts b/src/channels/plugins/onboarding/whatsapp.ts index 3a140ee49d7..e640c8a3989 100644 --- a/src/channels/plugins/onboarding/whatsapp.ts +++ b/src/channels/plugins/onboarding/whatsapp.ts @@ -79,6 +79,27 @@ async function promptWhatsAppOwnerAllowFrom(params: { return { normalized, allowFrom }; } +async function applyWhatsAppOwnerAllowlist(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + existingAllowFrom: string[]; + title: string; + messageLines: string[]; +}): Promise { + const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + prompter: params.prompter, + existingAllowFrom: params.existingAllowFrom, + }); + let next = setWhatsAppSelfChatMode(params.cfg, true); + next = setWhatsAppDmPolicy(next, "allowlist"); + next = setWhatsAppAllowFrom(next, allowFrom); + await params.prompter.note( + [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), + params.title, + ); + return next; +} + async function promptWhatsAppAllowFrom( cfg: OpenClawConfig, _runtime: RuntimeEnv, @@ -90,18 +111,13 @@ async function promptWhatsAppAllowFrom( const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; if (options?.forceAllowlist) { - const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + return await applyWhatsAppOwnerAllowlist({ + cfg, prompter, existingAllowFrom, + title: "WhatsApp allowlist", + messageLines: ["Allowlist mode enabled."], }); - let next = setWhatsAppSelfChatMode(cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, allowFrom); - await prompter.note( - ["Allowlist mode enabled.", `- allowFrom includes ${normalized}`].join("\n"), - "WhatsApp allowlist", - ); - return next; } await prompter.note( @@ -127,22 +143,16 @@ async function promptWhatsAppAllowFrom( }); if (phoneMode === "personal") { - const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + return await applyWhatsAppOwnerAllowlist({ + cfg, prompter, existingAllowFrom, - }); - let next = setWhatsAppSelfChatMode(cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, allowFrom); - await prompter.note( - [ + title: "WhatsApp personal phone", + messageLines: [ "Personal phone mode enabled.", "- dmPolicy set to allowlist (pairing skipped)", - `- allowFrom includes ${normalized}`, - ].join("\n"), - "WhatsApp personal phone", - ); - return next; + ], + }); } const policy = (await prompter.select({ diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 8aab8c5f916..13d79849f12 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -3,6 +3,19 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import { sendMessageIMessage } from "../../../imessage/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; +function resolveIMessageMaxBytes(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; +}) { + return resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.channels?.imessage?.mediaMaxMb, + accountId: params.accountId, + }); +} + export const imessageOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkText, @@ -10,13 +23,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveIMessageMaxBytes({ cfg, accountId }); const result = await send(to, text, { maxBytes, accountId: accountId ?? undefined, @@ -25,13 +32,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.imessage?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveIMessageMaxBytes({ cfg, accountId }); const result = await send(to, text, { mediaUrl, maxBytes, diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 45544b417da..b96615b1486 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -3,6 +3,18 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import { sendMessageSignal } from "../../../signal/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; +function resolveSignalMaxBytes(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; +}) { + return resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, + accountId: params.accountId, + }); +} + export const signalOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkText, @@ -10,12 +22,7 @@ export const signalOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveSignalMaxBytes({ cfg, accountId }); const result = await send(to, text, { maxBytes, accountId: accountId ?? undefined, @@ -24,12 +31,7 @@ export const signalOutbound: ChannelOutboundAdapter = { }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, - accountId, - }); + const maxBytes = resolveSignalMaxBytes({ cfg, accountId }); const result = await send(to, text, { mediaUrl, maxBytes, diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index d7c05ea8d7f..b5bd4273fbd 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -46,36 +46,63 @@ async function applySlackMessageSendingHooks(params: { return { cancelled: false, text: hookResult?.content ?? params.text }; } +async function sendSlackOutboundMessage(params: { + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string | null; + deps?: { sendSlack?: typeof sendMessageSlack } | null; + replyToId?: string | null; + threadId?: string | number | null; + identity?: OutboundIdentity; +}) { + const send = params.deps?.sendSlack ?? sendMessageSlack; + // Use threadId fallback so routed tool notifications stay in the Slack thread. + const threadTs = + params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); + const hookResult = await applySlackMessageSendingHooks({ + to: params.to, + text: params.text, + threadTs, + mediaUrl: params.mediaUrl, + accountId: params.accountId ?? undefined, + }); + if (hookResult.cancelled) { + return { + channel: "slack" as const, + messageId: "cancelled-by-hook", + channelId: params.to, + meta: { cancelled: true }, + }; + } + + const slackIdentity = resolveSlackSendIdentity(params.identity); + const result = await send(params.to, hookResult.text, { + threadTs, + accountId: params.accountId ?? undefined, + ...(params.mediaUrl + ? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots } + : {}), + ...(slackIdentity ? { identity: slackIdentity } : {}), + }); + return { channel: "slack" as const, ...result }; +} + export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => { - const send = deps?.sendSlack ?? sendMessageSlack; - // Use threadId fallback so routed tool notifications stay in the Slack thread. - const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); - const hookResult = await applySlackMessageSendingHooks({ + return await sendSlackOutboundMessage({ to, text, - threadTs, - accountId: accountId ?? undefined, + accountId, + deps, + replyToId, + threadId, + identity, }); - if (hookResult.cancelled) { - return { - channel: "slack", - messageId: "cancelled-by-hook", - channelId: to, - meta: { cancelled: true }, - }; - } - - const slackIdentity = resolveSlackSendIdentity(identity); - const result = await send(to, hookResult.text, { - threadTs, - accountId: accountId ?? undefined, - ...(slackIdentity ? { identity: slackIdentity } : {}), - }); - return { channel: "slack", ...result }; }, sendMedia: async ({ to, @@ -88,33 +115,16 @@ export const slackOutbound: ChannelOutboundAdapter = { threadId, identity, }) => { - const send = deps?.sendSlack ?? sendMessageSlack; - // Use threadId fallback so routed tool notifications stay in the Slack thread. - const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); - const hookResult = await applySlackMessageSendingHooks({ + return await sendSlackOutboundMessage({ to, text, - threadTs, - mediaUrl, - accountId: accountId ?? undefined, - }); - if (hookResult.cancelled) { - return { - channel: "slack", - messageId: "cancelled-by-hook", - channelId: to, - meta: { cancelled: true }, - }; - } - - const slackIdentity = resolveSlackSendIdentity(identity); - const result = await send(to, hookResult.text, { mediaUrl, mediaLocalRoots, - threadTs, - accountId: accountId ?? undefined, - ...(slackIdentity ? { identity: slackIdentity } : {}), + accountId, + deps, + replyToId, + threadId, + identity, }); - return { channel: "slack", ...result }; }, }; diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts deleted file mode 100644 index 7981addf566..00000000000 --- a/src/channels/plugins/outbound/telegram.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramOutbound } from "./telegram.js"; - -describe("telegramOutbound.sendPayload", () => { - it("sends text payload with buttons", async () => { - const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Hello", - channelData: { - telegram: { - buttons: [[{ text: "Option", callback_data: "/option" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(1); - expect(sendTelegram).toHaveBeenCalledWith( - "telegram:123", - "Hello", - expect.objectContaining({ - buttons: [[{ text: "Option", callback_data: "/option" }]], - textMode: "html", - }), - ); - expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); - }); - - it("sends media payloads and attaches buttons only to first", async () => { - const sendTelegram = vi - .fn() - .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) - .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Caption", - mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - channelData: { - telegram: { - buttons: [[{ text: "Go", callback_data: "/go" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(2); - expect(sendTelegram).toHaveBeenNthCalledWith( - 1, - "telegram:123", - "Caption", - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - buttons: [[{ text: "Go", callback_data: "/go" }]], - }), - ); - const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; - expect(sendTelegram).toHaveBeenNthCalledWith( - 2, - "telegram:123", - "", - expect.objectContaining({ - mediaUrl: "https://example.com/b.png", - }), - ); - expect(secondOpts?.buttons).toBeUndefined(); - expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); - }); -}); diff --git a/src/channels/plugins/outbound/whatsapp.test.ts b/src/channels/plugins/outbound/whatsapp.test.ts deleted file mode 100644 index 7922ed00795..00000000000 --- a/src/channels/plugins/outbound/whatsapp.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { whatsappOutbound } from "./whatsapp.js"; - -describe("whatsappOutbound.resolveTarget", () => { - it("returns error when no target is provided even with allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: undefined, - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("returns error when implicit target is not in allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "+15550000000", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("keeps group JID targets even when allowFrom does not contain them", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "120363401234567890@g.us", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: true, - to: "120363401234567890@g.us", - }); - }); -}); diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts new file mode 100644 index 00000000000..91277158d2e --- /dev/null +++ b/src/channels/plugins/plugins-channel.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; +import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; +import { normalizeSignalAccountInput } from "./onboarding/signal.js"; +import { telegramOutbound } from "./outbound/telegram.js"; +import { whatsappOutbound } from "./outbound/whatsapp.js"; + +describe("imessage target normalization", () => { + it("preserves service prefixes for handles", () => { + expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); + }); + + it("drops service prefixes for chat targets", () => { + expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); + expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); + expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); + }); +}); + +describe("signal target normalization", () => { + it("normalizes uuid targets by stripping uuid:", () => { + expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("normalizes signal:uuid targets", () => { + expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("preserves case for group targets", () => { + expect( + normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), + ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + }); + + it("accepts uuid prefixes for target detection", () => { + expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + }); + + it("accepts compact UUIDs for target detection", () => { + expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); + }); + + it("rejects invalid uuid prefixes", () => { + expect(looksLikeSignalTargetId("uuid:")).toBe(false); + expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); + }); +}); + +describe("telegramOutbound.sendPayload", () => { + it("sends text payload with buttons", async () => { + const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Hello", + channelData: { + telegram: { + buttons: [[{ text: "Option", callback_data: "/option" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "telegram:123", + "Hello", + expect.objectContaining({ + buttons: [[{ text: "Option", callback_data: "/option" }]], + textMode: "html", + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); + }); + + it("sends media payloads and attaches buttons only to first", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) + .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Caption", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + channelData: { + telegram: { + buttons: [[{ text: "Go", callback_data: "/go" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(2); + expect(sendTelegram).toHaveBeenNthCalledWith( + 1, + "telegram:123", + "Caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + buttons: [[{ text: "Go", callback_data: "/go" }]], + }), + ); + const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; + expect(sendTelegram).toHaveBeenNthCalledWith( + 2, + "telegram:123", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/b.png", + }), + ); + expect(secondOpts?.buttons).toBeUndefined(); + expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); + }); +}); + +describe("whatsappOutbound.resolveTarget", () => { + it("returns error when no target is provided even with allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: undefined, + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("returns error when implicit target is not in allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "+15550000000", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("keeps group JID targets even when allowFrom does not contain them", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "120363401234567890@g.us", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: true, + to: "120363401234567890@g.us", + }); + }); +}); + +describe("normalizeSignalAccountInput", () => { + it("accepts already normalized numbers", () => { + expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); + }); + + it("normalizes formatted input", () => { + expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); + }); + + it("rejects empty input", () => { + expect(normalizeSignalAccountInput(" ")).toBeNull(); + }); + + it("rejects non-numeric input", () => { + expect(normalizeSignalAccountInput("ok")).toBeNull(); + expect(normalizeSignalAccountInput("++--")).toBeNull(); + }); + + it("rejects inputs with stray + characters", () => { + expect(normalizeSignalAccountInput("++12345")).toBeNull(); + expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); + }); + + it("rejects numbers that are too short or too long", () => { + expect(normalizeSignalAccountInput("+1234")).toBeNull(); + expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); + }); +}); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts new file mode 100644 index 00000000000..64daeb574a2 --- /dev/null +++ b/src/channels/plugins/plugins-core.test.ts @@ -0,0 +1,395 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; +import type { DiscordProbe } from "../../discord/probe.js"; +import type { DiscordTokenResolution } from "../../discord/token.js"; +import type { IMessageProbe } from "../../imessage/probe.js"; +import type { LineProbeResult } from "../../line/types.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { SignalProbe } from "../../signal/probe.js"; +import type { SlackProbe } from "../../slack/probe.js"; +import type { TelegramProbe } from "../../telegram/probe.js"; +import type { TelegramTokenResolution } from "../../telegram/token.js"; +import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; +import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; +import { resolveChannelConfigWrites } from "./config-writes.js"; +import { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./directory-config.js"; +import { listChannelPlugins } from "./index.js"; +import { loadChannelPlugin } from "./load.js"; +import { loadChannelOutboundAdapter } from "./outbound/load.js"; + +describe("channel plugin registry", () => { + const emptyRegistry = createTestRegistry([]); + + const createPlugin = (id: string): ChannelPlugin => ({ + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }); + + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("sorts channel plugins by configured order", () => { + const registry = createTestRegistry( + ["slack", "telegram", "signal"].map((id) => ({ + pluginId: id, + plugin: createPlugin(id), + source: "test", + })), + ); + setActivePluginRegistry(registry); + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + expect(pluginIds).toEqual(["telegram", "slack", "signal"]); + }); +}); + +describe("channel plugin catalog", () => { + it("includes Microsoft Teams", () => { + const entry = getChannelPluginCatalogEntry("msteams"); + expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); + expect(entry?.meta.aliases).toContain("teams"); + }); + + it("lists plugin catalog entries", () => { + const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); + expect(ids).toContain("msteams"); + }); + + it("includes external catalog entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@openclaw/demo-channel", + openclaw: { + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo entry", + order: 999, + }, + install: { + npmSpec: "@openclaw/demo-channel", + }, + }, + }, + ], + }), + ); + + const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( + (entry) => entry.id, + ); + expect(ids).toContain("demo-channel"); + }); +}); + +const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ + plugins: [], + tools: [], + channels, + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + diagnostics: [], +}); + +const emptyRegistry = createRegistry([]); + +const msteamsOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async () => ({ channel: "msteams", messageId: "m1" }), + sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), +}; + +const msteamsPlugin: ChannelPlugin = { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/channels/msteams", + blurb: "Bot Framework; enterprise support.", + aliases: ["teams"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + outbound: msteamsOutbound, +}; + +const registryWithMSTeams = createRegistry([ + { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, +]); + +describe("channel plugin loader", () => { + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("loads channel plugins from the active registry", async () => { + setActivePluginRegistry(registryWithMSTeams); + const plugin = await loadChannelPlugin("msteams"); + expect(plugin).toBe(msteamsPlugin); + }); + + it("loads outbound adapters from registered plugins", async () => { + setActivePluginRegistry(registryWithMSTeams); + const outbound = await loadChannelOutboundAdapter("msteams"); + expect(outbound).toBe(msteamsOutbound); + }); +}); + +describe("BaseProbeResult assignability", () => { + it("TelegramProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SlackProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SignalProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("IMessageProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("LineProbeResult satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("BaseTokenResolution assignability", () => { + it("TelegramTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("resolveChannelConfigWrites", () => { + it("defaults to allow when unset", () => { + const cfg = {}; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); + }); + + it("blocks when channel config disables writes", () => { + const cfg = { channels: { slack: { configWrites: false } } }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); + }); + + it("account override wins over channel default", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + Work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); +}); + +describe("directory (config-backed)", () => { + it("lists Slack peers/groups from config", async () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + dm: { allowFrom: ["U123", "user:U999"] }, + dms: { U234: {} }, + channels: { C111: { users: ["U777"] } }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listSlackDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual([ + "user:u123", + "user:u234", + "user:u777", + "user:u999", + ]); + + const groups = await listSlackDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); + }); + + it("lists Discord peers/groups from config (numeric ids only)", async () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + dm: { allowFrom: ["<@111>", "nope"] }, + dms: { "222": {} }, + guilds: { + "123": { + users: ["<@12345>", "not-an-id"], + channels: { + "555": {}, + "channel:666": {}, + general: {}, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listDiscordDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); + + const groups = await listDiscordDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); + }); + + it("lists Telegram peers/groups from config", async () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + allowFrom: ["123", "alice", "tg:@bob"], + dms: { "456": {} }, + groups: { "-1001": {}, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listTelegramDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); + + const groups = await listTelegramDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["-1001"]); + }); + + it("lists WhatsApp peers/groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["+15550000000", "*", "123@g.us"], + groups: { "999@g.us": { requireMention: true }, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listWhatsAppDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); + + const groups = await listWhatsAppDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); + }); +}); diff --git a/src/channels/plugins/slack.actions.test.ts b/src/channels/plugins/slack.actions.test.ts deleted file mode 100644 index 844da4f09ad..00000000000 --- a/src/channels/plugins/slack.actions.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createSlackActions } from "./slack.actions.js"; - -const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); - -vi.mock("../../agents/tools/slack-actions.js", () => ({ - handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), -})); - -describe("slack actions adapter", () => { - beforeEach(() => { - handleSlackAction.mockClear(); - }); - - it("forwards threadId for read", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "read", - cfg, - params: { - channelId: "C1", - threadId: "171234.567", - }, - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "readMessages", - channelId: "C1", - threadId: "171234.567", - }); - }); - - it("forwards normalized limit for emoji-list", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "emoji-list", - cfg, - params: { - limit: "2.9", - }, - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "emojiList", - limit: 2, - }); - }); -}); diff --git a/src/channels/plugins/status-issues/discord.ts b/src/channels/plugins/status-issues/discord.ts index d3e6b795ca0..f3e8765093f 100644 --- a/src/channels/plugins/status-issues/discord.ts +++ b/src/channels/plugins/status-issues/discord.ts @@ -1,5 +1,10 @@ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { appendMatchMetadata, asString, isRecord } from "./shared.js"; +import { + appendMatchMetadata, + asString, + isRecord, + resolveEnabledConfiguredAccountId, +} from "./shared.js"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; @@ -111,10 +116,8 @@ export function collectDiscordStatusIssues( if (!account) { continue; } - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) { + const accountId = resolveEnabledConfiguredAccountId(account); + if (!accountId) { continue; } diff --git a/src/channels/plugins/status-issues/shared.ts b/src/channels/plugins/status-issues/shared.ts index da3606c2e9f..d4f5be878c1 100644 --- a/src/channels/plugins/status-issues/shared.ts +++ b/src/channels/plugins/status-issues/shared.ts @@ -30,3 +30,14 @@ export function appendMatchMetadata( const meta = formatMatchMetadata(params); return meta ? `${message} (${meta})` : message; } + +export function resolveEnabledConfiguredAccountId(account: { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; +}): string | null { + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + return enabled && configured ? accountId : null; +} diff --git a/src/channels/plugins/status-issues/telegram.ts b/src/channels/plugins/status-issues/telegram.ts index 8853bf4b1c8..97998eb4da4 100644 --- a/src/channels/plugins/status-issues/telegram.ts +++ b/src/channels/plugins/status-issues/telegram.ts @@ -1,5 +1,10 @@ import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; -import { appendMatchMetadata, asString, isRecord } from "./shared.js"; +import { + appendMatchMetadata, + asString, + isRecord, + resolveEnabledConfiguredAccountId, +} from "./shared.js"; type TelegramAccountStatus = { accountId?: unknown; @@ -81,10 +86,8 @@ export function collectTelegramStatusIssues( if (!account) { continue; } - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) { + const accountId = resolveEnabledConfiguredAccountId(account); + if (!accountId) { continue; } diff --git a/src/channels/web/index.test.ts b/src/channels/web/index.test.ts deleted file mode 100644 index 8f628495798..00000000000 --- a/src/channels/web/index.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import * as impl from "../../channel-web.js"; -import * as entry from "./index.js"; - -describe("channels/web entrypoint", () => { - it("re-exports web channel helpers", () => { - expect(entry.createWaSocket).toBe(impl.createWaSocket); - expect(entry.loginWeb).toBe(impl.loginWeb); - expect(entry.logWebSelfId).toBe(impl.logWebSelfId); - expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox); - expect(entry.monitorWebChannel).toBe(impl.monitorWebChannel); - expect(entry.pickWebChannel).toBe(impl.pickWebChannel); - expect(entry.sendMessageWhatsApp).toBe(impl.sendMessageWhatsApp); - expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR); - expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection); - expect(entry.webAuthExists).toBe(impl.webAuthExists); - }); -}); diff --git a/src/cli/cli-utils.test.ts b/src/cli/cli-utils.test.ts new file mode 100644 index 00000000000..5e8bfee99dd --- /dev/null +++ b/src/cli/cli-utils.test.ts @@ -0,0 +1,110 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; +import { parseCanvasSnapshotPayload } from "./nodes-canvas.js"; +import { parseByteSize } from "./parse-bytes.js"; +import { parseDurationMs } from "./parse-duration.js"; +import { shouldSkipRespawnForArgv } from "./respawn-policy.js"; +import { waitForever } from "./wait.js"; + +const { registerDnsCli } = await import("./dns-cli.js"); + +describe("waitForever", () => { + it("creates an unref'ed interval and returns a pending promise", () => { + const setIntervalSpy = vi.spyOn(global, "setInterval"); + const promise = waitForever(); + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1_000_000); + expect(promise).toBeInstanceOf(Promise); + setIntervalSpy.mockRestore(); + }); +}); + +describe("shouldSkipRespawnForArgv", () => { + it("skips respawn for help/version calls", () => { + expect(shouldSkipRespawnForArgv(["node", "openclaw", "--help"])).toBe(true); + expect(shouldSkipRespawnForArgv(["node", "openclaw", "-V"])).toBe(true); + }); + + it("keeps respawn path for normal commands", () => { + expect(shouldSkipRespawnForArgv(["node", "openclaw", "status"])).toBe(false); + }); +}); + +describe("nodes canvas helpers", () => { + it("parses canvas.snapshot payload", () => { + expect(parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" })).toEqual({ + format: "png", + base64: "aGk=", + }); + }); + + it("rejects invalid canvas.snapshot payload", () => { + expect(() => parseCanvasSnapshotPayload({ format: "png" })).toThrow( + /invalid canvas\.snapshot payload/i, + ); + }); +}); + +describe("dns cli", () => { + it("prints setup info (no apply)", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const program = new Command(); + registerDnsCli(program); + await program.parseAsync(["dns", "setup", "--domain", "openclaw.internal"], { from: "user" }); + const output = log.mock.calls.map((call) => call.join(" ")).join("\\n"); + expect(output).toContain("DNS setup"); + expect(output).toContain("openclaw.internal"); + } finally { + log.mockRestore(); + } + }); +}); + +describe("parseByteSize", () => { + it("parses bytes with units", () => { + expect(parseByteSize("10kb")).toBe(10 * 1024); + expect(parseByteSize("1mb")).toBe(1024 * 1024); + expect(parseByteSize("2gb")).toBe(2 * 1024 * 1024 * 1024); + }); + + it("parses shorthand units", () => { + expect(parseByteSize("5k")).toBe(5 * 1024); + expect(parseByteSize("1m")).toBe(1024 * 1024); + }); + + it("uses default unit when omitted", () => { + expect(parseByteSize("123")).toBe(123); + }); + + it("rejects invalid values", () => { + expect(() => parseByteSize("")).toThrow(); + expect(() => parseByteSize("nope")).toThrow(); + expect(() => parseByteSize("-5kb")).toThrow(); + }); +}); + +describe("parseDurationMs", () => { + it("parses bare ms", () => { + expect(parseDurationMs("10000")).toBe(10_000); + }); + + it("parses seconds suffix", () => { + expect(parseDurationMs("10s")).toBe(10_000); + }); + + it("parses minutes suffix", () => { + expect(parseDurationMs("1m")).toBe(60_000); + }); + + it("parses hours suffix", () => { + expect(parseDurationMs("2h")).toBe(7_200_000); + }); + + it("parses days suffix", () => { + expect(parseDurationMs("2d")).toBe(172_800_000); + }); + + it("supports decimals", () => { + expect(parseDurationMs("0.5s")).toBe(500); + }); +}); diff --git a/src/cli/dns-cli.test.ts b/src/cli/dns-cli.test.ts deleted file mode 100644 index 69d63dd28b1..00000000000 --- a/src/cli/dns-cli.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Command } from "commander"; -import { describe, expect, it, vi } from "vitest"; - -const { registerDnsCli } = await import("./dns-cli.js"); - -describe("dns cli", () => { - it("prints setup info (no apply)", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - const program = new Command(); - registerDnsCli(program); - await program.parseAsync(["dns", "setup", "--domain", "openclaw.internal"], { from: "user" }); - const output = log.mock.calls.map((call) => call.join(" ")).join("\n"); - expect(output).toContain("DNS setup"); - expect(output).toContain("openclaw.internal"); - } finally { - log.mockRestore(); - } - }); -}); diff --git a/src/cli/nodes-canvas.test.ts b/src/cli/nodes-canvas.test.ts deleted file mode 100644 index a3b7a394582..00000000000 --- a/src/cli/nodes-canvas.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseCanvasSnapshotPayload } from "./nodes-canvas.js"; - -describe("nodes canvas helpers", () => { - it("parses canvas.snapshot payload", () => { - expect(parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" })).toEqual({ - format: "png", - base64: "aGk=", - }); - }); - - it("rejects invalid canvas.snapshot payload", () => { - expect(() => parseCanvasSnapshotPayload({ format: "png" })).toThrow( - /invalid canvas\.snapshot payload/i, - ); - }); -}); diff --git a/src/cli/nodes-cli/pairing-render.ts b/src/cli/nodes-cli/pairing-render.ts new file mode 100644 index 00000000000..a24561912ee --- /dev/null +++ b/src/cli/nodes-cli/pairing-render.ts @@ -0,0 +1,38 @@ +import type { PendingRequest } from "./types.js"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +import { renderTable } from "../../terminal/table.js"; + +export function renderPendingPairingRequestsTable(params: { + pending: PendingRequest[]; + now: number; + tableWidth: number; + theme: { + heading: (text: string) => string; + warn: (text: string) => string; + muted: (text: string) => string; + }; +}) { + const { pending, now, tableWidth, theme } = params; + const rows = pending.map((r) => ({ + Request: r.requestId, + Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, + IP: r.remoteIp ?? "", + Requested: + typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown"), + Repair: r.isRepair ? theme.warn("yes") : "", + })); + return { + heading: theme.heading("Pending"), + table: renderTable({ + width: tableWidth, + columns: [ + { key: "Request", header: "Request", minWidth: 8 }, + { key: "Node", header: "Node", minWidth: 14, flex: true }, + { key: "IP", header: "IP", minWidth: 10 }, + { key: "Requested", header: "Requested", minWidth: 12 }, + { key: "Repair", header: "Repair", minWidth: 6 }, + ], + rows, + }).trimEnd(), + }; +} diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 76f839f6a74..3039189f0ff 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { renderTable } from "../../terminal/table.js"; import { shortenHomePath } from "../../utils.js"; @@ -14,7 +13,7 @@ import { } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; const parseFacing = (value: string): CameraFacing => { const v = String(value ?? "") @@ -37,12 +36,15 @@ export function registerNodesCameraCommands(nodes: Command) { .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera list", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); - const raw = await callGatewayCli("node.invoke", opts, { - nodeId, - command: "camera.list", - params: {}, - idempotencyKey: randomIdempotencyKey(), - }); + const raw = await callGatewayCli( + "node.invoke", + opts, + buildNodeInvokeParams({ + nodeId, + command: "camera.list", + params: {}, + }), + ); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = @@ -130,7 +132,7 @@ export function registerNodesCameraCommands(nodes: Command) { }> = []; for (const facing of facings) { - const invokeParams: Record = { + const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.snap", params: { @@ -141,11 +143,8 @@ export function registerNodesCameraCommands(nodes: Command) { delayMs: Number.isFinite(delayMs) ? delayMs : undefined, deviceId: deviceId || undefined, }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } + timeoutMs, + }); const raw = await callGatewayCli("node.invoke", opts, invokeParams); const res = @@ -204,7 +203,7 @@ export function registerNodesCameraCommands(nodes: Command) { : undefined; const deviceId = opts.deviceId ? String(opts.deviceId).trim() : undefined; - const invokeParams: Record = { + const invokeParams = buildNodeInvokeParams({ nodeId, command: "camera.clip", params: { @@ -214,11 +213,8 @@ export function registerNodesCameraCommands(nodes: Command) { format: "mp4", deviceId: deviceId || undefined, }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } + timeoutMs, + }); const raw = await callGatewayCli("node.invoke", opts, invokeParams); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; diff --git a/src/cli/nodes-cli/register.canvas.ts b/src/cli/nodes-cli/register.canvas.ts index 5883953eb47..d9877f8ad15 100644 --- a/src/cli/nodes-cli/register.canvas.ts +++ b/src/cli/nodes-cli/register.canvas.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import fs from "node:fs/promises"; import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; import { writeBase64ToFile } from "../nodes-camera.js"; @@ -9,21 +8,21 @@ import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-can import { parseTimeoutMs } from "../nodes-run.js"; import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record) { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); - const invokeParams: Record = { - nodeId, - command, - params, - idempotencyKey: randomIdempotencyKey(), - }; const timeoutMs = parseTimeoutMs(opts.invokeTimeout); - if (typeof timeoutMs === "number") { - invokeParams.timeoutMs = timeoutMs; - } - return await callGatewayCli("node.invoke", opts, invokeParams); + return await callGatewayCli( + "node.invoke", + opts, + buildNodeInvokeParams({ + nodeId, + command, + params, + timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined, + }), + ); } export function registerNodesCanvasCommands(nodes: Command) { @@ -42,7 +41,6 @@ export function registerNodesCanvasCommands(nodes: Command) { .option("--invoke-timeout ", "Node invoke timeout in ms (default 20000)", "20000") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas snapshot", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const formatOpt = String(opts.format ?? "jpg") .trim() .toLowerCase(); @@ -54,25 +52,11 @@ export function registerNodesCanvasCommands(nodes: Command) { const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : undefined; const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : undefined; - const timeoutMs = opts.invokeTimeout - ? Number.parseInt(String(opts.invokeTimeout), 10) - : undefined; - - const invokeParams: Record = { - nodeId, - command: "canvas.snapshot", - params: { - format: formatForParams, - maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, - quality: Number.isFinite(quality) ? quality : undefined, - }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } - - const raw = await callGatewayCli("node.invoke", opts, invokeParams); + const raw = await invokeCanvas(opts, "canvas.snapshot", { + format: formatForParams, + maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, + quality: Number.isFinite(quality) ? quality : undefined, + }); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = parseCanvasSnapshotPayload(res.payload); const filePath = canvasSnapshotTempPath({ diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 9241aeff782..daab00bdf32 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,10 +1,9 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; -import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { parsePairingList } from "./format.js"; +import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; export function registerNodesPairingCommands(nodes: Command) { @@ -28,28 +27,14 @@ export function registerNodesPairingCommands(nodes: Command) { const { heading, warn, muted } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); - const rows = pending.map((r) => ({ - Request: r.requestId, - Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, - IP: r.remoteIp ?? "", - Requested: - typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : muted("unknown"), - Repair: r.isRepair ? warn("yes") : "", - })); - defaultRuntime.log(heading("Pending")); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Request", header: "Request", minWidth: 8 }, - { key: "Node", header: "Node", minWidth: 14, flex: true }, - { key: "IP", header: "IP", minWidth: 10 }, - { key: "Requested", header: "Requested", minWidth: 12 }, - { key: "Repair", header: "Repair", minWidth: 6 }, - ], - rows, - }).trimEnd(), - ); + const rendered = renderPendingPairingRequestsTable({ + pending, + now, + tableWidth, + theme: { heading, warn, muted }, + }); + defaultRuntime.log(rendered.heading); + defaultRuntime.log(rendered.table); }); }), ); diff --git a/src/cli/nodes-cli/register.screen.ts b/src/cli/nodes-cli/register.screen.ts index 60ff4ec9716..e2034be1699 100644 --- a/src/cli/nodes-cli/register.screen.ts +++ b/src/cli/nodes-cli/register.screen.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; import { @@ -10,7 +9,7 @@ import { } from "../nodes-screen.js"; import { parseDurationMs } from "../parse-duration.js"; import { runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; export function registerNodesScreenCommands(nodes: Command) { const screen = nodes @@ -38,7 +37,7 @@ export function registerNodesScreenCommands(nodes: Command) { ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; - const invokeParams: Record = { + const invokeParams = buildNodeInvokeParams({ nodeId, command: "screen.record", params: { @@ -48,11 +47,8 @@ export function registerNodesScreenCommands(nodes: Command) { format: "mp4", includeAudio: opts.audio !== false, }, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - invokeParams.timeoutMs = timeoutMs; - } + timeoutMs, + }); const raw = await callGatewayCli("node.invoke", opts, invokeParams); const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e29b79d0699..414106f130b 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -7,6 +7,7 @@ import { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; +import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; function formatVersionLabel(raw: string) { @@ -356,31 +357,15 @@ export function registerNodesStatusCommands(nodes: Command) { } if (pendingRows.length > 0) { - const pendingRowsRendered = pendingRows.map((r) => ({ - Request: r.requestId, - Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, - IP: r.remoteIp ?? "", - Requested: - typeof r.ts === "number" - ? formatTimeAgo(Math.max(0, now - r.ts)) - : muted("unknown"), - Repair: r.isRepair ? warn("yes") : "", - })); + const rendered = renderPendingPairingRequestsTable({ + pending: pendingRows, + now, + tableWidth, + theme: { heading, warn, muted }, + }); defaultRuntime.log(""); - defaultRuntime.log(heading("Pending")); - defaultRuntime.log( - renderTable({ - width: tableWidth, - columns: [ - { key: "Request", header: "Request", minWidth: 8 }, - { key: "Node", header: "Node", minWidth: 14, flex: true }, - { key: "IP", header: "IP", minWidth: 10 }, - { key: "Requested", header: "Requested", minWidth: 12 }, - { key: "Repair", header: "Repair", minWidth: 6 }, - ], - rows: pendingRowsRendered, - }).trimEnd(), - ); + defaultRuntime.log(rendered.heading); + defaultRuntime.log(rendered.table); } if (filteredPaired.length > 0) { diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index a51e0fa2a9a..691da4dd7fa 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { NodeListNode, NodesRpcOpts } from "./types.js"; -import { callGateway } from "../../gateway/call.js"; +import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; import { resolveNodeIdFromCandidates } from "../../shared/node-match.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { withProgress } from "../progress.js"; @@ -37,6 +37,25 @@ export const callGatewayCli = async ( }), ); +export function buildNodeInvokeParams(params: { + nodeId: string; + command: string; + params?: Record; + timeoutMs?: number; + idempotencyKey?: string; +}): Record { + const invokeParams: Record = { + nodeId: params.nodeId, + command: params.command, + params: params.params, + idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(), + }; + if (typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)) { + invokeParams.timeoutMs = params.timeoutMs; + } + return invokeParams; +} + export function unauthorizedHintForMessage(message: string): string | null { const haystack = message.toLowerCase(); if ( diff --git a/src/cli/parse-bytes.test.ts b/src/cli/parse-bytes.test.ts deleted file mode 100644 index a0c1abcb0b0..00000000000 --- a/src/cli/parse-bytes.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseByteSize } from "./parse-bytes.js"; - -describe("parseByteSize", () => { - it("parses bytes with units", () => { - expect(parseByteSize("10kb")).toBe(10 * 1024); - expect(parseByteSize("1mb")).toBe(1024 * 1024); - expect(parseByteSize("2gb")).toBe(2 * 1024 * 1024 * 1024); - }); - - it("parses shorthand units", () => { - expect(parseByteSize("5k")).toBe(5 * 1024); - expect(parseByteSize("1m")).toBe(1024 * 1024); - }); - - it("uses default unit when omitted", () => { - expect(parseByteSize("123")).toBe(123); - }); - - it("rejects invalid values", () => { - expect(() => parseByteSize("")).toThrow(); - expect(() => parseByteSize("nope")).toThrow(); - expect(() => parseByteSize("-5kb")).toThrow(); - }); -}); diff --git a/src/cli/parse-duration.test.ts b/src/cli/parse-duration.test.ts deleted file mode 100644 index ad9d6a3a60c..00000000000 --- a/src/cli/parse-duration.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseDurationMs } from "./parse-duration.js"; - -describe("parseDurationMs", () => { - it("parses bare ms", () => { - expect(parseDurationMs("10000")).toBe(10_000); - }); - - it("parses seconds suffix", () => { - expect(parseDurationMs("10s")).toBe(10_000); - }); - - it("parses minutes suffix", () => { - expect(parseDurationMs("1m")).toBe(60_000); - }); - - it("parses hours suffix", () => { - expect(parseDurationMs("2h")).toBe(7_200_000); - }); - - it("parses days suffix", () => { - expect(parseDurationMs("2d")).toBe(172_800_000); - }); - - it("supports decimals", () => { - expect(parseDurationMs("0.5s")).toBe(500); - }); -}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 3d3a3341115..5f897351c5f 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildPluginStatusReport } from "../plugins/status.js"; @@ -591,6 +592,9 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(result.error); process.exit(1); } + // Plugin CLI registrars may have warmed the manifest registry cache before install; + // force a rescan so config validation sees the freshly installed plugin. + clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; @@ -640,6 +644,8 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(result.error); process.exit(1); } + // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. + clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); next = recordPluginInstall(next, { diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 524c6b3a88e..ab0d6b497bf 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -43,6 +43,13 @@ export function installBaseProgramMocks() { ], configureCommand, configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return configureCommandWithSections(resolved, runtime); + } + return configureCommand({}, runtime); + }, })); vi.mock("../commands/setup.js", () => ({ setupCommand })); vi.mock("../commands/onboard.js", () => ({ onboardCommand })); diff --git a/src/cli/respawn-policy.test.ts b/src/cli/respawn-policy.test.ts deleted file mode 100644 index 25e026b0a56..00000000000 --- a/src/cli/respawn-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldSkipRespawnForArgv } from "./respawn-policy.js"; - -describe("shouldSkipRespawnForArgv", () => { - it("skips respawn for help/version calls", () => { - expect(shouldSkipRespawnForArgv(["node", "openclaw", "--help"])).toBe(true); - expect(shouldSkipRespawnForArgv(["node", "openclaw", "-V"])).toBe(true); - }); - - it("keeps respawn path for normal commands", () => { - expect(shouldSkipRespawnForArgv(["node", "openclaw", "status"])).toBe(false); - }); -}); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 1e1a869eac4..550bbbf43ec 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateRunResult } from "../infra/update-runner.js"; +import { captureEnv } from "../test-utils/env.js"; const confirm = vi.fn(); const select = vi.fn(); @@ -597,7 +598,7 @@ describe("update-cli", () => { it("updateWizardCommand offers dev checkout and forwards selections", async () => { const tempDir = await createCaseDir("openclaw-update-wizard"); - const previousGitDir = process.env.OPENCLAW_GIT_DIR; + const envSnapshot = captureEnv(["OPENCLAW_GIT_DIR"]); try { setTty(true); process.env.OPENCLAW_GIT_DIR = tempDir; @@ -627,7 +628,7 @@ describe("update-cli", () => { const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; expect(call?.channel).toBe("dev"); } finally { - process.env.OPENCLAW_GIT_DIR = previousGitDir; + envSnapshot.restore(); } }); }); diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 507df6edc50..b137248f0b8 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js"; import { resolveStateDir } from "../../config/paths.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; +import { readPackageName, readPackageVersion } from "../../infra/package-json.js"; import { trimLogTail } from "../../infra/restart-sentinel.js"; import { parseSemver } from "../../infra/runtime-guard.js"; import { fetchNpmTagVersion } from "../../infra/update-check.js"; @@ -68,15 +69,7 @@ export function normalizeVersionTag(tag: string): string | null { return parseSemver(cleaned) ? cleaned : null; } -export async function readPackageVersion(root: string): Promise { - try { - const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { version?: string }; - return typeof parsed.version === "string" ? parsed.version : null; - } catch { - return null; - } -} +export { readPackageName, readPackageVersion }; export async function resolveTargetVersion( tag: string, @@ -99,17 +92,6 @@ export async function isGitCheckout(root: string): Promise { } } -export async function readPackageName(root: string): Promise { - try { - const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { name?: string }; - const name = parsed?.name?.trim(); - return name ? name : null; - } catch { - return null; - } -} - export async function isCorePackage(root: string): Promise { const name = await readPackageName(root); return Boolean(name && CORE_PACKAGE_NAMES.has(name)); diff --git a/src/cli/wait.test.ts b/src/cli/wait.test.ts deleted file mode 100644 index 5af1ba32a64..00000000000 --- a/src/cli/wait.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { waitForever } from "./wait.js"; - -describe("waitForever", () => { - it("creates an unref'ed interval and returns a pending promise", () => { - const setIntervalSpy = vi.spyOn(global, "setInterval"); - const promise = waitForever(); - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1_000_000); - expect(promise).toBeInstanceOf(Promise); - setIntervalSpy.mockRestore(); - }); -}); diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 3f6d995a908..aa0e2115235 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; const noopAsync = async () => {}; @@ -11,9 +12,7 @@ const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); describe("applyAuthChoiceHuggingface", () => { - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousHfToken = process.env.HF_TOKEN; - const previousHubToken = process.env.HUGGINGFACE_HUB_TOKEN; + const envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "HF_TOKEN", "HUGGINGFACE_HUB_TOKEN"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -21,21 +20,7 @@ describe("applyAuthChoiceHuggingface", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousHfToken === undefined) { - delete process.env.HF_TOKEN; - } else { - process.env.HF_TOKEN = previousHfToken; - } - if (previousHubToken === undefined) { - delete process.env.HUGGINGFACE_HUB_TOKEN; - } else { - process.env.HUGGINGFACE_HUB_TOKEN = previousHubToken; - } + envSnapshot.restore(); }); it("returns null when authChoice is not huggingface-api-key", async () => { diff --git a/src/commands/auth-choice.e2e.test.ts b/src/commands/auth-choice.e2e.test.ts index c58494792b5..2977b750e50 100644 --- a/src/commands/auth-choice.e2e.test.ts +++ b/src/commands/auth-choice.e2e.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { AuthChoice } from "./onboard-types.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { MINIMAX_CN_API_BASE_URL, @@ -38,18 +39,20 @@ const requireAgentDir = () => { }; describe("applyAuthChoice", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; - const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; - const previousHfToken = process.env.HF_TOKEN; - const previousHfHubToken = process.env.HUGGINGFACE_HUB_TOKEN; - const previousLitellmKey = process.env.LITELLM_API_KEY; - const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; - const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - const previousSshTty = process.env.SSH_TTY; - const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "HF_TOKEN", + "HUGGINGFACE_HUB_TOKEN", + "LITELLM_API_KEY", + "AI_GATEWAY_API_KEY", + "CLOUDFLARE_AI_GATEWAY_API_KEY", + "SSH_TTY", + "CHUTES_CLIENT_ID", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -61,66 +64,7 @@ describe("applyAuthChoice", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousAnthropicKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = previousAnthropicKey; - } - if (previousOpenrouterKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousOpenrouterKey; - } - if (previousHfToken === undefined) { - delete process.env.HF_TOKEN; - } else { - process.env.HF_TOKEN = previousHfToken; - } - if (previousHfHubToken === undefined) { - delete process.env.HUGGINGFACE_HUB_TOKEN; - } else { - process.env.HUGGINGFACE_HUB_TOKEN = previousHfHubToken; - } - if (previousLitellmKey === undefined) { - delete process.env.LITELLM_API_KEY; - } else { - process.env.LITELLM_API_KEY = previousLitellmKey; - } - if (previousAiGatewayKey === undefined) { - delete process.env.AI_GATEWAY_API_KEY; - } else { - process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey; - } - if (previousCloudflareGatewayKey === undefined) { - delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - } else { - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = previousCloudflareGatewayKey; - } - if (previousSshTty === undefined) { - delete process.env.SSH_TTY; - } else { - process.env.SSH_TTY = previousSshTty; - } - if (previousChutesClientId === undefined) { - delete process.env.CHUTES_CLIENT_ID; - } else { - process.env.CHUTES_CLIENT_ID = previousChutesClientId; - } + envSnapshot.restore(); }); it("does not throw when openai-codex oauth fails", async () => { diff --git a/src/commands/auth-choice.moonshot.e2e.test.ts b/src/commands/auth-choice.moonshot.e2e.test.ts index 8bddbd7a6f6..d215125f357 100644 --- a/src/commands/auth-choice.moonshot.e2e.test.ts +++ b/src/commands/auth-choice.moonshot.e2e.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice } from "./auth-choice.js"; const noopAsync = async () => {}; @@ -17,65 +18,61 @@ const requireAgentDir = () => { return agentDir; }; +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + ...overrides, + }; +} + describe("applyAuthChoice (moonshot)", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousMoonshotKey = process.env.MOONSHOT_API_KEY; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "MOONSHOT_API_KEY", + ]); let tempStateDir: string | null = null; + async function setupTempState() { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + delete process.env.MOONSHOT_API_KEY; + } + afterEach(async () => { if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousMoonshotKey === undefined) { - delete process.env.MOONSHOT_API_KEY; - } else { - process.env.MOONSHOT_API_KEY = previousMoonshotKey; - } + envSnapshot.restore(); }); it("keeps the .cn baseUrl when setDefaultModel is false", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; - delete process.env.MOONSHOT_API_KEY; + await setupTempState(); const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); + const runtime = createRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", @@ -107,30 +104,11 @@ describe("applyAuthChoice (moonshot)", () => { }); it("sets the default model when setDefaultModel is true", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; - delete process.env.MOONSHOT_API_KEY; + await setupTempState(); const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); + const runtime = createRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index be1e3bb9fac..28debb7e411 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -18,21 +18,49 @@ export type ChannelsStatusOptions = { timeout?: string; }; +function appendEnabledConfiguredLinkedBits(bits: string[], account: Record) { + if (typeof account.enabled === "boolean") { + bits.push(account.enabled ? "enabled" : "disabled"); + } + if (typeof account.configured === "boolean") { + bits.push(account.configured ? "configured" : "not configured"); + } + if (typeof account.linked === "boolean") { + bits.push(account.linked ? "linked" : "not linked"); + } +} + +function appendModeBit(bits: string[], account: Record) { + if (typeof account.mode === "string" && account.mode.length > 0) { + bits.push(`mode:${account.mode}`); + } +} + +function appendTokenSourceBits(bits: string[], account: Record) { + if (typeof account.tokenSource === "string" && account.tokenSource) { + bits.push(`token:${account.tokenSource}`); + } + if (typeof account.botTokenSource === "string" && account.botTokenSource) { + bits.push(`bot:${account.botTokenSource}`); + } + if (typeof account.appTokenSource === "string" && account.appTokenSource) { + bits.push(`app:${account.appTokenSource}`); + } +} + +function appendBaseUrlBit(bits: string[], account: Record) { + if (typeof account.baseUrl === "string" && account.baseUrl) { + bits.push(`url:${account.baseUrl}`); + } +} + export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); const accountLines = (provider: ChatChannel, accounts: Array>) => accounts.map((account) => { const bits: string[] = []; - if (typeof account.enabled === "boolean") { - bits.push(account.enabled ? "enabled" : "disabled"); - } - if (typeof account.configured === "boolean") { - bits.push(account.configured ? "configured" : "not configured"); - } - if (typeof account.linked === "boolean") { - bits.push(account.linked ? "linked" : "not linked"); - } + appendEnabledConfiguredLinkedBits(bits, account); if (typeof account.running === "boolean") { bits.push(account.running ? "running" : "stopped"); } @@ -53,9 +81,7 @@ export function formatGatewayChannelsStatusLines(payload: Record 0) { - bits.push(`mode:${account.mode}`); - } + appendModeBit(bits, account); const botUsername = (() => { const bot = account.bot as { username?: string | null } | undefined; const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot; @@ -78,15 +104,7 @@ export function formatGatewayChannelsStatusLines(payload: Record 0) { bits.push(`allow:${account.allowFrom.slice(0, 2).join(",")}`); } - if (typeof account.tokenSource === "string" && account.tokenSource) { - bits.push(`token:${account.tokenSource}`); - } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { - bits.push(`bot:${account.botTokenSource}`); - } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { - bits.push(`app:${account.appTokenSource}`); - } + appendTokenSourceBits(bits, account); const application = account.application as | { intents?: { messageContent?: string } } | undefined; @@ -101,9 +119,7 @@ export function formatGatewayChannelsStatusLines(payload: Record>) => accounts.map((account) => { const bits: string[] = []; - if (typeof account.enabled === "boolean") { - bits.push(account.enabled ? "enabled" : "disabled"); - } - if (typeof account.configured === "boolean") { - bits.push(account.configured ? "configured" : "not configured"); - } - if (typeof account.linked === "boolean") { - bits.push(account.linked ? "linked" : "not linked"); - } - if (typeof account.mode === "string" && account.mode.length > 0) { - bits.push(`mode:${account.mode}`); - } - if (typeof account.tokenSource === "string" && account.tokenSource) { - bits.push(`token:${account.tokenSource}`); - } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { - bits.push(`bot:${account.botTokenSource}`); - } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { - bits.push(`app:${account.appTokenSource}`); - } - if (typeof account.baseUrl === "string" && account.baseUrl) { - bits.push(`url:${account.baseUrl}`); - } + appendEnabledConfiguredLinkedBits(bits, account); + appendModeBit(bits, account); + appendTokenSourceBits(bits, account); + appendBaseUrlBit(bits, account); const accountId = typeof account.accountId === "string" ? account.accountId : "default"; const name = typeof account.name === "string" ? account.name.trim() : ""; const labelText = formatChannelAccountLabel({ diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index eeaf02ae4e8..2d82753cca2 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -1,7 +1,8 @@ import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, it, test } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { buildCleanupPlan } from "./cleanup-utils.js"; +import { applyAgentDefaultPrimaryModel } from "./model-default.js"; describe("buildCleanupPlan", () => { test("resolves inside-state flags and workspace dirs", () => { @@ -29,3 +30,23 @@ describe("buildCleanupPlan", () => { ); }); }); + +describe("applyAgentDefaultPrimaryModel", () => { + it("does not mutate when already set", () => { + const cfg = { agents: { defaults: { model: { primary: "a/b" } } } } as OpenClawConfig; + const result = applyAgentDefaultPrimaryModel({ cfg, model: "a/b" }); + expect(result.changed).toBe(false); + expect(result.next).toBe(cfg); + }); + + it("normalizes legacy models", () => { + const cfg = { agents: { defaults: { model: { primary: "legacy" } } } } as OpenClawConfig; + const result = applyAgentDefaultPrimaryModel({ + cfg, + model: "a/b", + legacyModels: new Set(["legacy"]), + }); + expect(result.changed).toBe(false); + expect(result.next).toBe(cfg); + }); +}); diff --git a/src/commands/model-default.test.ts b/src/commands/model-default.test.ts deleted file mode 100644 index dab27ae31e6..00000000000 --- a/src/commands/model-default.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -describe("applyAgentDefaultPrimaryModel", () => { - it("does not mutate when already set", () => { - const cfg = { agents: { defaults: { model: { primary: "a/b" } } } } as OpenClawConfig; - const result = applyAgentDefaultPrimaryModel({ cfg, model: "a/b" }); - expect(result.changed).toBe(false); - expect(result.next).toBe(cfg); - }); - - it("normalizes legacy models", () => { - const cfg = { agents: { defaults: { model: { primary: "legacy" } } } } as OpenClawConfig; - const result = applyAgentDefaultPrimaryModel({ - cfg, - model: "a/b", - legacyModels: new Set(["legacy"]), - }); - expect(result.changed).toBe(false); - expect(result.next).toBe(cfg); - }); -}); diff --git a/src/commands/models/set-image.ts b/src/commands/models/set-image.ts index 0b15ff5e670..920418e4f89 100644 --- a/src/commands/models/set-image.ts +++ b/src/commands/models/set-image.ts @@ -1,34 +1,10 @@ import type { RuntimeEnv } from "../../runtime.js"; import { logConfigUpdated } from "../../config/logging.js"; -import { - mergePrimaryFallbackConfig, - type PrimaryFallbackConfig, - resolveModelTarget, - updateConfig, -} from "./shared.js"; +import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; export async function modelsSetImageCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const key = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agents?.defaults?.models }; - if (!nextModels[key]) { - nextModels[key] = {}; - } - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - imageModel: mergePrimaryFallbackConfig( - cfg.agents?.defaults?.imageModel as unknown as PrimaryFallbackConfig | undefined, - { primary: key }, - ), - models: nextModels, - }, - }, - }; + return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "imageModel" }); }); logConfigUpdated(runtime); diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index a6291f72696..d0506acbdf6 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,34 +1,10 @@ import type { RuntimeEnv } from "../../runtime.js"; import { logConfigUpdated } from "../../config/logging.js"; -import { - mergePrimaryFallbackConfig, - type PrimaryFallbackConfig, - resolveModelTarget, - updateConfig, -} from "./shared.js"; +import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const key = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agents?.defaults?.models }; - if (!nextModels[key]) { - nextModels[key] = {}; - } - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - model: mergePrimaryFallbackConfig( - cfg.agents?.defaults?.model as unknown as PrimaryFallbackConfig | undefined, - { primary: key }, - ), - models: nextModels, - }, - }, - }; + return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "model" }); }); logConfigUpdated(runtime); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 9f65eebf8f1..64439ef60c7 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -169,6 +169,37 @@ export function mergePrimaryFallbackConfig( return next; } +export function applyDefaultModelPrimaryUpdate(params: { + cfg: OpenClawConfig; + modelRaw: string; + field: "model" | "imageModel"; +}): OpenClawConfig { + const resolved = resolveModelTarget({ raw: params.modelRaw, cfg: params.cfg }); + const key = `${resolved.provider}/${resolved.model}`; + + const nextModels = { ...params.cfg.agents?.defaults?.models }; + if (!nextModels[key]) { + nextModels[key] = {}; + } + + const defaults = params.cfg.agents?.defaults ?? {}; + const existing = (defaults as Record)[params.field] as + | PrimaryFallbackConfig + | undefined; + + return { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...defaults, + [params.field]: mergePrimaryFallbackConfig(existing, { primary: key }), + models: nextModels, + }, + }, + }; +} + export { modelKey }; export { DEFAULT_MODEL, DEFAULT_PROVIDER }; diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index eb6858f87d5..a26c544e133 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthProfileConfig, applyLitellmProviderConfig, @@ -40,9 +41,12 @@ const requireAgentDir = () => { }; describe("writeOAuthCredentials", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "OPENCLAW_OAUTH_DIR", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -50,22 +54,7 @@ describe("writeOAuthCredentials", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - delete process.env.OPENCLAW_OAUTH_DIR; + envSnapshot.restore(); }); it("writes auth-profiles.json under OPENCLAW_AGENT_DIR when set", async () => { @@ -100,9 +89,11 @@ describe("writeOAuthCredentials", () => { }); describe("setMinimaxApiKey", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -110,21 +101,7 @@ describe("setMinimaxApiKey", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); }); it("writes to OPENCLAW_AGENT_DIR when set", async () => { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 25c1c6fc220..210ef5b7ad1 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -12,6 +12,32 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { setupChannels } from "./onboard-channels.js"; +const noopAsync = async () => {}; + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "__done__" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + vi.mock("node:fs/promises", () => ({ default: { access: vi.fn(async () => { @@ -56,24 +82,13 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -97,24 +112,14 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), + const prompter = createPrompter({ note, select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -146,24 +151,13 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels( { @@ -209,24 +203,13 @@ describe("setupChannels", () => { const multiselect = vi.fn(async () => { throw new Error("unexpected multiselect"); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, - text: vi.fn(async () => ""), - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels( { diff --git a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts index 188bfae6aa1..ea2a4199307 100644 --- a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js"; @@ -12,20 +13,6 @@ type RuntimeMock = { exit: (code: number) => never; }; -type EnvSnapshot = { - home: string | undefined; - stateDir: string | undefined; - configPath: string | undefined; - skipChannels: string | undefined; - skipGmail: string | undefined; - skipCron: string | undefined; - skipCanvas: string | undefined; - token: string | undefined; - password: string | undefined; - customApiKey: string | undefined; - disableConfigCache: string | undefined; -}; - type OnboardEnv = { configPath: string; runtime: RuntimeMock; @@ -47,49 +34,23 @@ async function removeDirWithRetry(dir: string): Promise { } } -function captureEnv(): EnvSnapshot { - return { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - customApiKey: process.env.CUSTOM_API_KEY, - disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE, - }; -} - -function restoreEnvVar(key: keyof NodeJS.ProcessEnv, value: string | undefined): void { - if (value == null) { - delete process.env[key]; - return; - } - process.env[key] = value; -} - -function restoreEnv(prev: EnvSnapshot): void { - restoreEnvVar("HOME", prev.home); - restoreEnvVar("OPENCLAW_STATE_DIR", prev.stateDir); - restoreEnvVar("OPENCLAW_CONFIG_PATH", prev.configPath); - restoreEnvVar("OPENCLAW_SKIP_CHANNELS", prev.skipChannels); - restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", prev.skipGmail); - restoreEnvVar("OPENCLAW_SKIP_CRON", prev.skipCron); - restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas); - restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token); - restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password); - restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey); - restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache); -} - async function withOnboardEnv( prefix: string, run: (ctx: OnboardEnv) => Promise, ): Promise { - const prev = captureEnv(); + const prev = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_SKIP_GMAIL_WATCHER", + "OPENCLAW_SKIP_CRON", + "OPENCLAW_SKIP_CANVAS_HOST", + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "CUSTOM_API_KEY", + "OPENCLAW_DISABLE_CONFIG_CACHE", + ]); process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; @@ -120,7 +81,7 @@ async function withOnboardEnv( await run({ configPath, runtime }); } finally { await removeDirWithRetry(tempHome); - restoreEnv(prev); + prev.restore(); } } diff --git a/src/commands/status.e2e.test.ts b/src/commands/status.e2e.test.ts index e3e9e7673fc..57f8d8f62fc 100644 --- a/src/commands/status.e2e.test.ts +++ b/src/commands/status.e2e.test.ts @@ -1,18 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; -let previousProfile: string | undefined; +let envSnapshot: ReturnType; beforeAll(() => { - previousProfile = process.env.OPENCLAW_PROFILE; + envSnapshot = captureEnv(["OPENCLAW_PROFILE"]); process.env.OPENCLAW_PROFILE = "isolated"; }); afterAll(() => { - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - } else { - process.env.OPENCLAW_PROFILE = previousProfile; - } + envSnapshot.restore(); }); const mocks = vi.hoisted(() => ({ @@ -249,6 +246,7 @@ vi.mock("../infra/update-check.js", () => ({ }, registry: { latestVersion: "0.0.0" }, }), + formatGitInstallLabel: vi.fn(() => "main · @ deadbeef"), compareSemverStrings: vi.fn(() => 0), })); vi.mock("../config/config.js", async (importOriginal) => { diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts new file mode 100644 index 00000000000..e3853666889 --- /dev/null +++ b/src/config/config-misc.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("$schema key in config (#14998)", () => { + it("accepts config with $schema string", () => { + const result = OpenClawSchema.safeParse({ + $schema: "https://openclaw.ai/config.json", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.$schema).toBe("https://openclaw.ai/config.json"); + } + }); + + it("accepts config without $schema", () => { + const result = OpenClawSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects non-string $schema", () => { + const result = OpenClawSchema.safeParse({ $schema: 123 }); + expect(result.success).toBe(false); + }); +}); + +describe("ui.seamColor", () => { + it("accepts hex colors", () => { + const res = validateConfigObject({ ui: { seamColor: "#FF4500" } }); + expect(res.ok).toBe(true); + }); + + it("rejects non-hex colors", () => { + const res = validateConfigObject({ ui: { seamColor: "lobster" } }); + expect(res.ok).toBe(false); + }); + + it("rejects invalid hex length", () => { + const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } }); + expect(res.ok).toBe(false); + }); +}); + +describe("web search provider config", () => { + it("accepts perplexity provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "perplexity", + perplexity: { + apiKey: "test-key", + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); + +describe("talk.voiceAliases", () => { + it("accepts a string map of voice aliases", () => { + const res = validateConfigObject({ + talk: { + voiceAliases: { + Clawd: "EXAVITQu4vr4xnSDxMaL", + Roger: "CwhRBWXzGAHq8TQ4Fs17", + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects non-string voice alias values", () => { + const res = validateConfigObject({ + talk: { + voiceAliases: { + Clawd: 123, + }, + }, + }); + expect(res.ok).toBe(false); + }); +}); diff --git a/src/config/config.cron-webhook-schema.test.ts b/src/config/config.cron-webhook-schema.test.ts new file mode 100644 index 00000000000..e6f64bf5890 --- /dev/null +++ b/src/config/config.cron-webhook-schema.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("cron webhook schema", () => { + it("accepts cron.webhook and cron.webhookToken", () => { + const res = OpenClawSchema.safeParse({ + cron: { + enabled: true, + webhook: "https://example.invalid/cron", + webhookToken: "secret-token", + }, + }); + + expect(res.success).toBe(true); + }); + + it("rejects non-http(s) cron.webhook URLs", () => { + const res = OpenClawSchema.safeParse({ + cron: { + webhook: "ftp://example.invalid/cron", + }, + }); + + expect(res.success).toBe(false); + }); +}); diff --git a/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts b/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts index da574e9a4d8..371b1da121c 100644 --- a/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts +++ b/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts @@ -12,7 +12,8 @@ import { import { withTempHome } from "./test-helpers.js"; function envWith(overrides: Record): NodeJS.ProcessEnv { - return { ...process.env, ...overrides }; + // Hermetic env: don't inherit process.env because other tests may mutate it. + return { ...overrides }; } function loadConfigForHome(home: string) { diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 92903ff32f7..7add1d3c293 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -3,13 +3,13 @@ import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { - it("accepts binds array in sandbox.docker config", () => { + it("accepts safe binds array in sandbox.docker config", () => { const res = validateConfigObject({ agents: { defaults: { sandbox: { docker: { - binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }, }, }, @@ -29,8 +29,8 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); if (res.ok) { expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([ - "/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw", + "/var/data/myapp:/data:ro", ]); expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([ "/home/user/projects:/projects:ro", @@ -38,6 +38,51 @@ describe("sandbox docker config", () => { } }); + it("rejects network host mode via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "host", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects seccomp unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + seccompProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects apparmor unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + apparmorProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + it("rejects non-string values in binds array", () => { const res = validateConfigObject({ agents: { diff --git a/src/config/config.schema-key.test.ts b/src/config/config.schema-key.test.ts deleted file mode 100644 index effa08347fa..00000000000 --- a/src/config/config.schema-key.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OpenClawSchema } from "./zod-schema.js"; - -describe("$schema key in config (#14998)", () => { - it("accepts config with $schema string", () => { - const result = OpenClawSchema.safeParse({ - $schema: "https://openclaw.ai/config.json", - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.$schema).toBe("https://openclaw.ai/config.json"); - } - }); - - it("accepts config without $schema", () => { - const result = OpenClawSchema.safeParse({}); - expect(result.success).toBe(true); - }); - - it("rejects non-string $schema", () => { - const result = OpenClawSchema.safeParse({ $schema: 123 }); - expect(result.success).toBe(false); - }); -}); diff --git a/src/config/config.talk-voicealiases.test.ts b/src/config/config.talk-voicealiases.test.ts deleted file mode 100644 index e7e32c5b698..00000000000 --- a/src/config/config.talk-voicealiases.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("talk.voiceAliases", () => { - it("accepts a string map of voice aliases", () => { - const res = validateConfigObject({ - talk: { - voiceAliases: { - Clawd: "EXAVITQu4vr4xnSDxMaL", - Roger: "CwhRBWXzGAHq8TQ4Fs17", - }, - }, - }); - expect(res.ok).toBe(true); - }); - - it("rejects non-string voice alias values", () => { - const res = validateConfigObject({ - talk: { - voiceAliases: { - Clawd: 123, - }, - }, - }); - expect(res.ok).toBe(false); - }); -}); diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts deleted file mode 100644 index a0f1c6acded..00000000000 --- a/src/config/config.web-search-provider.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("web search provider config", () => { - it("accepts perplexity provider and config", () => { - const res = validateConfigObject({ - tools: { - web: { - search: { - enabled: true, - provider: "perplexity", - perplexity: { - apiKey: "test-key", - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-pro", - }, - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); -}); diff --git a/src/config/home-env.test-harness.ts b/src/config/home-env.test-harness.ts index 02808461b0f..78abde370dc 100644 --- a/src/config/home-env.test-harness.ts +++ b/src/config/home-env.test-harness.ts @@ -1,39 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; - -type HomeEnvSnapshot = { - home: string | undefined; - userProfile: string | undefined; - homeDrive: string | undefined; - homePath: string | undefined; - stateDir: string | undefined; -}; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - home: process.env.HOME, - userProfile: process.env.USERPROFILE, - homeDrive: process.env.HOMEDRIVE, - homePath: process.env.HOMEPATH, - stateDir: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreKey = (key: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreKey("HOME", snapshot.home); - restoreKey("USERPROFILE", snapshot.userProfile); - restoreKey("HOMEDRIVE", snapshot.homeDrive); - restoreKey("HOMEPATH", snapshot.homePath); - restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); -} +import { captureEnv } from "../test-utils/env.js"; export async function withTempHome( prefix: string, @@ -42,7 +10,13 @@ export async function withTempHome( const home = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - const snapshot = snapshotHomeEnv(); + const snapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); process.env.HOME = home; process.env.USERPROFILE = home; process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); @@ -58,7 +32,7 @@ export async function withTempHome( try { return await fn(home); } finally { - restoreHomeEnv(snapshot); + snapshot.restore(); await fs.rm(home, { recursive: true, force: true }); } } diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index b8353e54b62..04f5e34a77f 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { createConfigIO } from "./io.js"; describe("config io write", () => { @@ -10,37 +11,6 @@ describe("config io write", () => { error: () => {}, }; - type HomeEnvSnapshot = { - home: string | undefined; - userProfile: string | undefined; - homeDrive: string | undefined; - homePath: string | undefined; - stateDir: string | undefined; - }; - - const snapshotHomeEnv = (): HomeEnvSnapshot => ({ - home: process.env.HOME, - userProfile: process.env.USERPROFILE, - homeDrive: process.env.HOMEDRIVE, - homePath: process.env.HOMEPATH, - stateDir: process.env.OPENCLAW_STATE_DIR, - }); - - const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { - const restoreKey = (key: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreKey("HOME", snapshot.home); - restoreKey("USERPROFILE", snapshot.userProfile); - restoreKey("HOMEDRIVE", snapshot.homeDrive); - restoreKey("HOMEPATH", snapshot.homePath); - restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); - }; - let fixtureRoot = ""; let caseId = 0; @@ -49,7 +19,13 @@ describe("config io write", () => { const home = path.join(fixtureRoot, `${safePrefix}${caseId++}`); await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - const snapshot = snapshotHomeEnv(); + const snapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); process.env.HOME = home; process.env.USERPROFILE = home; process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); @@ -65,7 +41,7 @@ describe("config io write", () => { try { await fn(home); } finally { - restoreHomeEnv(snapshot); + snapshot.restore(); } } diff --git a/src/config/sessions/metadata.test.ts b/src/config/sessions/metadata.test.ts deleted file mode 100644 index c85624f0cbb..00000000000 --- a/src/config/sessions/metadata.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deriveSessionMetaPatch } from "./metadata.js"; - -describe("deriveSessionMetaPatch", () => { - it("captures origin + group metadata", () => { - const patch = deriveSessionMetaPatch({ - ctx: { - Provider: "whatsapp", - ChatType: "group", - GroupSubject: "Family", - From: "123@g.us", - }, - sessionKey: "agent:main:whatsapp:group:123@g.us", - }); - - expect(patch?.origin?.label).toBe("Family id:123@g.us"); - expect(patch?.origin?.provider).toBe("whatsapp"); - expect(patch?.subject).toBe("Family"); - expect(patch?.channel).toBe("whatsapp"); - expect(patch?.groupId).toBe("123@g.us"); - }); -}); diff --git a/src/config/sessions/paths.test.ts b/src/config/sessions/paths.test.ts deleted file mode 100644 index 443b7791b8f..00000000000 --- a/src/config/sessions/paths.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveSessionFilePath, - resolveSessionFilePathOptions, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, - resolveStorePath, - validateSessionId, -} from "./paths.js"; - -describe("resolveStorePath", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("uses OPENCLAW_HOME for tilde expansion", () => { - vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); - vi.stubEnv("HOME", "/home/other"); - - const resolved = resolveStorePath("~/.openclaw/agents/{agentId}/sessions/sessions.json", { - agentId: "research", - }); - - expect(resolved).toBe( - path.resolve("/srv/openclaw-home/.openclaw/agents/research/sessions/sessions.json"), - ); - }); -}); - -describe("session path safety", () => { - it("validates safe session IDs", () => { - expect(validateSessionId("sess-1")).toBe("sess-1"); - expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello"); - }); - - it("rejects unsafe session IDs", () => { - expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); - }); - - it("resolves transcript path inside an explicit sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); - - expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); - }); - - it("rejects unsafe sessionFile candidates that escape the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }), - ).toThrow(/within sessions directory/); - - expect(() => - resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }), - ).toThrow(/within sessions directory/); - }); - - it("accepts sessionFile candidates within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "subdir/threaded-session.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); - }); - - it("accepts absolute sessionFile paths that resolve within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl")); - }); - - it("accepts absolute sessionFile with topic suffix within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl")); - }); - - it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, - { sessionsDir }, - ), - ).toThrow(/within sessions directory/); - }); - - it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => { - const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); - const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); - const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir, agentId: "ops" }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses absolute path fallback when sessionFile includes a different agent dir", () => { - const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); - const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); - const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses sibling fallback for custom per-agent store roots", () => { - const mainSessionsDir = "/srv/custom/agents/main/sessions"; - const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir, agentId: "ops" }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses extracted agent fallback for custom per-agent store roots", () => { - const mainSessionsDir = "/srv/custom/agents/main/sessions"; - const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses agent sessions dir fallback for transcript path", () => { - const resolved = resolveSessionTranscriptPath("sess-1", "main"); - expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); - }); - - it("keeps storePath and agentId when resolving session file options", () => { - const opts = resolveSessionFilePathOptions({ - storePath: "/tmp/custom/agent-store/sessions.json", - agentId: "ops", - }); - expect(opts).toEqual({ - sessionsDir: path.resolve("/tmp/custom/agent-store"), - agentId: "ops", - }); - }); - - it("keeps custom per-agent store roots when agentId is provided", () => { - const opts = resolveSessionFilePathOptions({ - storePath: "/srv/custom/agents/ops/sessions/sessions.json", - agentId: "ops", - }); - expect(opts).toEqual({ - sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"), - agentId: "ops", - }); - }); - - it("falls back to agentId when storePath is absent", () => { - const opts = resolveSessionFilePathOptions({ agentId: "ops" }); - expect(opts).toEqual({ agentId: "ops" }); - }); -}); diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts deleted file mode 100644 index 01962a887e5..00000000000 --- a/src/config/sessions/reset.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionConfig } from "../types.base.js"; -import { resolveSessionResetPolicy } from "./reset.js"; - -describe("resolveSessionResetPolicy", () => { - describe("backward compatibility: resetByType.dm → direct", () => { - it("uses resetByType.direct when available", () => { - const sessionCfg = { - resetByType: { - direct: { mode: "idle" as const, idleMinutes: 30 }, - }, - } satisfies SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("idle"); - expect(policy.idleMinutes).toBe(30); - }); - - it("falls back to resetByType.dm (legacy) when direct is missing", () => { - // Simulating legacy config with "dm" key instead of "direct" - const sessionCfg = { - resetByType: { - dm: { mode: "idle" as const, idleMinutes: 45 }, - }, - } as unknown as SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("idle"); - expect(policy.idleMinutes).toBe(45); - }); - - it("prefers resetByType.direct over resetByType.dm when both present", () => { - const sessionCfg = { - resetByType: { - direct: { mode: "daily" as const }, - dm: { mode: "idle" as const, idleMinutes: 99 }, - }, - } as unknown as SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("daily"); - }); - - it("does not use dm fallback for group/thread types", () => { - const sessionCfg = { - resetByType: { - dm: { mode: "idle" as const, idleMinutes: 45 }, - }, - } as unknown as SessionConfig; - - const groupPolicy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "group", - }); - - // Should use default mode since group has no config and dm doesn't apply - expect(groupPolicy.mode).toBe("daily"); - }); - }); -}); diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts new file mode 100644 index 00000000000..ae963eb6441 --- /dev/null +++ b/src/config/sessions/sessions.test.ts @@ -0,0 +1,652 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionConfig } from "../types.base.js"; +import type { SessionEntry } from "./types.js"; +import { + clearSessionStoreCacheForTest, + getSessionStoreLockQueueSizeForTest, + loadSessionStore, + updateSessionStore, + updateSessionStoreEntry, +} from "../sessions.js"; +import { withSessionStoreLockForTest } from "../sessions.js"; +import { deriveSessionMetaPatch } from "./metadata.js"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, + resolveSessionTranscriptPath, + resolveSessionTranscriptPathInDir, + resolveStorePath, + validateSessionId, +} from "./paths.js"; +import { resolveSessionResetPolicy } from "./reset.js"; +import { updateSessionStore as updateSessionStoreUnsafe } from "./store.js"; +import { + appendAssistantMessageToSessionTranscript, + resolveMirroredTranscriptText, +} from "./transcript.js"; + +describe("deriveSessionMetaPatch", () => { + it("captures origin + group metadata", () => { + const patch = deriveSessionMetaPatch({ + ctx: { + Provider: "whatsapp", + ChatType: "group", + GroupSubject: "Family", + From: "123@g.us", + }, + sessionKey: "agent:main:whatsapp:group:123@g.us", + }); + + expect(patch?.origin?.label).toBe("Family id:123@g.us"); + expect(patch?.origin?.provider).toBe("whatsapp"); + expect(patch?.subject).toBe("Family"); + expect(patch?.channel).toBe("whatsapp"); + expect(patch?.groupId).toBe("123@g.us"); + }); +}); + +describe("resolveStorePath", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses OPENCLAW_HOME for tilde expansion", () => { + vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); + vi.stubEnv("HOME", "/home/other"); + + const resolved = resolveStorePath("~/.openclaw/agents/{agentId}/sessions/sessions.json", { + agentId: "research", + }); + + expect(resolved).toBe( + path.resolve("/srv/openclaw-home/.openclaw/agents/research/sessions/sessions.json"), + ); + }); +}); + +describe("session path safety", () => { + it("validates safe session IDs", () => { + expect(validateSessionId("sess-1")).toBe("sess-1"); + expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello"); + }); + + it("rejects unsafe session IDs", () => { + expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); + }); + + it("resolves transcript path inside an explicit sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); + + expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); + }); + + it("rejects unsafe sessionFile candidates that escape the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }), + ).toThrow(/within sessions directory/); + + expect(() => + resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }), + ).toThrow(/within sessions directory/); + }); + + it("accepts sessionFile candidates within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "subdir/threaded-session.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); + }); + + it("accepts absolute sessionFile paths that resolve within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl")); + }); + + it("accepts absolute sessionFile with topic suffix within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl")); + }); + + it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, + { sessionsDir }, + ), + ).toThrow(/within sessions directory/); + }); + + it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => { + const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); + const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); + const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir, agentId: "ops" }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses absolute path fallback when sessionFile includes a different agent dir", () => { + const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); + const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); + const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses sibling fallback for custom per-agent store roots", () => { + const mainSessionsDir = "/srv/custom/agents/main/sessions"; + const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir, agentId: "ops" }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses extracted agent fallback for custom per-agent store roots", () => { + const mainSessionsDir = "/srv/custom/agents/main/sessions"; + const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses agent sessions dir fallback for transcript path", () => { + const resolved = resolveSessionTranscriptPath("sess-1", "main"); + expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); + }); + + it("keeps storePath and agentId when resolving session file options", () => { + const opts = resolveSessionFilePathOptions({ + storePath: "/tmp/custom/agent-store/sessions.json", + agentId: "ops", + }); + expect(opts).toEqual({ + sessionsDir: path.resolve("/tmp/custom/agent-store"), + agentId: "ops", + }); + }); + + it("keeps custom per-agent store roots when agentId is provided", () => { + const opts = resolveSessionFilePathOptions({ + storePath: "/srv/custom/agents/ops/sessions/sessions.json", + agentId: "ops", + }); + expect(opts).toEqual({ + sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"), + agentId: "ops", + }); + }); + + it("falls back to agentId when storePath is absent", () => { + const opts = resolveSessionFilePathOptions({ agentId: "ops" }); + expect(opts).toEqual({ agentId: "ops" }); + }); +}); + +describe("resolveSessionResetPolicy", () => { + describe("backward compatibility: resetByType.dm -> direct", () => { + it("uses resetByType.direct when available", () => { + const sessionCfg = { + resetByType: { + direct: { mode: "idle" as const, idleMinutes: 30 }, + }, + } satisfies SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("idle"); + expect(policy.idleMinutes).toBe(30); + }); + + it("falls back to resetByType.dm (legacy) when direct is missing", () => { + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("idle"); + expect(policy.idleMinutes).toBe(45); + }); + + it("prefers resetByType.direct over resetByType.dm when both present", () => { + const sessionCfg = { + resetByType: { + direct: { mode: "daily" as const }, + dm: { mode: "idle" as const, idleMinutes: 99 }, + }, + } as unknown as SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("daily"); + }); + + it("does not use dm fallback for group/thread types", () => { + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const groupPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "group", + }); + + expect(groupPolicy.mode).toBe("daily"); + }); + }); +}); + +describe("session store lock (Promise chain mutex)", () => { + let lockFixtureRoot = ""; + let lockCaseId = 0; + let lockTmpDirs: string[] = []; + + async function makeTmpStore( + initial: Record = {}, + ): Promise<{ dir: string; storePath: string }> { + const dir = path.join(lockFixtureRoot, `case-${lockCaseId++}`); + await fsPromises.mkdir(dir); + lockTmpDirs.push(dir); + const storePath = path.join(dir, "sessions.json"); + if (Object.keys(initial).length > 0) { + await fsPromises.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8"); + } + return { dir, storePath }; + } + + beforeAll(async () => { + lockFixtureRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-")); + }); + + afterAll(async () => { + if (lockFixtureRoot) { + await fsPromises.rm(lockFixtureRoot, { recursive: true, force: true }).catch(() => undefined); + } + }); + + afterEach(async () => { + clearSessionStoreCacheForTest(); + lockTmpDirs = []; + }); + + it("serializes concurrent updateSessionStore calls without data loss", async () => { + const key = "agent:main:test"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, + }); + + const N = 4; + await Promise.all( + Array.from({ length: N }, (_, i) => + updateSessionStore(storePath, async (store) => { + const entry = store[key] as Record; + await Promise.resolve(); + entry.counter = (entry.counter as number) + 1; + entry.tag = `writer-${i}`; + }), + ), + ); + + const store = loadSessionStore(storePath); + expect((store[key] as Record).counter).toBe(N); + }); + + it("concurrent updateSessionStoreEntry patches all merge correctly", async () => { + const key = "agent:main:merge"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + await Promise.all([ + updateSessionStoreEntry({ + storePath, + sessionKey: key, + update: async () => { + await Promise.resolve(); + return { modelOverride: "model-a" }; + }, + }), + updateSessionStoreEntry({ + storePath, + sessionKey: key, + update: async () => { + await Promise.resolve(); + return { thinkingLevel: "high" as const }; + }, + }), + updateSessionStoreEntry({ + storePath, + sessionKey: key, + update: async () => { + await Promise.resolve(); + return { systemPromptOverride: "custom" }; + }, + }), + ]); + + const store = loadSessionStore(storePath); + const entry = store[key]; + expect(entry.modelOverride).toBe("model-a"); + expect(entry.thinkingLevel).toBe("high"); + expect(entry.systemPromptOverride).toBe("custom"); + }); + + it("continues processing queued tasks after a preceding task throws", async () => { + const key = "agent:main:err"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + const errorPromise = updateSessionStore(storePath, async () => { + throw new Error("boom"); + }); + + const successPromise = updateSessionStore(storePath, async (store) => { + store[key] = { ...store[key], modelOverride: "after-error" } as unknown as SessionEntry; + }); + + await expect(errorPromise).rejects.toThrow("boom"); + await successPromise; + + const store = loadSessionStore(storePath); + expect(store[key]?.modelOverride).toBe("after-error"); + }); + + it("multiple consecutive errors do not permanently poison the queue", async () => { + const key = "agent:main:multi-err"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + const errors = Array.from({ length: 3 }, (_, i) => + updateSessionStore(storePath, async () => { + throw new Error(`fail-${i}`); + }), + ); + + const success = updateSessionStore(storePath, async (store) => { + store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry; + }); + + for (const p of errors) { + await expect(p).rejects.toThrow(); + } + await success; + + const store = loadSessionStore(storePath); + expect(store[key]?.modelOverride).toBe("recovered"); + }); + + it("operations on different storePaths execute concurrently", async () => { + const { storePath: pathA } = await makeTmpStore({ + a: { sessionId: "a", updatedAt: 100 }, + }); + const { storePath: pathB } = await makeTmpStore({ + b: { sessionId: "b", updatedAt: 100 }, + }); + + const order: string[] = []; + let started = 0; + let releaseBoth: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseBoth = resolve; + }); + const markStarted = () => { + started += 1; + if (started === 2) { + releaseBoth?.(); + } + }; + + const opA = updateSessionStore(pathA, async (store) => { + order.push("a-start"); + markStarted(); + await gate; + store.a = { ...store.a, modelOverride: "done-a" } as unknown as SessionEntry; + order.push("a-end"); + }); + + const opB = updateSessionStore(pathB, async (store) => { + order.push("b-start"); + markStarted(); + await gate; + store.b = { ...store.b, modelOverride: "done-b" } as unknown as SessionEntry; + order.push("b-end"); + }); + + await Promise.all([opA, opB]); + + const aStart = order.indexOf("a-start"); + const bStart = order.indexOf("b-start"); + const aEnd = order.indexOf("a-end"); + const bEnd = order.indexOf("b-end"); + const firstEnd = Math.min(aEnd, bEnd); + expect(aStart).toBeGreaterThanOrEqual(0); + expect(bStart).toBeGreaterThanOrEqual(0); + expect(aEnd).toBeGreaterThanOrEqual(0); + expect(bEnd).toBeGreaterThanOrEqual(0); + expect(aStart).toBeLessThan(firstEnd); + expect(bStart).toBeLessThan(firstEnd); + + expect(loadSessionStore(pathA).a?.modelOverride).toBe("done-a"); + expect(loadSessionStore(pathB).b?.modelOverride).toBe("done-b"); + }); + + it("cleans up LOCK_QUEUES entry after all tasks complete", async () => { + const { storePath } = await makeTmpStore({ + x: { sessionId: "x", updatedAt: 100 }, + }); + + await updateSessionStore(storePath, async (store) => { + store.x = { ...store.x, modelOverride: "done" } as unknown as SessionEntry; + }); + + await Promise.resolve(); + + expect(getSessionStoreLockQueueSizeForTest()).toBe(0); + }); + + it("cleans up LOCK_QUEUES entry even after errors", async () => { + const { storePath } = await makeTmpStore({}); + + await updateSessionStore(storePath, async () => { + throw new Error("fail"); + }).catch(() => undefined); + + await Promise.resolve(); + + expect(getSessionStoreLockQueueSizeForTest()).toBe(0); + }); +}); + +describe("withSessionStoreLock storePath guard (#14717)", () => { + it("throws descriptive error when storePath is undefined", async () => { + await expect( + updateSessionStoreUnsafe(undefined as unknown as string, (store) => store), + ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); + }); + + it("throws descriptive error when storePath is empty string", async () => { + await expect(updateSessionStoreUnsafe("", (store) => store)).rejects.toThrow( + "withSessionStoreLock: storePath must be a non-empty string", + ); + }); + + it("withSessionStoreLockForTest also throws descriptive error when storePath is undefined", async () => { + await expect( + withSessionStoreLockForTest(undefined as unknown as string, async () => {}), + ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); + }); +}); + +describe("resolveMirroredTranscriptText", () => { + it("prefers media filenames over text", () => { + const result = resolveMirroredTranscriptText({ + text: "caption here", + mediaUrls: ["https://example.com/files/report.pdf?sig=123"], + }); + expect(result).toBe("report.pdf"); + }); + + it("returns trimmed text when no media", () => { + const result = resolveMirroredTranscriptText({ text: " hello " }); + expect(result).toBe("hello"); + }); +}); + +describe("appendAssistantMessageToSessionTranscript", () => { + let tempDir: string; + let storePath: string; + let sessionsDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); + sessionsDir = path.join(tempDir, "agents", "main", "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + storePath = path.join(sessionsDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns error for missing sessionKey", async () => { + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "", + text: "test", + storePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("missing sessionKey"); + } + }); + + it("returns error for empty text", async () => { + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "test-session", + text: " ", + storePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("empty text"); + } + }); + + it("returns error for unknown sessionKey", async () => { + fs.writeFileSync(storePath, JSON.stringify({}), "utf-8"); + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "nonexistent", + text: "test message", + storePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("unknown sessionKey"); + } + }); + + it("creates transcript file and appends message for valid session", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(storePath, JSON.stringify(store), "utf-8"); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + storePath, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(fs.existsSync(result.sessionFile)).toBe(true); + + const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const header = JSON.parse(lines[0]); + expect(header.type).toBe("session"); + expect(header.id).toBe(sessionId); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.type).toBe("message"); + expect(messageLine.message.role).toBe("assistant"); + expect(messageLine.message.content[0].type).toBe("text"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + } + }); +}); diff --git a/src/config/sessions/store.lock.test.ts b/src/config/sessions/store.lock.test.ts deleted file mode 100644 index 91ee7e0ddf3..00000000000 --- a/src/config/sessions/store.lock.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { SessionEntry } from "./types.js"; -import { - clearSessionStoreCacheForTest, - getSessionStoreLockQueueSizeForTest, - loadSessionStore, - updateSessionStore, - updateSessionStoreEntry, - withSessionStoreLockForTest, -} from "../sessions.js"; - -describe("session store lock (Promise chain mutex)", () => { - let fixtureRoot = ""; - let caseId = 0; - let tmpDirs: string[] = []; - - function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; - } - - async function makeTmpStore( - initial: Record = {}, - ): Promise<{ dir: string; storePath: string }> { - const dir = path.join(fixtureRoot, `case-${caseId++}`); - await fs.mkdir(dir); - tmpDirs.push(dir); - const storePath = path.join(dir, "sessions.json"); - if (Object.keys(initial).length > 0) { - await fs.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8"); - } - return { dir, storePath }; - } - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); - } - }); - - afterEach(async () => { - clearSessionStoreCacheForTest(); - tmpDirs = []; - }); - - // ── 1. Concurrent access does not corrupt data ────────────────────── - - it("serializes concurrent updateSessionStore calls without data loss", async () => { - const key = "agent:main:test"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, - }); - - // Launch a few concurrent read-modify-write cycles (enough to surface stale-read races). - const N = 4; - await Promise.all( - Array.from({ length: N }, (_, i) => - updateSessionStore(storePath, async (store) => { - const entry = store[key] as Record; - // Keep an async boundary so stale-read races would surface without serialization. - await Promise.resolve(); - entry.counter = (entry.counter as number) + 1; - entry.tag = `writer-${i}`; - }), - ), - ); - - const store = loadSessionStore(storePath); - expect((store[key] as Record).counter).toBe(N); - }); - - it("concurrent updateSessionStoreEntry patches all merge correctly", async () => { - const key = "agent:main:merge"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - await Promise.all([ - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { modelOverride: "model-a" }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { thinkingLevel: "high" as const }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { systemPromptOverride: "custom" }; - }, - }), - ]); - - const store = loadSessionStore(storePath); - const entry = store[key]; - expect(entry.modelOverride).toBe("model-a"); - expect(entry.thinkingLevel).toBe("high"); - expect(entry.systemPromptOverride).toBe("custom"); - }); - - // ── 2. Error in fn() does not break queue ─────────────────────────── - - it("continues processing queued tasks after a preceding task throws", async () => { - const key = "agent:main:err"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const errorPromise = updateSessionStore(storePath, async () => { - throw new Error("boom"); - }); - - // Queue a second write immediately after the failing one. - const successPromise = updateSessionStore(storePath, async (store) => { - store[key] = { ...store[key], modelOverride: "after-error" } as unknown as SessionEntry; - }); - - await expect(errorPromise).rejects.toThrow("boom"); - await successPromise; // must resolve, not hang or reject - - const store = loadSessionStore(storePath); - expect(store[key]?.modelOverride).toBe("after-error"); - }); - - it("multiple consecutive errors do not permanently poison the queue", async () => { - const key = "agent:main:multi-err"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const errors = Array.from({ length: 3 }, (_, i) => - updateSessionStore(storePath, async () => { - throw new Error(`fail-${i}`); - }), - ); - - const success = updateSessionStore(storePath, async (store) => { - store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry; - }); - - // All error promises reject. - for (const p of errors) { - await expect(p).rejects.toThrow(); - } - // The trailing write succeeds. - await success; - - const store = loadSessionStore(storePath); - expect(store[key]?.modelOverride).toBe("recovered"); - }); - - // ── 3. Different storePaths run independently / in parallel ───────── - - it("operations on different storePaths execute concurrently", async () => { - const { storePath: pathA } = await makeTmpStore({ - a: { sessionId: "a", updatedAt: 100 }, - }); - const { storePath: pathB } = await makeTmpStore({ - b: { sessionId: "b", updatedAt: 100 }, - }); - - const order: string[] = []; - let started = 0; - let releaseBoth: (() => void) | undefined; - const gate = new Promise((resolve) => { - releaseBoth = resolve; - }); - const markStarted = () => { - started += 1; - if (started === 2) { - releaseBoth?.(); - } - }; - - const opA = updateSessionStore(pathA, async (store) => { - order.push("a-start"); - markStarted(); - await gate; - store.a = { ...store.a, modelOverride: "done-a" } as unknown as SessionEntry; - order.push("a-end"); - }); - - const opB = updateSessionStore(pathB, async (store) => { - order.push("b-start"); - markStarted(); - await gate; - store.b = { ...store.b, modelOverride: "done-b" } as unknown as SessionEntry; - order.push("b-end"); - }); - - await Promise.all([opA, opB]); - - // Parallel behavior: both ops start before either one finishes. - const aStart = order.indexOf("a-start"); - const bStart = order.indexOf("b-start"); - const aEnd = order.indexOf("a-end"); - const bEnd = order.indexOf("b-end"); - const firstEnd = Math.min(aEnd, bEnd); - expect(aStart).toBeGreaterThanOrEqual(0); - expect(bStart).toBeGreaterThanOrEqual(0); - expect(aEnd).toBeGreaterThanOrEqual(0); - expect(bEnd).toBeGreaterThanOrEqual(0); - expect(aStart).toBeLessThan(firstEnd); - expect(bStart).toBeLessThan(firstEnd); - - expect(loadSessionStore(pathA).a?.modelOverride).toBe("done-a"); - expect(loadSessionStore(pathB).b?.modelOverride).toBe("done-b"); - }); - - // ── 4. LOCK_QUEUES cleanup ───────────────────────────────────────── - - it("cleans up LOCK_QUEUES entry after all tasks complete", async () => { - const { storePath } = await makeTmpStore({ - x: { sessionId: "x", updatedAt: 100 }, - }); - - await updateSessionStore(storePath, async (store) => { - store.x = { ...store.x, modelOverride: "done" } as unknown as SessionEntry; - }); - - // Allow microtask (finally) to run. - await Promise.resolve(); - - expect(getSessionStoreLockQueueSizeForTest()).toBe(0); - }); - - it("cleans up LOCK_QUEUES entry even after errors", async () => { - const { storePath } = await makeTmpStore({}); - - await updateSessionStore(storePath, async () => { - throw new Error("fail"); - }).catch(() => undefined); - - await Promise.resolve(); - - expect(getSessionStoreLockQueueSizeForTest()).toBe(0); - }); - - // ── 5. FIFO order guarantee ────────────────────────────────────────── - - it("executes queued operations in FIFO order", async () => { - const key = "agent:main:fifo"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100, order: "" }, - }); - - const executionOrder: number[] = []; - - // Queue 5 operations sequentially (no awaiting in between). - const promises = Array.from({ length: 5 }, (_, i) => - updateSessionStore(storePath, async (store) => { - executionOrder.push(i); - const entry = store[key] as Record; - entry.order = ((entry.order as string) || "") + String(i); - }), - ); - - await Promise.all(promises); - - // Execution order must be 0, 1, 2, 3, 4 (FIFO). - expect(executionOrder).toEqual([0, 1, 2, 3, 4]); - - // The store should reflect sequential application. - const store = loadSessionStore(storePath); - expect((store[key] as Record).order).toBe("01234"); - }); - - it("times out queued operations strictly and does not run them later", async () => { - vi.useFakeTimers(); - try { - const { storePath } = await makeTmpStore({ - x: { sessionId: "x", updatedAt: 100 }, - }); - let timedOutRan = false; - - const releaseLock = createDeferred(); - const lockStarted = createDeferred(); - const lockHolder = withSessionStoreLockForTest( - storePath, - async () => { - lockStarted.resolve(); - await releaseLock.promise; - }, - { timeoutMs: 1_000 }, - ); - await lockStarted.promise; - const timedOut = withSessionStoreLockForTest( - storePath, - async () => { - timedOutRan = true; - }, - { timeoutMs: 5 }, - ); - - // Attach rejection handler before advancing fake timers to avoid unhandled rejections. - const timedOutExpectation = expect(timedOut).rejects.toThrow( - "timeout waiting for session store lock", - ); - await vi.advanceTimersByTimeAsync(5); - await timedOutExpectation; - releaseLock.resolve(); - await lockHolder; - await vi.runOnlyPendingTimersAsync(); - expect(timedOutRan).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("creates and removes lock file while operation runs", async () => { - const key = "agent:main:no-lock-file"; - const { dir, storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const lockPath = `${storePath}.lock`; - const allowWrite = createDeferred(); - const writeStarted = createDeferred(); - const write = updateSessionStore(storePath, async (store) => { - writeStarted.resolve(); - await allowWrite.promise; - store[key] = { ...store[key], modelOverride: "v" } as unknown as SessionEntry; - }); - - await writeStarted.promise; - await fs.access(lockPath); - allowWrite.resolve(); - await write; - - const files = await fs.readdir(dir); - const lockFiles = files.filter((f) => f.endsWith(".lock")); - expect(lockFiles).toHaveLength(0); - }); -}); diff --git a/src/config/sessions/store.undefined-path.test.ts b/src/config/sessions/store.undefined-path.test.ts deleted file mode 100644 index 8d0bc1b05be..00000000000 --- a/src/config/sessions/store.undefined-path.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Regression test for #14717: path.dirname(undefined) crash in withSessionStoreLock - * - * When a channel plugin passes undefined as storePath to recordSessionMetaFromInbound, - * the call chain reaches withSessionStoreLock → path.dirname(undefined) → TypeError crash. - * After fix, a clear Error is thrown instead of an unhandled TypeError. - */ -import { describe, expect, it } from "vitest"; -import { updateSessionStore } from "./store.js"; - -describe("withSessionStoreLock storePath guard (#14717)", () => { - it("throws descriptive error when storePath is undefined", async () => { - await expect( - updateSessionStore(undefined as unknown as string, (store) => store), - ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); - }); - - it("throws descriptive error when storePath is empty string", async () => { - await expect(updateSessionStore("", (store) => store)).rejects.toThrow( - "withSessionStoreLock: storePath must be a non-empty string", - ); - }); -}); diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts deleted file mode 100644 index 540ebd04752..00000000000 --- a/src/config/sessions/transcript.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - appendAssistantMessageToSessionTranscript, - resolveMirroredTranscriptText, -} from "./transcript.js"; - -describe("resolveMirroredTranscriptText", () => { - it("prefers media filenames over text", () => { - const result = resolveMirroredTranscriptText({ - text: "caption here", - mediaUrls: ["https://example.com/files/report.pdf?sig=123"], - }); - expect(result).toBe("report.pdf"); - }); - - it("returns trimmed text when no media", () => { - const result = resolveMirroredTranscriptText({ text: " hello " }); - expect(result).toBe("hello"); - }); -}); - -describe("appendAssistantMessageToSessionTranscript", () => { - let tempDir: string; - let storePath: string; - let sessionsDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); - sessionsDir = path.join(tempDir, "agents", "main", "sessions"); - fs.mkdirSync(sessionsDir, { recursive: true }); - storePath = path.join(sessionsDir, "sessions.json"); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it("returns error for missing sessionKey", async () => { - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "", - text: "test", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe("missing sessionKey"); - } - }); - - it("returns error for empty text", async () => { - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "test-session", - text: " ", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe("empty text"); - } - }); - - it("returns error for unknown sessionKey", async () => { - fs.writeFileSync(storePath, JSON.stringify({}), "utf-8"); - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "nonexistent", - text: "test message", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toContain("unknown sessionKey"); - } - }); - - it("creates transcript file and appends message for valid session", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(storePath, JSON.stringify(store), "utf-8"); - - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - storePath, - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(fs.existsSync(result.sessionFile)).toBe(true); - - const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); - expect(lines.length).toBe(2); // header + message - - const header = JSON.parse(lines[0]); - expect(header.type).toBe("session"); - expect(header.id).toBe(sessionId); - - const messageLine = JSON.parse(lines[1]); - expect(messageLine.type).toBe("message"); - expect(messageLine.message.role).toBe("assistant"); - expect(messageLine.message.content[0].type).toBe("text"); - expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); - } - }); -}); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index b58a8039064..851965e825a 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -91,6 +91,34 @@ export type CliBackendConfig = { imageMode?: "repeat" | "list"; /** Serialize runs for this CLI. */ serialize?: boolean; + /** Runtime reliability tuning for this backend's process lifecycle. */ + reliability?: { + /** No-output watchdog tuning (fresh vs resumed runs). */ + watchdog?: { + /** Fresh/new sessions (non-resume). */ + fresh?: { + /** Fixed watchdog timeout in ms (overrides ratio when set). */ + noOutputTimeoutMs?: number; + /** Fraction of overall timeout used when fixed timeout is not set. */ + noOutputTimeoutRatio?: number; + /** Lower bound for computed watchdog timeout. */ + minMs?: number; + /** Upper bound for computed watchdog timeout. */ + maxMs?: number; + }; + /** Resume sessions. */ + resume?: { + /** Fixed watchdog timeout in ms (overrides ratio when set). */ + noOutputTimeoutMs?: number; + /** Fraction of overall timeout used when fixed timeout is not set. */ + noOutputTimeoutRatio?: number; + /** Lower bound for computed watchdog timeout. */ + minMs?: number; + /** Upper bound for computed watchdog timeout. */ + maxMs?: number; + }; + }; + }; }; export type AgentDefaultsConfig = { diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 62a9c1da139..d1704b30b12 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -2,6 +2,8 @@ export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; + webhook?: string; + webhookToken?: string; /** * How long to retain completed cron run sessions before automatic pruning. * Accepts a duration string (e.g. "24h", "7d", "1h30m") or `false` to disable pruning. diff --git a/src/config/ui-seam-color.test.ts b/src/config/ui-seam-color.test.ts deleted file mode 100644 index 6483a0c8129..00000000000 --- a/src/config/ui-seam-color.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateConfigObject } from "./config.js"; - -describe("ui.seamColor", () => { - it("accepts hex colors", () => { - const res = validateConfigObject({ ui: { seamColor: "#FF4500" } }); - expect(res.ok).toBe(true); - }); - - it("rejects non-hex colors", () => { - const res = validateConfigObject({ ui: { seamColor: "lobster" } }); - expect(res.ok).toBe(false); - }); - - it("rejects invalid hex length", () => { - const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } }); - expect(res.ok).toBe(false); - }); -}); diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 302335a1d52..2508179707c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -1,11 +1,9 @@ import { z } from "zod"; import { HeartbeatSchema, + AgentSandboxSchema, AgentModelSchema, MemorySearchSchema, - SandboxBrowserSchema, - SandboxDockerSchema, - SandboxPruneSchema, } from "./zod-schema.agent-runtime.js"; import { BlockStreamingChunkSchema, @@ -166,20 +164,7 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), - sandbox: z - .object({ - mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), - workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), - sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), - scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), - perSession: z.boolean().optional(), - workspaceRoot: z.string().optional(), - docker: SandboxDockerSchema, - browser: SandboxBrowserSchema, - prune: SandboxPruneSchema, - }) - .strict() - .optional(), + sandbox: AgentSandboxSchema, }) .strict() .optional(); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 197f29368ca..e84bb38d787 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -125,6 +125,34 @@ export const SandboxDockerSchema = z binds: z.array(z.string()).optional(), }) .strict() + .superRefine((data, ctx) => { + if (data.network?.trim().toLowerCase() === "host") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', + }); + } + if (data.seccompProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["seccompProfile"], + message: + 'Sandbox security: seccomp profile "unconfined" is blocked. ' + + "Use a custom seccomp profile file or omit this setting.", + }); + } + if (data.apparmorProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["apparmorProfile"], + message: + 'Sandbox security: apparmor profile "unconfined" is blocked. ' + + "Use a named AppArmor profile or omit this setting.", + }); + } + }) .optional(); export const SandboxBrowserSchema = z diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 213ed9bedba..dbcbf80652f 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -275,6 +275,34 @@ export const CliBackendSchema = z imageArg: z.string().optional(), imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), serialize: z.boolean().optional(), + reliability: z + .object({ + watchdog: z + .object({ + fresh: z + .object({ + noOutputTimeoutMs: z.number().int().min(1000).optional(), + noOutputTimeoutRatio: z.number().min(0.05).max(0.95).optional(), + minMs: z.number().int().min(1000).optional(), + maxMs: z.number().int().min(1000).optional(), + }) + .strict() + .optional(), + resume: z + .object({ + noOutputTimeoutMs: z.number().int().min(1000).optional(), + noOutputTimeoutRatio: z.number().min(0.05).max(0.95).optional(), + minMs: z.number().int().min(1000).optional(), + maxMs: z.number().int().min(1000).optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 97e8ad22206..1fdb62dcf04 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -93,6 +93,14 @@ const MemorySchema = z .strict() .optional(); +const HttpUrlSchema = z + .string() + .url() + .refine((value) => { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + }, "Expected http:// or https:// URL"); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -295,6 +303,8 @@ export const OpenClawSchema = z enabled: z.boolean().optional(), store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), + webhook: HttpUrlSchema.optional(), + webhookToken: z.string().optional().register(sensitive), sessionRetention: z.union([z.string(), z.literal(false)]).optional(), }) .strict() diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts new file mode 100644 index 00000000000..6d07189765f --- /dev/null +++ b/src/cron/service.get-job.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; + +const logger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" }); +installCronTestHooks({ logger }); + +function createCronService(storePath: string) { + return new CronService({ + storePath, + cronEnabled: true, + log: logger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); +} + +describe("CronService.getJob", () => { + it("returns added jobs and undefined for missing ids", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const added = await cron.add({ + name: "lookup-test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + expect(cron.getJob(added.id)?.id).toBe(added.id); + expect(cron.getJob("missing-job-id")).toBeUndefined(); + } finally { + cron.stop(); + } + }); + + it("preserves notify on create for true, false, and omitted", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const notifyTrue = await cron.add({ + name: "notify-true", + enabled: true, + notify: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + const notifyFalse = await cron.add({ + name: "notify-false", + enabled: true, + notify: false, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + const notifyOmitted = await cron.add({ + name: "notify-omitted", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + expect(cron.getJob(notifyTrue.id)?.notify).toBe(true); + expect(cron.getJob(notifyFalse.id)?.notify).toBe(false); + expect(cron.getJob(notifyOmitted.id)?.notify).toBeUndefined(); + } finally { + cron.stop(); + } + }); +}); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index b11ca9854b1..edb95f0792a 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -100,4 +100,24 @@ describe("applyJobPatch", () => { bestEffort: undefined, }); }); + + it("updates notify via patch", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-4", + name: "job-4", + enabled: true, + notify: false, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + state: {}, + }; + + expect(() => applyJobPatch(job, { notify: true })).not.toThrow(); + expect(job.notify).toBe(true); + }); }); diff --git a/src/cron/service.ts b/src/cron/service.ts index 8f82a2e6947..8891ee9915b 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -1,4 +1,4 @@ -import type { CronJobCreate, CronJobPatch } from "./types.js"; +import type { CronJob, CronJobCreate, CronJobPatch } from "./types.js"; import * as ops from "./service/ops.js"; import { type CronServiceDeps, createCronServiceState } from "./service/state.js"; @@ -42,6 +42,10 @@ export class CronService { return await ops.run(this.state, id, mode); } + getJob(id: string): CronJob | undefined { + return this.state.store?.jobs.find((job) => job.id === id); + } + wake(opts: { mode: "now" | "next-heartbeat"; text: string }) { return ops.wakeNow(this.state, opts); } diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 71a11af7bca..fa1b6a15783 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -127,7 +127,10 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob; return { changed, skip: false }; } -export function recomputeNextRuns(state: CronServiceState): boolean { +function walkSchedulableJobs( + state: CronServiceState, + fn: (params: { job: CronJob; nowMs: number }) => boolean, +): boolean { if (!state.store) { return false; } @@ -141,6 +144,16 @@ export function recomputeNextRuns(state: CronServiceState): boolean { if (tick.skip) { continue; } + if (fn({ job, nowMs: now })) { + changed = true; + } + } + return changed; +} + +export function recomputeNextRuns(state: CronServiceState): boolean { + return walkSchedulableJobs(state, ({ job, nowMs: now }) => { + let changed = false; // Only recompute if nextRunAtMs is missing or already past-due. // Preserving a still-future nextRunAtMs avoids accidentally advancing // a job that hasn't fired yet (e.g. during restart recovery). @@ -179,8 +192,8 @@ export function recomputeNextRuns(state: CronServiceState): boolean { } } } - } - return changed; + return changed; + }); } /** @@ -191,19 +204,8 @@ export function recomputeNextRuns(state: CronServiceState): boolean { * (see #13992). */ export function recomputeNextRunsForMaintenance(state: CronServiceState): boolean { - if (!state.store) { - return false; - } - let changed = false; - const now = state.deps.nowMs(); - for (const job of state.store.jobs) { - const tick = normalizeJobTickState({ state, job, nowMs: now }); - if (tick.changed) { - changed = true; - } - if (tick.skip) { - continue; - } + return walkSchedulableJobs(state, ({ job, nowMs: now }) => { + let changed = false; // Only compute missing nextRunAtMs, do NOT recompute existing ones. // If a job was past-due but not found by findDueJobs, recomputing would // cause it to be silently skipped. @@ -214,8 +216,8 @@ export function recomputeNextRunsForMaintenance(state: CronServiceState): boolea changed = true; } } - } - return changed; + return changed; + }); } export function nextWakeAtMs(state: CronServiceState) { @@ -256,6 +258,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo name: normalizeRequiredName(input.name), description: normalizeOptionalText(input.description), enabled, + notify: typeof input.notify === "boolean" ? input.notify : undefined, deleteAfterRun, createdAtMs: now, updatedAtMs: now, @@ -284,6 +287,9 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (typeof patch.enabled === "boolean") { job.enabled = patch.enabled; } + if (typeof patch.notify === "boolean") { + job.notify = patch.notify; + } if (typeof patch.deleteAfterRun === "boolean") { job.deleteAfterRun = patch.deleteAfterRun; } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 1df1dfc95e3..2545a84d210 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -14,6 +14,19 @@ import { locked } from "./locked.js"; import { ensureLoaded, persist, warnIfDisabled } from "./store.js"; import { armTimer, emit, executeJob, runMissedJobs, stopTimer, wake } from "./timer.js"; +async function ensureLoadedForRead(state: CronServiceState) { + await ensureLoaded(state, { skipRecompute: true }); + if (!state.store) { + return; + } + // Use the maintenance-only version so that read-only operations never + // advance a past-due nextRunAtMs without executing the job (#16156). + const changed = recomputeNextRunsForMaintenance(state); + if (changed) { + await persist(state); + } +} + export async function start(state: CronServiceState) { await locked(state, async () => { if (!state.deps.cronEnabled) { @@ -54,15 +67,7 @@ export function stop(state: CronServiceState) { export async function status(state: CronServiceState) { return await locked(state, async () => { - await ensureLoaded(state, { skipRecompute: true }); - if (state.store) { - // Use the maintenance-only version so that read-only operations never - // advance a past-due nextRunAtMs without executing the job (#16156). - const changed = recomputeNextRunsForMaintenance(state); - if (changed) { - await persist(state); - } - } + await ensureLoadedForRead(state); return { enabled: state.deps.cronEnabled, storePath: state.deps.storePath, @@ -74,15 +79,7 @@ export async function status(state: CronServiceState) { export async function list(state: CronServiceState, opts?: { includeDisabled?: boolean }) { return await locked(state, async () => { - await ensureLoaded(state, { skipRecompute: true }); - if (state.store) { - // Use the maintenance-only version so that read-only operations never - // advance a past-due nextRunAtMs without executing the job (#16156). - const changed = recomputeNextRunsForMaintenance(state); - if (changed) { - await persist(state); - } - } + await ensureLoadedForRead(state); const includeDisabled = opts?.includeDisabled === true; const jobs = (state.store?.jobs ?? []).filter((j) => includeDisabled || j.enabled); return jobs.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 3c18a5e03fd..08cdfad626d 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -276,18 +276,7 @@ export async function onTimer(state: CronServiceState) { endedAt: result.endedAt, }); - emit(state, { - jobId: job.id, - action: "finished", - status: result.status, - error: result.error, - summary: result.summary, - sessionId: result.sessionId, - sessionKey: result.sessionKey, - runAtMs: result.startedAt, - durationMs: job.state.lastDurationMs, - nextRunAtMs: job.state.nextRunAtMs, - }); + emitJobFinished(state, job, result, result.startedAt); if (shouldDelete && state.store) { state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id); @@ -342,19 +331,54 @@ function findDueJobs(state: CronServiceState): CronJob[] { return []; } const now = state.deps.nowMs(); - return state.store.jobs.filter((j) => { - if (!j.state) { - j.state = {}; - } - if (!j.enabled) { - return false; - } - if (typeof j.state.runningAtMs === "number") { - return false; - } - const next = j.state.nextRunAtMs; - return typeof next === "number" && now >= next; - }); + return collectRunnableJobs(state, now); +} + +function isRunnableJob(params: { + job: CronJob; + nowMs: number; + skipJobIds?: ReadonlySet; + skipAtIfAlreadyRan?: boolean; +}): boolean { + const { job, nowMs } = params; + if (!job.state) { + job.state = {}; + } + if (!job.enabled) { + return false; + } + if (params.skipJobIds?.has(job.id)) { + return false; + } + if (typeof job.state.runningAtMs === "number") { + return false; + } + if (params.skipAtIfAlreadyRan && job.schedule.kind === "at" && job.state.lastStatus) { + // Any terminal status (ok, error, skipped) means the job already ran at least once. + // Don't re-fire it on restart — applyJobResult disables one-shot jobs, but guard + // here defensively (#13845). + return false; + } + const next = job.state.nextRunAtMs; + return typeof next === "number" && nowMs >= next; +} + +function collectRunnableJobs( + state: CronServiceState, + nowMs: number, + opts?: { skipJobIds?: ReadonlySet; skipAtIfAlreadyRan?: boolean }, +): CronJob[] { + if (!state.store) { + return []; + } + return state.store.jobs.filter((job) => + isRunnableJob({ + job, + nowMs, + skipJobIds: opts?.skipJobIds, + skipAtIfAlreadyRan: opts?.skipAtIfAlreadyRan, + }), + ); } export async function runMissedJobs( @@ -366,28 +390,7 @@ export async function runMissedJobs( } const now = state.deps.nowMs(); const skipJobIds = opts?.skipJobIds; - const missed = state.store.jobs.filter((j) => { - if (!j.state) { - j.state = {}; - } - if (!j.enabled) { - return false; - } - if (skipJobIds?.has(j.id)) { - return false; - } - if (typeof j.state.runningAtMs === "number") { - return false; - } - const next = j.state.nextRunAtMs; - if (j.schedule.kind === "at" && j.state.lastStatus) { - // Any terminal status (ok, error, skipped) means the job already - // ran at least once. Don't re-fire it on restart — applyJobResult - // disables one-shot jobs, but guard here defensively (#13845). - return false; - } - return typeof next === "number" && now >= next; - }); + const missed = collectRunnableJobs(state, now, { skipJobIds, skipAtIfAlreadyRan: true }); if (missed.length > 0) { state.deps.log.info( @@ -405,19 +408,7 @@ export async function runDueJobs(state: CronServiceState) { return; } const now = state.deps.nowMs(); - const due = state.store.jobs.filter((j) => { - if (!j.state) { - j.state = {}; - } - if (!j.enabled) { - return false; - } - if (typeof j.state.runningAtMs === "number") { - return false; - } - const next = j.state.nextRunAtMs; - return typeof next === "number" && now >= next; - }); + const due = collectRunnableJobs(state, now); for (const job of due) { await executeJob(state, job, now, { forced: false }); } @@ -563,18 +554,7 @@ export async function executeJob( endedAt, }); - emit(state, { - jobId: job.id, - action: "finished", - status: coreResult.status, - error: coreResult.error, - summary: coreResult.summary, - sessionId: coreResult.sessionId, - sessionKey: coreResult.sessionKey, - runAtMs: startedAt, - durationMs: job.state.lastDurationMs, - nextRunAtMs: job.state.nextRunAtMs, - }); + emitJobFinished(state, job, coreResult, startedAt); if (shouldDelete && state.store) { state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id); @@ -582,6 +562,32 @@ export async function executeJob( } } +function emitJobFinished( + state: CronServiceState, + job: CronJob, + result: { + status: "ok" | "error" | "skipped"; + error?: string; + summary?: string; + sessionId?: string; + sessionKey?: string; + }, + runAtMs: number, +) { + emit(state, { + jobId: job.id, + action: "finished", + status: result.status, + error: result.error, + summary: result.summary, + sessionId: result.sessionId, + sessionKey: result.sessionKey, + runAtMs, + durationMs: job.state.lastDurationMs, + nextRunAtMs: job.state.nextRunAtMs, + }); +} + export function wake( state: CronServiceState, opts: { mode: "now" | "next-heartbeat"; text: string }, diff --git a/src/cron/types.ts b/src/cron/types.ts index c3168346fb4..22363851357 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -71,6 +71,7 @@ export type CronJob = { name: string; description?: string; enabled: boolean; + notify?: boolean; deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index c215ae1a88d..4350ae76cac 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -6,6 +6,7 @@ import { GATEWAY_WINDOWS_TASK_NAME, resolveGatewayLaunchAgentLabel, resolveGatewayProfileSuffix, + resolveGatewayServiceDescription, resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, } from "./constants.js"; @@ -196,3 +197,23 @@ describe("formatGatewayServiceDescription", () => { ); }); }); + +describe("resolveGatewayServiceDescription", () => { + it("prefers explicit description override", () => { + expect( + resolveGatewayServiceDescription({ + env: { OPENCLAW_PROFILE: "work", OPENCLAW_SERVICE_VERSION: "1.0.0" }, + description: "Custom", + }), + ).toBe("Custom"); + }); + + it("resolves version from explicit environment map", () => { + expect( + resolveGatewayServiceDescription({ + env: { OPENCLAW_PROFILE: "work", OPENCLAW_SERVICE_VERSION: "local" }, + environment: { OPENCLAW_SERVICE_VERSION: "remote" }, + }), + ).toBe("OpenClaw Gateway (profile: work, vremote)"); + }); +}); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 212eb93a2a9..3ee523b1535 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -75,6 +75,20 @@ export function formatGatewayServiceDescription(params?: { return `OpenClaw Gateway (${parts.join(", ")})`; } +export function resolveGatewayServiceDescription(params: { + env: Record; + environment?: Record; + description?: string; +}): string { + return ( + params.description ?? + formatGatewayServiceDescription({ + profile: params.env.OPENCLAW_PROFILE, + version: params.environment?.OPENCLAW_SERVICE_VERSION ?? params.env.OPENCLAW_SERVICE_VERSION, + }) + ); +} + export function resolveNodeLaunchAgentLabel(): string { return NODE_LAUNCH_AGENT_LABEL; } diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 3d33af682dc..795fe828096 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import { - formatGatewayServiceDescription, GATEWAY_LAUNCH_AGENT_LABEL, + resolveGatewayServiceDescription, resolveGatewayLaunchAgentLabel, resolveLegacyGatewayLaunchAgentLabels, } from "./constants.js"; @@ -384,12 +384,7 @@ export async function installLaunchAgent({ const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); await fs.mkdir(path.dirname(plistPath), { recursive: true }); - const serviceDescription = - description ?? - formatGatewayServiceDescription({ - profile: env.OPENCLAW_PROFILE, - version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, - }); + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const plist = buildLaunchAgentPlist({ label, comment: serviceDescription, diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index d29d470ffe8..a70f100793a 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; -import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; +import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; import { formatLine } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; @@ -190,12 +190,7 @@ export async function installScheduledTask({ await assertSchtasksAvailable(); const scriptPath = resolveTaskScriptPath(env); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - const taskDescription = - description ?? - formatGatewayServiceDescription({ - profile: env.OPENCLAW_PROFILE, - version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, - }); + const taskDescription = resolveGatewayServiceDescription({ env, environment, description }); const script = buildTaskScript({ description: taskDescription, programArguments, diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 6ef8af5765f..41107d4a2ee 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import { - formatGatewayServiceDescription, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, + resolveGatewayServiceDescription, resolveGatewaySystemdServiceName, } from "./constants.js"; import { execFileUtf8 } from "./exec-file.js"; @@ -200,12 +200,7 @@ export async function installSystemdService({ const unitPath = resolveSystemdUnitPath(env); await fs.mkdir(path.dirname(unitPath), { recursive: true }); - const serviceDescription = - description ?? - formatGatewayServiceDescription({ - profile: env.OPENCLAW_PROFILE, - version: environment?.OPENCLAW_SERVICE_VERSION ?? env.OPENCLAW_SERVICE_VERSION, - }); + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const unit = buildSystemdUnit({ description: serviceDescription, programArguments, diff --git a/src/discord/monitor/agent-components.test.ts b/src/discord/monitor/agent-components.test.ts deleted file mode 100644 index ea19695dc63..00000000000 --- a/src/discord/monitor/agent-components.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js"; - -const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); -const upsertPairingRequestMock = vi.hoisted(() => vi.fn()); -const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../../infra/system-events.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), - }; -}); - -const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; - -const createDmButtonInteraction = (overrides: Partial = {}) => { - const reply = vi.fn().mockResolvedValue(undefined); - const defer = vi.fn().mockResolvedValue(undefined); - const interaction = { - rawData: { channel_id: "dm-channel" }, - user: { id: "123456789", username: "Alice", discriminator: "1234" }, - defer, - reply, - ...overrides, - } as unknown as ButtonInteraction; - return { interaction, defer, reply }; -}; - -const createDmSelectInteraction = (overrides: Partial = {}) => { - const reply = vi.fn().mockResolvedValue(undefined); - const defer = vi.fn().mockResolvedValue(undefined); - const interaction = { - rawData: { channel_id: "dm-channel" }, - user: { id: "123456789", username: "Alice", discriminator: "1234" }, - values: ["alpha"], - defer, - reply, - ...overrides, - } as unknown as StringSelectMenuInteraction; - return { interaction, defer, reply }; -}; - -beforeEach(() => { - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); - enqueueSystemEventMock.mockReset(); -}); - -describe("agent components", () => { - it("sends pairing reply when DM sender is not allowlisted", async () => { - const button = createAgentComponentButton({ - cfg: createCfg(), - accountId: "default", - dmPolicy: "pairing", - }); - const { interaction, defer, reply } = createDmButtonInteraction(); - - await button.run(interaction, { componentId: "hello" } as ComponentData); - - expect(defer).toHaveBeenCalledWith({ ephemeral: true }); - expect(reply).toHaveBeenCalledTimes(1); - expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); - expect(enqueueSystemEventMock).not.toHaveBeenCalled(); - }); - - it("allows DM interactions when pairing store allowlist matches", async () => { - readAllowFromStoreMock.mockResolvedValue(["123456789"]); - const button = createAgentComponentButton({ - cfg: createCfg(), - accountId: "default", - dmPolicy: "allowlist", - }); - const { interaction, defer, reply } = createDmButtonInteraction(); - - await button.run(interaction, { componentId: "hello" } as ComponentData); - - expect(defer).toHaveBeenCalledWith({ ephemeral: true }); - expect(reply).toHaveBeenCalledWith({ content: "✓" }); - expect(enqueueSystemEventMock).toHaveBeenCalled(); - }); - - it("matches tag-based allowlist entries for DM select menus", async () => { - const select = createAgentSelectMenu({ - cfg: createCfg(), - accountId: "default", - dmPolicy: "allowlist", - allowFrom: ["Alice#1234"], - }); - const { interaction, defer, reply } = createDmSelectInteraction(); - - await select.run(interaction, { componentId: "hello" } as ComponentData); - - expect(defer).toHaveBeenCalledWith({ ephemeral: true }); - expect(reply).toHaveBeenCalledWith({ content: "✓" }); - expect(enqueueSystemEventMock).toHaveBeenCalled(); - }); -}); diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 2d52653750d..ebc67f7ffce 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -24,7 +24,7 @@ import { resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, - resolveDiscordMemberAllowed, + resolveDiscordMemberAccessState, } from "./allow-list.js"; import { formatDiscordUserTag } from "./format.js"; @@ -217,15 +217,15 @@ async function ensureGuildComponentMemberAllowed(params: { scope: channelCtx.isThread ? "thread" : "channel", }); - const channelUsers = channelConfig?.users ?? guildInfo?.users; - const channelRoles = channelConfig?.roles ?? guildInfo?.roles; - const memberAllowed = resolveDiscordMemberAllowed({ - userAllowList: channelUsers, - roleAllowList: channelRoles, + const { memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, memberRoleIds, - userId: user.id, - userName: user.username, - userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, + sender: { + id: user.id, + name: user.username, + tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined, + }, }); if (memberAllowed) { return true; diff --git a/src/discord/monitor/allow-list.test.ts b/src/discord/monitor/allow-list.test.ts deleted file mode 100644 index c620bd71af1..00000000000 --- a/src/discord/monitor/allow-list.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DiscordChannelConfigResolved } from "./allow-list.js"; -import { - resolveDiscordMemberAllowed, - resolveDiscordOwnerAllowFrom, - resolveDiscordRoleAllowed, -} from "./allow-list.js"; - -describe("resolveDiscordOwnerAllowFrom", () => { - it("returns undefined when no allowlist is configured", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true } as DiscordChannelConfigResolved, - sender: { id: "123" }, - }); - - expect(result).toBeUndefined(); - }); - - it("skips wildcard matches for owner allowFrom", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true, users: ["*"] } as DiscordChannelConfigResolved, - sender: { id: "123" }, - }); - - expect(result).toBeUndefined(); - }); - - it("returns a matching user id entry", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true, users: ["123"] } as DiscordChannelConfigResolved, - sender: { id: "123" }, - }); - - expect(result).toEqual(["123"]); - }); - - it("returns the normalized name slug for name matches", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, - sender: { id: "999", name: "Some User" }, - }); - - expect(result).toEqual(["some-user"]); - }); -}); - -describe("resolveDiscordRoleAllowed", () => { - it("allows when no role allowlist is configured", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: undefined, - memberRoleIds: ["role-1"], - }); - - expect(allowed).toBe(true); - }); - - it("matches role IDs only", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: ["123"], - memberRoleIds: ["123", "456"], - }); - - expect(allowed).toBe(true); - }); - - it("does not match non-ID role entries", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: ["Admin"], - memberRoleIds: ["Admin"], - }); - - expect(allowed).toBe(false); - }); - - it("returns false when no matching role IDs", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: ["456"], - memberRoleIds: ["123"], - }); - - expect(allowed).toBe(false); - }); -}); - -describe("resolveDiscordMemberAllowed", () => { - it("allows when no user or role allowlists are configured", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: undefined, - roleAllowList: undefined, - memberRoleIds: [], - userId: "u1", - }); - - expect(allowed).toBe(true); - }); - - it("allows when user allowlist matches", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: ["123"], - roleAllowList: ["456"], - memberRoleIds: ["999"], - userId: "123", - }); - - expect(allowed).toBe(true); - }); - - it("allows when role allowlist matches", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: ["999"], - roleAllowList: ["456"], - memberRoleIds: ["456"], - userId: "123", - }); - - expect(allowed).toBe(true); - }); - - it("denies when user and role allowlists do not match", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: ["u2"], - roleAllowList: ["role-2"], - memberRoleIds: ["role-1"], - userId: "u1", - }); - - expect(allowed).toBe(false); - }); -}); diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 452be0e4d5d..b81b0af9994 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -202,6 +202,28 @@ export function resolveDiscordMemberAllowed(params: { return userOk || roleOk; } +export function resolveDiscordMemberAccessState(params: { + channelConfig?: DiscordChannelConfigResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; + memberRoleIds: string[]; + sender: { id: string; name?: string; tag?: string }; +}) { + const channelUsers = params.channelConfig?.users ?? params.guildInfo?.users; + const channelRoles = params.channelConfig?.roles ?? params.guildInfo?.roles; + const hasAccessRestrictions = + (Array.isArray(channelUsers) && channelUsers.length > 0) || + (Array.isArray(channelRoles) && channelRoles.length > 0); + const memberAllowed = resolveDiscordMemberAllowed({ + userAllowList: channelUsers, + roleAllowList: channelRoles, + memberRoleIds: params.memberRoleIds, + userId: params.sender.id, + userName: params.sender.name, + userTag: params.sender.tag, + }); + return { channelUsers, channelRoles, hasAccessRestrictions, memberAllowed } as const; +} + export function resolveDiscordOwnerAllowFrom(params: { channelConfig?: DiscordChannelConfigResolved | null; guildInfo?: DiscordGuildEntryResolved | null; diff --git a/src/discord/monitor/gateway-registry.test.ts b/src/discord/monitor/gateway-registry.test.ts deleted file mode 100644 index 8e0c66a87e3..00000000000 --- a/src/discord/monitor/gateway-registry.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { GatewayPlugin } from "@buape/carbon/gateway"; -import { beforeEach, describe, expect, it } from "vitest"; -import { - clearGateways, - getGateway, - registerGateway, - unregisterGateway, -} from "./gateway-registry.js"; - -function fakeGateway(props: Partial = {}): GatewayPlugin { - return { isConnected: true, ...props } as unknown as GatewayPlugin; -} - -describe("gateway-registry", () => { - beforeEach(() => { - clearGateways(); - }); - - it("stores and retrieves a gateway by account", () => { - const gateway = fakeGateway(); - registerGateway("account-a", gateway); - expect(getGateway("account-a")).toBe(gateway); - expect(getGateway("account-b")).toBeUndefined(); - }); - - it("uses collision-safe key when accountId is undefined", () => { - const gateway = fakeGateway(); - registerGateway(undefined, gateway); - expect(getGateway(undefined)).toBe(gateway); - // "default" as a literal account ID must not collide with the sentinel key - expect(getGateway("default")).toBeUndefined(); - }); - - it("unregisters a gateway", () => { - const gateway = fakeGateway(); - registerGateway("account-a", gateway); - unregisterGateway("account-a"); - expect(getGateway("account-a")).toBeUndefined(); - }); - - it("clears all gateways", () => { - registerGateway("a", fakeGateway()); - registerGateway("b", fakeGateway()); - clearGateways(); - expect(getGateway("a")).toBeUndefined(); - expect(getGateway("b")).toBeUndefined(); - }); - - it("overwrites existing entry for same account", () => { - const gateway1 = fakeGateway({ isConnected: true }); - const gateway2 = fakeGateway({ isConnected: false }); - registerGateway("account-a", gateway1); - registerGateway("account-a", gateway2); - expect(getGateway("account-a")).toBe(gateway2); - }); -}); diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 946eceb6a23..09b3559b835 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -39,7 +39,7 @@ import { resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, - resolveDiscordMemberAllowed, + resolveDiscordMemberAccessState, resolveDiscordShouldRequireMention, resolveGroupDmAllow, } from "./allow-list.js"; @@ -476,18 +476,11 @@ export async function preflightDiscordMessage( surface: "discord", }); const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg); - const channelUsers = channelConfig?.users ?? guildInfo?.users; - const channelRoles = channelConfig?.roles ?? guildInfo?.roles; - const hasAccessRestrictions = - (Array.isArray(channelUsers) && channelUsers.length > 0) || - (Array.isArray(channelRoles) && channelRoles.length > 0); - const memberAllowed = resolveDiscordMemberAllowed({ - userAllowList: channelUsers, - roleAllowList: channelRoles, + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, memberRoleIds, - userId: sender.id, - userName: sender.name, - userTag: sender.tag, + sender, }); if (!isDirectMessage) { diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts new file mode 100644 index 00000000000..ec9e2fa4bbb --- /dev/null +++ b/src/discord/monitor/monitor.test.ts @@ -0,0 +1,614 @@ +import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon"; +import type { Client } from "@buape/carbon"; +import type { GatewayPresenceUpdate } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { DiscordChannelConfigResolved } from "./allow-list.js"; +import { buildAgentSessionKey } from "../../routing/resolve-route.js"; +import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js"; +import { + resolveDiscordMemberAllowed, + resolveDiscordOwnerAllowFrom, + resolveDiscordRoleAllowed, +} from "./allow-list.js"; +import { + clearGateways, + getGateway, + registerGateway, + unregisterGateway, +} from "./gateway-registry.js"; +import { clearPresences, getPresence, presenceCacheSize, setPresence } from "./presence-cache.js"; +import { resolveDiscordPresenceUpdate } from "./presence.js"; +import { + maybeCreateDiscordAutoThread, + resolveDiscordAutoThreadContext, + resolveDiscordAutoThreadReplyPlan, + resolveDiscordReplyDeliveryPlan, +} from "./threading.js"; + +const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); +const upsertPairingRequestMock = vi.hoisted(() => vi.fn()); +const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), +})); + +vi.mock("../../infra/system-events.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + }; +}); + +describe("agent components", () => { + const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; + + const createDmButtonInteraction = (overrides: Partial = {}) => { + const reply = vi.fn().mockResolvedValue(undefined); + const defer = vi.fn().mockResolvedValue(undefined); + const interaction = { + rawData: { channel_id: "dm-channel" }, + user: { id: "123456789", username: "Alice", discriminator: "1234" }, + defer, + reply, + ...overrides, + } as unknown as ButtonInteraction; + return { interaction, defer, reply }; + }; + + const createDmSelectInteraction = (overrides: Partial = {}) => { + const reply = vi.fn().mockResolvedValue(undefined); + const defer = vi.fn().mockResolvedValue(undefined); + const interaction = { + rawData: { channel_id: "dm-channel" }, + user: { id: "123456789", username: "Alice", discriminator: "1234" }, + values: ["alpha"], + defer, + reply, + ...overrides, + } as unknown as StringSelectMenuInteraction; + return { interaction, defer, reply }; + }; + + beforeEach(() => { + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + enqueueSystemEventMock.mockReset(); + }); + + it("sends pairing reply when DM sender is not allowlisted", async () => { + const button = createAgentComponentButton({ + cfg: createCfg(), + accountId: "default", + dmPolicy: "pairing", + }); + const { interaction, defer, reply } = createDmButtonInteraction(); + + await button.run(interaction, { componentId: "hello" } as ComponentData); + + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledTimes(1); + expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("allows DM interactions when pairing store allowlist matches", async () => { + readAllowFromStoreMock.mockResolvedValue(["123456789"]); + const button = createAgentComponentButton({ + cfg: createCfg(), + accountId: "default", + dmPolicy: "allowlist", + }); + const { interaction, defer, reply } = createDmButtonInteraction(); + + await button.run(interaction, { componentId: "hello" } as ComponentData); + + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); + expect(enqueueSystemEventMock).toHaveBeenCalled(); + }); + + it("matches tag-based allowlist entries for DM select menus", async () => { + const select = createAgentSelectMenu({ + cfg: createCfg(), + accountId: "default", + dmPolicy: "allowlist", + allowFrom: ["Alice#1234"], + }); + const { interaction, defer, reply } = createDmSelectInteraction(); + + await select.run(interaction, { componentId: "hello" } as ComponentData); + + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); + expect(enqueueSystemEventMock).toHaveBeenCalled(); + }); +}); + +describe("resolveDiscordOwnerAllowFrom", () => { + it("returns undefined when no allowlist is configured", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true } as DiscordChannelConfigResolved, + sender: { id: "123" }, + }); + + expect(result).toBeUndefined(); + }); + + it("skips wildcard matches for owner allowFrom", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true, users: ["*"] } as DiscordChannelConfigResolved, + sender: { id: "123" }, + }); + + expect(result).toBeUndefined(); + }); + + it("returns a matching user id entry", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true, users: ["123"] } as DiscordChannelConfigResolved, + sender: { id: "123" }, + }); + + expect(result).toEqual(["123"]); + }); + + it("returns the normalized name slug for name matches", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, + sender: { id: "999", name: "Some User" }, + }); + + expect(result).toEqual(["some-user"]); + }); +}); + +describe("resolveDiscordRoleAllowed", () => { + it("allows when no role allowlist is configured", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: undefined, + memberRoleIds: ["role-1"], + }); + + expect(allowed).toBe(true); + }); + + it("matches role IDs only", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: ["123"], + memberRoleIds: ["123", "456"], + }); + + expect(allowed).toBe(true); + }); + + it("does not match non-ID role entries", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: ["Admin"], + memberRoleIds: ["Admin"], + }); + + expect(allowed).toBe(false); + }); + + it("returns false when no matching role IDs", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: ["456"], + memberRoleIds: ["123"], + }); + + expect(allowed).toBe(false); + }); +}); + +describe("resolveDiscordMemberAllowed", () => { + it("allows when no user or role allowlists are configured", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: undefined, + roleAllowList: undefined, + memberRoleIds: [], + userId: "u1", + }); + + expect(allowed).toBe(true); + }); + + it("allows when user allowlist matches", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: ["123"], + roleAllowList: ["456"], + memberRoleIds: ["999"], + userId: "123", + }); + + expect(allowed).toBe(true); + }); + + it("allows when role allowlist matches", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: ["999"], + roleAllowList: ["456"], + memberRoleIds: ["456"], + userId: "123", + }); + + expect(allowed).toBe(true); + }); + + it("denies when user and role allowlists do not match", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: ["u2"], + roleAllowList: ["role-2"], + memberRoleIds: ["role-1"], + userId: "u1", + }); + + expect(allowed).toBe(false); + }); +}); + +describe("gateway-registry", () => { + type GatewayPlugin = { isConnected: boolean }; + + function fakeGateway(props: Partial = {}): GatewayPlugin { + return { isConnected: true, ...props }; + } + + beforeEach(() => { + clearGateways(); + }); + + it("stores and retrieves a gateway by account", () => { + const gateway = fakeGateway(); + registerGateway("account-a", gateway as never); + expect(getGateway("account-a")).toBe(gateway); + expect(getGateway("account-b")).toBeUndefined(); + }); + + it("uses collision-safe key when accountId is undefined", () => { + const gateway = fakeGateway(); + registerGateway(undefined, gateway as never); + expect(getGateway(undefined)).toBe(gateway); + expect(getGateway("default")).toBeUndefined(); + }); + + it("unregisters a gateway", () => { + const gateway = fakeGateway(); + registerGateway("account-a", gateway as never); + unregisterGateway("account-a"); + expect(getGateway("account-a")).toBeUndefined(); + }); + + it("clears all gateways", () => { + registerGateway("a", fakeGateway() as never); + registerGateway("b", fakeGateway() as never); + clearGateways(); + expect(getGateway("a")).toBeUndefined(); + expect(getGateway("b")).toBeUndefined(); + }); + + it("overwrites existing entry for same account", () => { + const gateway1 = fakeGateway({ isConnected: true }); + const gateway2 = fakeGateway({ isConnected: false }); + registerGateway("account-a", gateway1 as never); + registerGateway("account-a", gateway2 as never); + expect(getGateway("account-a")).toBe(gateway2); + }); +}); + +describe("presence-cache", () => { + beforeEach(() => { + clearPresences(); + }); + + it("scopes presence entries by account", () => { + const presenceA = { status: "online" } as GatewayPresenceUpdate; + const presenceB = { status: "idle" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presenceA); + setPresence("account-b", "user-1", presenceB); + + expect(getPresence("account-a", "user-1")).toBe(presenceA); + expect(getPresence("account-b", "user-1")).toBe(presenceB); + expect(getPresence("account-a", "user-2")).toBeUndefined(); + }); + + it("clears presence per account", () => { + const presence = { status: "dnd" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presence); + setPresence("account-b", "user-2", presence); + + clearPresences("account-a"); + + expect(getPresence("account-a", "user-1")).toBeUndefined(); + expect(getPresence("account-b", "user-2")).toBe(presence); + expect(presenceCacheSize()).toBe(1); + }); +}); + +describe("resolveDiscordPresenceUpdate", () => { + it("returns null when no presence config provided", () => { + expect(resolveDiscordPresenceUpdate({})).toBeNull(); + }); + + 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([]); + }); + + 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", + }); + }); + + it("includes streaming url when activityType is streaming", () => { + const presence = resolveDiscordPresenceUpdate({ + activity: "Live", + 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", + }); + }); +}); + +describe("resolveDiscordAutoThreadContext", () => { + it("returns null when no createdThreadId", () => { + expect( + resolveDiscordAutoThreadContext({ + agentId: "agent", + channel: "discord", + messageChannelId: "parent", + createdThreadId: undefined, + }), + ).toBeNull(); + }); + + it("re-keys session context to the created thread", () => { + const context = resolveDiscordAutoThreadContext({ + agentId: "agent", + channel: "discord", + messageChannelId: "parent", + createdThreadId: "thread", + }); + expect(context).not.toBeNull(); + expect(context?.To).toBe("channel:thread"); + expect(context?.From).toBe("discord:channel:thread"); + expect(context?.OriginatingTo).toBe("channel:thread"); + expect(context?.SessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + expect(context?.ParentSessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), + ); + }); +}); + +describe("resolveDiscordReplyDeliveryPlan", () => { + it("uses reply references when posting to the original target", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:parent", + replyToMode: "all", + messageId: "m1", + threadChannel: null, + createdThreadId: null, + }); + expect(plan.deliverTarget).toBe("channel:parent"); + expect(plan.replyTarget).toBe("channel:parent"); + expect(plan.replyReference.use()).toBe("m1"); + }); + + it("disables reply references when autoThread creates a new thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:parent", + replyToMode: "all", + messageId: "m1", + threadChannel: null, + createdThreadId: "thread", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBeUndefined(); + }); + + it("respects replyToMode off even inside a thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "off", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBeUndefined(); + }); + + it("uses existingId when inside a thread with replyToMode all", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "all", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.replyReference.use()).toBe("m1"); + }); + + it("uses existingId only on first call with replyToMode first inside a thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "first", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.replyReference.use()).toBeUndefined(); + }); +}); + +describe("maybeCreateDiscordAutoThread", () => { + it("returns existing thread ID when creation fails due to race condition", async () => { + const client = { + rest: { + post: async () => { + throw new Error("A thread has already been created on this message"); + }, + get: async () => ({ thread: { id: "existing-thread" } }), + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBe("existing-thread"); + }); + + it("returns undefined when creation fails and no existing thread found", async () => { + const client = { + rest: { + post: async () => { + throw new Error("Some other error"); + }, + get: async () => ({ thread: null }), + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBeUndefined(); + }); +}); + +describe("resolveDiscordAutoThreadReplyPlan", () => { + it("switches delivery + session context to the created thread", async () => { + const client = { + rest: { post: async () => ({ id: "thread" }) }, + } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBeUndefined(); + expect(plan.autoThreadContext?.SessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + }); + + it("routes replies to an existing thread channel", async () => { + const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: { id: "thread" }, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.autoThreadContext).toBeNull(); + }); + + it("does nothing when autoThread is disabled", async () => { + const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: false, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:parent"); + expect(plan.autoThreadContext).toBeNull(); + }); +}); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 62e2fb03c89..65d1ab1468c 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -42,6 +42,7 @@ import { } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { chunkItems } from "../../utils/chunk-items.js"; import { loadWebMedia } from "../../web/media.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { @@ -51,7 +52,7 @@ import { normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, - resolveDiscordMemberAllowed, + resolveDiscordMemberAccessState, resolveDiscordOwnerAllowFrom, } from "./allow-list.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; @@ -146,17 +147,6 @@ function readDiscordCommandArgs( return Object.keys(values).length > 0 ? { values } : undefined; } -function chunkItems(items: T[], size: number): T[][] { - if (size <= 0) { - return [items]; - } - const rows: T[][] = []; - for (let i = 0; i < items.length; i += size) { - rows.push(items.slice(i, i + size)); - } - return rows; -} - const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg"; function createCommandArgsWithValue(params: { argName: string; value: string }): CommandArgs { @@ -667,18 +657,11 @@ async function dispatchDiscordCommandInteraction(params: { } } if (!isDirectMessage) { - const channelUsers = channelConfig?.users ?? guildInfo?.users; - const channelRoles = channelConfig?.roles ?? guildInfo?.roles; - const hasAccessRestrictions = - (Array.isArray(channelUsers) && channelUsers.length > 0) || - (Array.isArray(channelRoles) && channelRoles.length > 0); - const memberAllowed = resolveDiscordMemberAllowed({ - userAllowList: channelUsers, - roleAllowList: channelRoles, + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, memberRoleIds, - userId: sender.id, - userName: sender.name, - userTag: sender.tag, + sender, }); const authorizers = useAccessGroups ? [ diff --git a/src/discord/monitor/presence-cache.test.ts b/src/discord/monitor/presence-cache.test.ts deleted file mode 100644 index e7dd04d0806..00000000000 --- a/src/discord/monitor/presence-cache.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GatewayPresenceUpdate } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it } from "vitest"; -import { clearPresences, getPresence, presenceCacheSize, setPresence } from "./presence-cache.js"; - -describe("presence-cache", () => { - beforeEach(() => { - clearPresences(); - }); - - it("scopes presence entries by account", () => { - const presenceA = { status: "online" } as GatewayPresenceUpdate; - const presenceB = { status: "idle" } as GatewayPresenceUpdate; - - setPresence("account-a", "user-1", presenceA); - setPresence("account-b", "user-1", presenceB); - - expect(getPresence("account-a", "user-1")).toBe(presenceA); - expect(getPresence("account-b", "user-1")).toBe(presenceB); - expect(getPresence("account-a", "user-2")).toBeUndefined(); - }); - - it("clears presence per account", () => { - const presence = { status: "dnd" } as GatewayPresenceUpdate; - - setPresence("account-a", "user-1", presence); - setPresence("account-b", "user-2", presence); - - clearPresences("account-a"); - - expect(getPresence("account-a", "user-1")).toBeUndefined(); - expect(getPresence("account-b", "user-2")).toBe(presence); - expect(presenceCacheSize()).toBe(1); - }); -}); diff --git a/src/discord/monitor/presence.test.ts b/src/discord/monitor/presence.test.ts deleted file mode 100644 index 83fd15efaf6..00000000000 --- a/src/discord/monitor/presence.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveDiscordPresenceUpdate } from "./presence.js"; - -describe("resolveDiscordPresenceUpdate", () => { - it("returns null when no presence config provided", () => { - expect(resolveDiscordPresenceUpdate({})).toBeNull(); - }); - - 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([]); - }); - - 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", - }); - }); - - it("includes streaming url when activityType is streaming", () => { - const presence = resolveDiscordPresenceUpdate({ - activity: "Live", - 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", - }); - }); -}); diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts deleted file mode 100644 index 587aca8bb16..00000000000 --- a/src/discord/monitor/threading.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { Client } from "@buape/carbon"; -import { describe, expect, it } from "vitest"; -import { buildAgentSessionKey } from "../../routing/resolve-route.js"; -import { - maybeCreateDiscordAutoThread, - resolveDiscordAutoThreadContext, - resolveDiscordAutoThreadReplyPlan, - resolveDiscordReplyDeliveryPlan, -} from "./threading.js"; - -describe("resolveDiscordAutoThreadContext", () => { - it("returns null when no createdThreadId", () => { - expect( - resolveDiscordAutoThreadContext({ - agentId: "agent", - channel: "discord", - messageChannelId: "parent", - createdThreadId: undefined, - }), - ).toBeNull(); - }); - - it("re-keys session context to the created thread", () => { - const context = resolveDiscordAutoThreadContext({ - agentId: "agent", - channel: "discord", - messageChannelId: "parent", - createdThreadId: "thread", - }); - expect(context).not.toBeNull(); - expect(context?.To).toBe("channel:thread"); - expect(context?.From).toBe("discord:channel:thread"); - expect(context?.OriginatingTo).toBe("channel:thread"); - expect(context?.SessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "thread" }, - }), - ); - expect(context?.ParentSessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "parent" }, - }), - ); - }); -}); - -describe("resolveDiscordReplyDeliveryPlan", () => { - it("uses reply references when posting to the original target", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:parent", - replyToMode: "all", - messageId: "m1", - threadChannel: null, - createdThreadId: null, - }); - expect(plan.deliverTarget).toBe("channel:parent"); - expect(plan.replyTarget).toBe("channel:parent"); - expect(plan.replyReference.use()).toBe("m1"); - }); - - it("disables reply references when autoThread creates a new thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:parent", - replyToMode: "all", - messageId: "m1", - threadChannel: null, - createdThreadId: "thread", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBeUndefined(); - }); - - it("respects replyToMode off even inside a thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "off", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - expect(plan.replyReference.use()).toBeUndefined(); - }); - - it("uses existingId when inside a thread with replyToMode all", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "all", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - // "all" returns the reference on every call. - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.replyReference.use()).toBe("m1"); - }); - - it("uses existingId only on first call with replyToMode first inside a thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "first", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - // "first" returns the reference only once. - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.replyReference.use()).toBeUndefined(); - }); -}); - -describe("maybeCreateDiscordAutoThread", () => { - it("returns existing thread ID when creation fails due to race condition", async () => { - // First call succeeds (simulating another agent creating the thread) - const client = { - rest: { - post: async () => { - throw new Error("A thread has already been created on this message"); - }, - get: async () => { - // Return message with existing thread (simulating race condition resolution) - return { thread: { id: "existing-thread" } }; - }, - }, - } as unknown as Client; - - const result = await maybeCreateDiscordAutoThread({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - }); - - expect(result).toBe("existing-thread"); - }); - - it("returns undefined when creation fails and no existing thread found", async () => { - const client = { - rest: { - post: async () => { - throw new Error("Some other error"); - }, - get: async () => { - // Message has no thread - return { thread: null }; - }, - }, - } as unknown as Client; - - const result = await maybeCreateDiscordAutoThread({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - }); - - expect(result).toBeUndefined(); - }); -}); - -describe("resolveDiscordAutoThreadReplyPlan", () => { - it("switches delivery + session context to the created thread", async () => { - const client = { - rest: { post: async () => ({ id: "thread" }) }, - } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBeUndefined(); - expect(plan.autoThreadContext?.SessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "thread" }, - }), - ); - }); - - it("routes replies to an existing thread channel", async () => { - const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: { id: "thread" }, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.autoThreadContext).toBeNull(); - }); - - it("does nothing when autoThread is disabled", async () => { - const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: false, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); - expect(plan.deliverTarget).toBe("channel:parent"); - expect(plan.autoThreadContext).toBeNull(); - }); -}); diff --git a/src/discord/send.voice-message.security.test.ts b/src/discord/send.voice-message.security.test.ts deleted file mode 100644 index 9651f57c5ae..00000000000 --- a/src/discord/send.voice-message.security.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { sendVoiceMessageDiscord } from "./send.js"; - -describe("sendVoiceMessageDiscord - media hardening", () => { - it("rejects local paths outside allowed media roots (prevents local file exfiltration)", async () => { - const candidate = path.join(process.cwd(), "package.json"); - await expect(sendVoiceMessageDiscord("channel:123", candidate)).rejects.toThrow( - /Local media path is not under an allowed directory/, - ); - }); - - it("blocks SSRF targets when given a private-network URL", async () => { - await expect( - sendVoiceMessageDiscord("channel:123", "http://127.0.0.1/voice.ogg"), - ).rejects.toThrow(/Failed to fetch media|Blocked/); - }); - - it("does not allow non-http URL schemes to reach ffmpeg/ffprobe", async () => { - await expect( - sendVoiceMessageDiscord("channel:123", "rtsp://example.com/voice.ogg"), - ).rejects.toThrow(/Local media path is not under an allowed directory|ENOENT|no such file/i); - }); -}); diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 9e6ead765b9..4c8790319f2 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -8,11 +8,23 @@ const agentCommand = vi.fn(); vi.mock("../commands/agent.js", () => ({ agentCommand })); const { runBootOnce } = await import("./boot.js"); -const { resolveMainSessionKey } = await import("../config/sessions/main-session.js"); +const { resolveAgentIdFromSessionKey, resolveMainSessionKey } = + await import("../config/sessions/main-session.js"); +const { resolveStorePath } = await import("../config/sessions/paths.js"); +const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js"); describe("runBootOnce", () => { - beforeEach(() => { + const resolveMainStore = (cfg: { session?: { store?: string } } = {}) => { + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + return { sessionKey, storePath }; + }; + + beforeEach(async () => { vi.clearAllMocks(); + const { storePath } = resolveMainStore(); + await fs.rm(storePath, { force: true }); }); const makeDeps = () => ({ @@ -69,4 +81,115 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); + + it("generates new session ID when no existing session exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + agentCommand.mockResolvedValue(undefined); + const cfg = {}; + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify a boot-style session ID was generated (format: boot-YYYY-MM-DD_HH-MM-SS-xxx-xxxxxxxx) + expect(call?.sessionId).toMatch(/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("uses a fresh boot session ID even when main session mapping already exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + const cfg = {}; + const { sessionKey, storePath } = resolveMainStore(cfg); + const existingSessionId = "main-session-abc123"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now(), + }, + }); + + agentCommand.mockResolvedValue(undefined); + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + + expect(call?.sessionId).not.toBe(existingSessionId); + expect(call?.sessionId).toMatch(/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/); + expect(call?.sessionKey).toBe(sessionKey); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("restores the original main session mapping after the boot run", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Check if the system is healthy."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + const cfg = {}; + const { sessionKey, storePath } = resolveMainStore(cfg); + const existingSessionId = "main-session-xyz789"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now() - 60_000, // 1 minute ago + }, + }); + + agentCommand.mockImplementation(async (opts: { sessionId?: string }) => { + const current = loadSessionStore(storePath, { skipCache: true }); + current[sessionKey] = { + sessionId: String(opts.sessionId), + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, current); + }); + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + const restored = loadSessionStore(storePath, { skipCache: true }); + expect(restored[sessionKey]?.sessionId).toBe(existingSessionId); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("removes a boot-created main-session mapping when none existed before", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), "health check", "utf-8"); + + const cfg = {}; + const { sessionKey, storePath } = resolveMainStore(cfg); + + agentCommand.mockImplementation(async (opts: { sessionId?: string }) => { + const current = loadSessionStore(storePath, { skipCache: true }); + current[sessionKey] = { + sessionId: String(opts.sessionId), + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, current); + }); + + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + const restored = loadSessionStore(storePath, { skipCache: true }); + expect(restored[sessionKey]).toBeUndefined(); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); }); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index bc95c2ab6c5..e9486eac32a 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -3,9 +3,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { agentCommand } from "../commands/agent.js"; -import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { + resolveAgentIdFromSessionKey, + resolveMainSessionKey, +} from "../config/sessions/main-session.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { type RuntimeEnv, defaultRuntime } from "../runtime.js"; @@ -16,6 +22,14 @@ function generateBootSessionId(): string { return `boot-${ts}-${suffix}`; } +type SessionMappingSnapshot = { + storePath: string; + sessionKey: string; + canRestore: boolean; + hadEntry: boolean; + entry?: SessionEntry; +}; + const log = createSubsystemLogger("gateway/boot"); const BOOT_FILENAME = "BOOT.md"; @@ -58,6 +72,68 @@ async function loadBootFile( } } +function snapshotMainSessionMapping(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): SessionMappingSnapshot { + const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); + try { + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[params.sessionKey]; + if (!entry) { + return { + storePath, + sessionKey: params.sessionKey, + canRestore: true, + hadEntry: false, + }; + } + return { + storePath, + sessionKey: params.sessionKey, + canRestore: true, + hadEntry: true, + entry: structuredClone(entry), + }; + } catch (err) { + log.debug("boot: could not snapshot main session mapping", { + sessionKey: params.sessionKey, + error: String(err), + }); + return { + storePath, + sessionKey: params.sessionKey, + canRestore: false, + hadEntry: false, + }; + } +} + +async function restoreMainSessionMapping( + snapshot: SessionMappingSnapshot, +): Promise { + if (!snapshot.canRestore) { + return undefined; + } + try { + await updateSessionStore( + snapshot.storePath, + (store) => { + if (snapshot.hadEntry && snapshot.entry) { + store[snapshot.sessionKey] = snapshot.entry; + return; + } + delete store[snapshot.sessionKey]; + }, + { activeSessionKey: snapshot.sessionKey }, + ); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + export async function runBootOnce(params: { cfg: OpenClawConfig; deps: CliDeps; @@ -84,7 +160,12 @@ export async function runBootOnce(params: { const sessionKey = resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); const sessionId = generateBootSessionId(); + const mappingSnapshot = snapshotMainSessionMapping({ + cfg: params.cfg, + sessionKey, + }); + let agentFailure: string | undefined; try { await agentCommand( { @@ -96,10 +177,22 @@ export async function runBootOnce(params: { bootRuntime, params.deps, ); - return { status: "ran" }; } catch (err) { - const messageText = err instanceof Error ? err.message : String(err); - log.error(`boot: agent run failed: ${messageText}`); - return { status: "failed", reason: messageText }; + agentFailure = err instanceof Error ? err.message : String(err); + log.error(`boot: agent run failed: ${agentFailure}`); } + + const mappingRestoreFailure = await restoreMainSessionMapping(mappingSnapshot); + if (mappingRestoreFailure) { + log.error(`boot: failed to restore main session mapping: ${mappingRestoreFailure}`); + } + + if (!agentFailure && !mappingRestoreFailure) { + return { status: "ran" }; + } + const reasonParts = [ + agentFailure ? `agent run failed: ${agentFailure}` : undefined, + mappingRestoreFailure ? `mapping restore failed: ${mappingRestoreFailure}` : undefined, + ].filter((part): part is string => Boolean(part)); + return { status: "failed", reason: reasonParts.join("; ") }; } diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index a4aff647ac0..f6c5afb607a 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; const loadConfig = vi.fn(); const resolveGatewayPort = vi.fn(); @@ -331,7 +332,10 @@ describe("callGateway error details", () => { }); describe("callGateway url override auth requirements", () => { + let envSnapshot: ReturnType; + beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]); loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); @@ -345,8 +349,7 @@ describe("callGateway url override auth requirements", () => { }); afterEach(() => { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; + envSnapshot.restore(); }); it("throws when url override is set without explicit credentials", async () => { @@ -366,9 +369,10 @@ describe("callGateway url override auth requirements", () => { }); describe("callGateway password resolution", () => { - const originalEnvPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; + let envSnapshot: ReturnType; beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PASSWORD"]); loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); @@ -383,11 +387,7 @@ describe("callGateway password resolution", () => { }); afterEach(() => { - if (originalEnvPassword == null) { - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - } else { - process.env.OPENCLAW_GATEWAY_PASSWORD = originalEnvPassword; - } + envSnapshot.restore(); }); it("uses local config password when env is unset", async () => { @@ -468,9 +468,10 @@ describe("callGateway password resolution", () => { }); describe("callGateway token resolution", () => { - const originalEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN; + let envSnapshot: ReturnType; beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); @@ -485,11 +486,7 @@ describe("callGateway token resolution", () => { }); afterEach(() => { - if (originalEnvToken == null) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = originalEnvToken; - } + envSnapshot.restore(); }); it("uses explicit token when url override is set", async () => { diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts new file mode 100644 index 00000000000..9829f45c999 --- /dev/null +++ b/src/gateway/chat-abort.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import { + abortChatRunById, + type ChatAbortOps, + type ChatAbortControllerEntry, +} from "./chat-abort.js"; + +function createActiveEntry(sessionKey: string): ChatAbortControllerEntry { + const now = Date.now(); + return { + controller: new AbortController(), + sessionId: "sess-1", + sessionKey, + startedAtMs: now, + expiresAtMs: now + 10_000, + }; +} + +function createOps(params: { + runId: string; + entry: ChatAbortControllerEntry; + buffer?: string; +}): ChatAbortOps & { + broadcast: ReturnType; + nodeSendToSession: ReturnType; + removeChatRun: ReturnType; +} { + const { runId, entry, buffer } = params; + const broadcast = vi.fn(); + const nodeSendToSession = vi.fn(); + const removeChatRun = vi.fn(); + + return { + chatAbortControllers: new Map([[runId, entry]]), + chatRunBuffers: new Map(buffer !== undefined ? [[runId, buffer]] : []), + chatDeltaSentAt: new Map([[runId, Date.now()]]), + chatAbortedRuns: new Map(), + removeChatRun, + agentRunSeq: new Map(), + broadcast, + nodeSendToSession, + }; +} + +describe("abortChatRunById", () => { + it("broadcasts aborted payload with partial message when buffered text exists", () => { + const runId = "run-1"; + const sessionKey = "main"; + const entry = createActiveEntry(sessionKey); + const ops = createOps({ runId, entry, buffer: " Partial reply " }); + ops.agentRunSeq.set(runId, 2); + ops.agentRunSeq.set("client-run-1", 4); + ops.removeChatRun.mockReturnValue({ sessionKey, clientRunId: "client-run-1" }); + + const result = abortChatRunById(ops, { runId, sessionKey, stopReason: "user" }); + + expect(result).toEqual({ aborted: true }); + expect(entry.controller.signal.aborted).toBe(true); + expect(ops.chatAbortControllers.has(runId)).toBe(false); + expect(ops.chatRunBuffers.has(runId)).toBe(false); + expect(ops.chatDeltaSentAt.has(runId)).toBe(false); + expect(ops.removeChatRun).toHaveBeenCalledWith(runId, runId, sessionKey); + expect(ops.agentRunSeq.has(runId)).toBe(false); + expect(ops.agentRunSeq.has("client-run-1")).toBe(false); + + expect(ops.broadcast).toHaveBeenCalledTimes(1); + const payload = ops.broadcast.mock.calls[0]?.[1] as Record; + expect(payload).toEqual( + expect.objectContaining({ + runId, + sessionKey, + seq: 3, + state: "aborted", + stopReason: "user", + }), + ); + expect(payload.message).toEqual( + expect.objectContaining({ + role: "assistant", + content: [{ type: "text", text: " Partial reply " }], + }), + ); + expect((payload.message as { timestamp?: unknown }).timestamp).toEqual(expect.any(Number)); + expect(ops.nodeSendToSession).toHaveBeenCalledWith(sessionKey, "chat", payload); + }); + + it("omits aborted message when buffered text is empty", () => { + const runId = "run-1"; + const sessionKey = "main"; + const entry = createActiveEntry(sessionKey); + const ops = createOps({ runId, entry, buffer: " " }); + + const result = abortChatRunById(ops, { runId, sessionKey }); + + expect(result).toEqual({ aborted: true }); + const payload = ops.broadcast.mock.calls[0]?.[1] as Record; + expect(payload.message).toBeUndefined(); + }); + + it("preserves partial message even when abort listeners clear buffers synchronously", () => { + const runId = "run-1"; + const sessionKey = "main"; + const entry = createActiveEntry(sessionKey); + const ops = createOps({ runId, entry, buffer: "streamed text" }); + + // Simulate synchronous cleanup triggered by AbortController listeners. + entry.controller.signal.addEventListener("abort", () => { + ops.chatRunBuffers.delete(runId); + }); + + const result = abortChatRunById(ops, { runId, sessionKey }); + + expect(result).toEqual({ aborted: true }); + const payload = ops.broadcast.mock.calls[0]?.[1] as Record; + expect(payload.message).toEqual( + expect.objectContaining({ + role: "assistant", + content: [{ type: "text", text: "streamed text" }], + }), + ); + }); +}); diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 12c47f5b189..0d544324133 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -52,15 +52,23 @@ function broadcastChatAborted( runId: string; sessionKey: string; stopReason?: string; + partialText?: string; }, ) { - const { runId, sessionKey, stopReason } = params; + const { runId, sessionKey, stopReason, partialText } = params; const payload = { runId, sessionKey, seq: (ops.agentRunSeq.get(runId) ?? 0) + 1, state: "aborted" as const, stopReason, + message: partialText + ? { + role: "assistant", + content: [{ type: "text", text: partialText }], + timestamp: Date.now(), + } + : undefined, }; ops.broadcast("chat", payload); ops.nodeSendToSession(sessionKey, "chat", payload); @@ -83,13 +91,15 @@ export function abortChatRunById( return { aborted: false }; } + const bufferedText = ops.chatRunBuffers.get(runId); + const partialText = bufferedText && bufferedText.trim() ? bufferedText : undefined; ops.chatAbortedRuns.set(runId, Date.now()); active.controller.abort(); ops.chatAbortControllers.delete(runId); ops.chatRunBuffers.delete(runId); ops.chatDeltaSentAt.delete(runId); const removed = ops.removeChatRun(runId, runId, sessionKey); - broadcastChatAborted(ops, { runId, sessionKey, stopReason }); + broadcastChatAborted(ops, { runId, sessionKey, stopReason, partialText }); ops.agentRunSeq.delete(runId); if (removed?.clientRunId) { ops.agentRunSeq.delete(removed.clientRunId); diff --git a/src/gateway/client.maxpayload.test.ts b/src/gateway/client.maxpayload.test.ts deleted file mode 100644 index acd23da179e..00000000000 --- a/src/gateway/client.maxpayload.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { GatewayClient } from "./client.js"; - -const wsMockState = vi.hoisted(() => ({ - last: null as { url: unknown; opts: unknown } | null, -})); - -vi.mock("ws", () => ({ - WebSocket: class MockWebSocket { - on = vi.fn(); - close = vi.fn(); - send = vi.fn(); - - constructor(url: unknown, opts: unknown) { - wsMockState.last = { url, opts }; - } - }, -})); - -describe("GatewayClient", () => { - test("uses a large maxPayload for node snapshots", () => { - wsMockState.last = null; - const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); - client.start(); - - expect(wsMockState.last?.url).toBe("ws://127.0.0.1:1"); - expect(wsMockState.last?.opts).toEqual( - expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }), - ); - }); -}); diff --git a/src/gateway/control-ui.test.ts b/src/gateway/control-ui.test.ts deleted file mode 100644 index 13dd3020bf6..00000000000 --- a/src/gateway/control-ui.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { handleControlUiHttpRequest } from "./control-ui.js"; - -const makeResponse = (): { - res: ServerResponse; - setHeader: ReturnType; - end: ReturnType; -} => { - const setHeader = vi.fn(); - const end = vi.fn(); - const res = { - headersSent: false, - statusCode: 200, - setHeader, - end, - } as unknown as ServerResponse; - return { res, setHeader, end }; -}; - -describe("handleControlUiHttpRequest", () => { - it("sets anti-clickjacking headers for Control UI responses", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); - try { - await fs.writeFile(path.join(tmp, "index.html"), "\n"); - const { res, setHeader } = makeResponse(); - const handled = handleControlUiHttpRequest( - { url: "/", method: "GET" } as IncomingMessage, - res, - { - root: { kind: "resolved", path: tmp }, - }, - ); - expect(handled).toBe(true); - expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); - expect(setHeader).toHaveBeenCalledWith("Content-Security-Policy", "frame-ancestors 'none'"); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 4484bf26e52..527fc889a27 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -12,6 +12,7 @@ import { } from "./control-ui-shared.js"; const ROOT_PREFIX = "/"; +const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json"; export type ControlUiRequestOptions = { basePath?: string; @@ -68,8 +69,24 @@ type ControlUiAvatarMeta = { function applyControlUiSecurityHeaders(res: ServerResponse) { res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + // Control UI: block framing, block inline scripts, keep styles permissive + // (UI uses a lot of inline style attributes in templates). + res.setHeader( + "Content-Security-Policy", + [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self' ws: wss:", + ].join("; "), + ); res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "no-referrer"); } function sendJson(res: ServerResponse, status: number, body: unknown) { @@ -160,66 +177,10 @@ function serveFile(res: ServerResponse, filePath: string) { res.end(fs.readFileSync(filePath)); } -interface ControlUiInjectionOpts { - basePath: string; - assistantName?: string; - assistantAvatar?: string; -} - -function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string { - const { basePath, assistantName, assistantAvatar } = opts; - const script = - ``; - // Check if already injected - if (html.includes("__OPENCLAW_ASSISTANT_NAME__")) { - return html; - } - const headClose = html.indexOf(""); - if (headClose !== -1) { - return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`; - } - return `${script}${html}`; -} - -interface ServeIndexHtmlOpts { - basePath: string; - config?: OpenClawConfig; - agentId?: string; -} - -function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) { - const { basePath, config, agentId } = opts; - const identity = config - ? resolveAssistantIdentity({ cfg: config, agentId }) - : DEFAULT_ASSISTANT_IDENTITY; - const resolvedAgentId = - typeof (identity as { agentId?: string }).agentId === "string" - ? (identity as { agentId?: string }).agentId - : agentId; - const avatarValue = - resolveAssistantAvatarUrl({ - avatar: identity.avatar, - agentId: resolvedAgentId, - basePath, - }) ?? identity.avatar; +function serveIndexHtml(res: ServerResponse, indexPath: string) { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); - const raw = fs.readFileSync(indexPath, "utf8"); - res.end( - injectControlUiConfig(raw, { - basePath, - assistantName: identity.name, - assistantAvatar: avatarValue, - }), - ); + res.end(fs.readFileSync(indexPath, "utf8")); } function isSafeRelativePath(relPath: string) { @@ -279,6 +240,35 @@ export function handleControlUiHttpRequest( applyControlUiSecurityHeaders(res); + const bootstrapConfigPath = basePath + ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` + : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; + if (pathname === bootstrapConfigPath) { + const config = opts?.config; + const identity = config + ? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId }) + : DEFAULT_ASSISTANT_IDENTITY; + const avatarValue = resolveAssistantAvatarUrl({ + avatar: identity.avatar, + agentId: identity.agentId, + basePath, + }); + if (req.method === "HEAD") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache"); + res.end(); + return true; + } + sendJson(res, 200, { + basePath, + assistantName: identity.name, + assistantAvatar: avatarValue ?? identity.avatar, + assistantAgentId: identity.agentId, + }); + return true; + } + const rootState = opts?.root; if (rootState?.kind === "invalid") { res.statusCode = 503; @@ -341,11 +331,7 @@ export function handleControlUiHttpRequest( if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { if (path.basename(filePath) === "index.html") { - serveIndexHtml(res, filePath, { - basePath, - config: opts?.config, - agentId: opts?.agentId, - }); + serveIndexHtml(res, filePath); return true; } serveFile(res, filePath); @@ -355,11 +341,7 @@ export function handleControlUiHttpRequest( // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); if (fs.existsSync(indexPath)) { - serveIndexHtml(res, indexPath, { - basePath, - config: opts?.config, - agentId: opts?.agentId, - }); + serveIndexHtml(res, indexPath); return true; } diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts new file mode 100644 index 00000000000..a510f93550b --- /dev/null +++ b/src/gateway/gateway-misc.test.ts @@ -0,0 +1,370 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, test, vi } from "vitest"; +import type { RequestFrame } from "./protocol/index.js"; +import type { GatewayClient as GatewayMethodClient } from "./server-methods/types.js"; +import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; +import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; +import { GatewayClient } from "./client.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; +import { + DEFAULT_DANGEROUS_NODE_COMMANDS, + resolveNodeCommandAllowlist, +} from "./node-command-policy.js"; +import { createGatewayBroadcaster } from "./server-broadcast.js"; +import { createChatRunRegistry } from "./server-chat.js"; +import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js"; +import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; +import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; + +const wsMockState = vi.hoisted(() => ({ + last: null as { url: unknown; opts: unknown } | null, +})); + +vi.mock("ws", () => ({ + WebSocket: class MockWebSocket { + on = vi.fn(); + close = vi.fn(); + send = vi.fn(); + + constructor(url: unknown, opts: unknown) { + wsMockState.last = { url, opts }; + } + }, +})); + +describe("GatewayClient", () => { + test("uses a large maxPayload for node snapshots", () => { + wsMockState.last = null; + const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); + client.start(); + + expect(wsMockState.last?.url).toBe("ws://127.0.0.1:1"); + expect(wsMockState.last?.opts).toEqual( + expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }), + ); + }); +}); + +const makeControlUiResponse = (): { + res: ServerResponse; + setHeader: ReturnType; + end: ReturnType; +} => { + const setHeader = vi.fn(); + const end = vi.fn(); + const res = { + headersSent: false, + statusCode: 200, + setHeader, + end, + } as unknown as ServerResponse; + return { res, setHeader, end }; +}; + +describe("handleControlUiHttpRequest", () => { + it("sets anti-clickjacking headers for Control UI responses", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "\n"); + const { res, setHeader } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/", method: "GET" } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + }, + ); + expect(handled).toBe(true); + expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); + const csp = setHeader.mock.calls.find((call) => call[0] === "Content-Security-Policy")?.[1]; + expect(typeof csp).toBe("string"); + expect(String(csp)).toContain("frame-ancestors 'none'"); + expect(String(csp)).toContain("script-src 'self'"); + expect(String(csp)).not.toContain("script-src 'self' 'unsafe-inline'"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("does not inject inline scripts into index.html", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const html = "Hello\n"; + await fs.writeFile(path.join(tmp, "index.html"), html); + const { res, end } = makeControlUiResponse(); + const handled = handleControlUiHttpRequest( + { url: "/", method: "GET" } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + config: { + agents: { defaults: { workspace: tmp } }, + ui: { assistant: { name: ".png" } }, + }, + }, + ); + expect(handled).toBe(true); + const payload = String(end.mock.calls[0]?.[0] ?? ""); + const parsed = JSON.parse(payload) as { + basePath: string; + assistantName: string; + assistantAvatar: string; + assistantAgentId: string; + }; + expect(parsed.basePath).toBe(""); + expect(parsed.assistantName).toBe("